|
|
|
|
@ -5,8 +5,8 @@ import com.imeeting.dto.android.AndroidChunkUploadSessionState;
|
|
|
|
|
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
|
|
|
|
|
import com.imeeting.service.android.AndroidChunkUploadService;
|
|
|
|
|
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
|
|
|
|
|
import com.imeeting.service.biz.MeetingCommandService;
|
|
|
|
|
import com.imeeting.support.redis.AndroidChunkUploadSessionCache;
|
|
|
|
|
import com.unisbase.security.LoginUser;
|
|
|
|
|
import lombok.RequiredArgsConstructor;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
@ -32,10 +32,13 @@ import java.util.regex.Pattern;
|
|
|
|
|
@Service
|
|
|
|
|
@RequiredArgsConstructor
|
|
|
|
|
public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService {
|
|
|
|
|
private static final Pattern CHUNK_FILE_NAME_PATTERN = Pattern.compile("^chunk-(\\d+)(\\..+)?$");
|
|
|
|
|
private static final Pattern LEGACY_CHUNK_FILE_NAME_PATTERN = Pattern.compile("^chunk-(\\d+)(\\..+)?$");
|
|
|
|
|
private static final Pattern CHUNK_DIR_NAME_PATTERN = Pattern.compile("^chunk-(\\d+)$");
|
|
|
|
|
private static final String CHUNK_ROOT_DIR = "chunks";
|
|
|
|
|
|
|
|
|
|
private final AndroidChunkUploadSessionCache sessionCache;
|
|
|
|
|
private final LegacyMeetingAdapterService legacyMeetingAdapterService;
|
|
|
|
|
private final MeetingCommandService meetingCommandService;
|
|
|
|
|
|
|
|
|
|
@Value("${unisbase.app.upload-path}")
|
|
|
|
|
private String uploadPath;
|
|
|
|
|
@ -54,7 +57,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|
|
|
|
if (chunkIndex == null || chunkIndex < 0) {
|
|
|
|
|
throw new RuntimeException("分片参数无效");
|
|
|
|
|
}
|
|
|
|
|
if (chunkFile == null) {
|
|
|
|
|
if (chunkFile == null) {
|
|
|
|
|
throw new RuntimeException("chunk_file不能为空");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -63,19 +66,19 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|
|
|
|
throw new RuntimeException("分片上传会话与当前设备或会议不匹配");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String chunkFileName = buildStoredChunkFileName(chunkIndex, chunkFile.getOriginalFilename());
|
|
|
|
|
String previousFileName = state.getChunkFileNames().get(chunkIndex);
|
|
|
|
|
Path meetingDir = sessionDir(meetingId);
|
|
|
|
|
Files.createDirectories(meetingDir);
|
|
|
|
|
String originalFileName = resolveOriginalFileName(chunkFile.getOriginalFilename());
|
|
|
|
|
Path chunkFilePath = resolveChunkFilePath(meetingId, chunkIndex, originalFileName);
|
|
|
|
|
Path chunkDir = chunkFilePath.getParent();
|
|
|
|
|
clearChunkDirectory(chunkDir);
|
|
|
|
|
Files.createDirectories(chunkDir);
|
|
|
|
|
|
|
|
|
|
if (previousFileName != null && !previousFileName.equals(chunkFileName)) {
|
|
|
|
|
deleteQuietly(meetingDir.resolve(previousFileName));
|
|
|
|
|
Files.write(chunkFilePath, chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
|
|
|
|
|
|
|
|
|
String previousFileName = state.getChunkFileNames().put(chunkIndex, originalFileName);
|
|
|
|
|
if (previousFileName != null && !previousFileName.equals(originalFileName)) {
|
|
|
|
|
state.getUploadedChunkFileNames().remove(previousFileName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Files.write(meetingDir.resolve(chunkFileName), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
|
|
|
|
state.getUploadedChunkFileNames().add(chunkFileName);
|
|
|
|
|
state.getChunkFileNames().put(chunkIndex, chunkFileName);
|
|
|
|
|
state.getUploadedChunkFileNames().add(originalFileName);
|
|
|
|
|
state.getReceivedChunks().add(chunkIndex);
|
|
|
|
|
saveState(meetingId, state);
|
|
|
|
|
}
|
|
|
|
|
@ -91,47 +94,39 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|
|
|
|
throw new RuntimeException("total_chunks不能为空且必须大于0");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
AndroidChunkUploadSessionState state = loadStateForCompletion(meetingId, authContext);
|
|
|
|
|
AndroidChunkUploadSessionState state = loadStateForCompletion(meetingId, authContext);
|
|
|
|
|
if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) {
|
|
|
|
|
throw new RuntimeException("分片上传会话与当前设备或会议不匹配");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Path meetingDir = sessionDir(meetingId);
|
|
|
|
|
Files.createDirectories(meetingDir);
|
|
|
|
|
Path meetingDir = sessionDir(meetingId);
|
|
|
|
|
Files.createDirectories(meetingDir);
|
|
|
|
|
|
|
|
|
|
state.setTotalChunks(totalChunks);
|
|
|
|
|
List<Path> orderedChunkPaths = rebuildChunkStateFromDisk(state, meetingDir, totalChunks);
|
|
|
|
|
List<Path> orderedChunkPaths = rebuildChunkStateFromDisk(state, meetingDir, totalChunks);
|
|
|
|
|
saveState(meetingId, state);
|
|
|
|
|
|
|
|
|
|
Path mergedFile = mergeChunks(state, orderedChunkPaths);
|
|
|
|
|
Path mergedFile = mergeChunks(state, orderedChunkPaths);
|
|
|
|
|
if (mergedFile == null) {
|
|
|
|
|
meetingCommandService.failOfflineTranscription(meetingId, "安卓上传文件为空");
|
|
|
|
|
cleanup(meetingId);
|
|
|
|
|
return new LegacyUploadAudioResponse(meetingId, null, "无可合并音频");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
MultipartFile mergedMultipart = new LocalMultipartFile(
|
|
|
|
|
buildMergedOriginalFilename(state, mergedFile),
|
|
|
|
|
resolveMergedOriginalFilename(state, orderedChunkPaths, mergedFile),
|
|
|
|
|
state.getContentType(),
|
|
|
|
|
Files.readAllBytes(mergedFile)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
LegacyUploadAudioResponse response;
|
|
|
|
|
if (authContext.isAnonymous()) {
|
|
|
|
|
response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice(
|
|
|
|
|
meetingId,
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
false,
|
|
|
|
|
mergedMultipart,
|
|
|
|
|
authContext
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
LoginUser loginUser = toLoginUser(authContext);
|
|
|
|
|
response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcess(
|
|
|
|
|
meetingId,
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
false,
|
|
|
|
|
mergedMultipart,
|
|
|
|
|
authContext,
|
|
|
|
|
loginUser
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
LegacyUploadAudioResponse response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice(
|
|
|
|
|
meetingId,
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
false,
|
|
|
|
|
mergedMultipart,
|
|
|
|
|
authContext
|
|
|
|
|
);
|
|
|
|
|
if (response != null) {
|
|
|
|
|
cleanup(meetingId);
|
|
|
|
|
}
|
|
|
|
|
@ -148,155 +143,170 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|
|
|
|
AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState();
|
|
|
|
|
state.setMeetingId(meetingId);
|
|
|
|
|
state.setDeviceId(authContext.getDeviceId());
|
|
|
|
|
state.setFileName(normalizeChunkSourceFileName(chunkFile.getOriginalFilename()));
|
|
|
|
|
state.setFileName(resolveOriginalFileName(chunkFile.getOriginalFilename()));
|
|
|
|
|
state.setContentType(chunkFile.getContentType());
|
|
|
|
|
saveState(meetingId, state);
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private AndroidChunkUploadSessionState loadStateForCompletion(Long meetingId, AndroidAuthContext authContext) {
|
|
|
|
|
AndroidChunkUploadSessionState state = getState(meetingId);
|
|
|
|
|
if (state != null) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
AndroidChunkUploadSessionState rebuiltState = new AndroidChunkUploadSessionState();
|
|
|
|
|
rebuiltState.setMeetingId(meetingId);
|
|
|
|
|
rebuiltState.setDeviceId(authContext.getDeviceId());
|
|
|
|
|
return rebuiltState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private List<Path> rebuildChunkStateFromDisk(AndroidChunkUploadSessionState state,
|
|
|
|
|
Path meetingDir,
|
|
|
|
|
int totalChunks) throws IOException {
|
|
|
|
|
Map<Integer, Path> validatedChunkFiles = scanChunkFiles(meetingDir, true);
|
|
|
|
|
Map<Integer, Path> mergeChunkFiles = scanChunkFiles(meetingDir, false);
|
|
|
|
|
state.getReceivedChunks().clear();
|
|
|
|
|
state.getUploadedChunkFileNames().clear();
|
|
|
|
|
state.getChunkFileNames().clear();
|
|
|
|
|
|
|
|
|
|
for (Map.Entry<Integer, Path> entry : validatedChunkFiles.entrySet()) {
|
|
|
|
|
Integer chunkIndex = entry.getKey();
|
|
|
|
|
Path stateChunkPath = mergeChunkFiles.getOrDefault(chunkIndex, entry.getValue());
|
|
|
|
|
String fileName = stateChunkPath.getFileName().toString();
|
|
|
|
|
state.getReceivedChunks().add(chunkIndex);
|
|
|
|
|
state.getUploadedChunkFileNames().add(fileName);
|
|
|
|
|
state.getChunkFileNames().put(chunkIndex, fileName);
|
|
|
|
|
private AndroidChunkUploadSessionState loadStateForCompletion(Long meetingId, AndroidAuthContext authContext) {
|
|
|
|
|
AndroidChunkUploadSessionState state = getState(meetingId);
|
|
|
|
|
if (state != null) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
AndroidChunkUploadSessionState rebuiltState = new AndroidChunkUploadSessionState();
|
|
|
|
|
rebuiltState.setMeetingId(meetingId);
|
|
|
|
|
rebuiltState.setDeviceId(authContext.getDeviceId());
|
|
|
|
|
return rebuiltState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<Path> orderedChunkPaths = new ArrayList<>(totalChunks);
|
|
|
|
|
for (int i = 0; i < totalChunks; i++) {
|
|
|
|
|
Path validatedChunkPath = validatedChunkFiles.get(i);
|
|
|
|
|
if (validatedChunkPath == null) {
|
|
|
|
|
throw new RuntimeException("分片未上传完整");
|
|
|
|
|
}
|
|
|
|
|
Path chunkPath = mergeChunkFiles.get(i);
|
|
|
|
|
if (chunkPath != null) {
|
|
|
|
|
orderedChunkPaths.add(chunkPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return orderedChunkPaths;
|
|
|
|
|
}
|
|
|
|
|
private List<Path> rebuildChunkStateFromDisk(AndroidChunkUploadSessionState state,
|
|
|
|
|
Path meetingDir,
|
|
|
|
|
int totalChunks) throws IOException {
|
|
|
|
|
Map<Integer, Path> chunkFiles = scanChunkFiles(meetingDir, true);
|
|
|
|
|
state.getReceivedChunks().clear();
|
|
|
|
|
state.getUploadedChunkFileNames().clear();
|
|
|
|
|
state.getChunkFileNames().clear();
|
|
|
|
|
|
|
|
|
|
private Map<Integer, Path> scanChunkFiles(Path meetingDir, boolean includePending) throws IOException {
|
|
|
|
|
Map<Integer, Path> chunkFiles = new TreeMap<>();
|
|
|
|
|
if (!Files.exists(meetingDir)) {
|
|
|
|
|
return chunkFiles;
|
|
|
|
|
}
|
|
|
|
|
try (var paths = Files.list(meetingDir)) {
|
|
|
|
|
paths.filter(Files::isRegularFile)
|
|
|
|
|
.filter(path -> isUsableChunkFile(path, includePending))
|
|
|
|
|
.forEach(path -> {
|
|
|
|
|
Integer chunkIndex = parseChunkIndex(path.getFileName().toString(), includePending);
|
|
|
|
|
if (chunkIndex == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
Path existing = chunkFiles.get(chunkIndex);
|
|
|
|
|
if (existing == null || isPreferredChunkFile(path, existing)) {
|
|
|
|
|
chunkFiles.put(chunkIndex, path);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return chunkFiles;
|
|
|
|
|
}
|
|
|
|
|
for (Map.Entry<Integer, Path> entry : chunkFiles.entrySet()) {
|
|
|
|
|
Integer chunkIndex = entry.getKey();
|
|
|
|
|
Path chunkPath = entry.getValue();
|
|
|
|
|
String fileName = chunkPath.getFileName().toString();
|
|
|
|
|
state.getReceivedChunks().add(chunkIndex);
|
|
|
|
|
state.getUploadedChunkFileNames().add(fileName);
|
|
|
|
|
state.getChunkFileNames().put(chunkIndex, fileName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean isUsableChunkFile(Path path, boolean includePending) {
|
|
|
|
|
if (path == null || path.getFileName() == null) {
|
|
|
|
|
return false;
|
|
|
|
|
List<Path> orderedChunkPaths = new ArrayList<>(totalChunks);
|
|
|
|
|
for (int i = 0; i < totalChunks; i++) {
|
|
|
|
|
Path chunkPath = chunkFiles.get(i);
|
|
|
|
|
if (chunkPath == null) {
|
|
|
|
|
throw new RuntimeException("分片未上传完整");
|
|
|
|
|
}
|
|
|
|
|
orderedChunkPaths.add(chunkPath);
|
|
|
|
|
}
|
|
|
|
|
return orderedChunkPaths;
|
|
|
|
|
}
|
|
|
|
|
String fileName = normalizeChunkStorageFileName(path.getFileName().toString(), includePending);
|
|
|
|
|
if (fileName == null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return CHUNK_FILE_NAME_PATTERN.matcher(fileName).matches();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Integer parseChunkIndex(String fileName, boolean includePending) {
|
|
|
|
|
String normalizedFileName = normalizeChunkStorageFileName(fileName, includePending);
|
|
|
|
|
if (normalizedFileName == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
Matcher matcher = CHUNK_FILE_NAME_PATTERN.matcher(normalizedFileName);
|
|
|
|
|
if (!matcher.matches()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return Integer.parseInt(matcher.group(1));
|
|
|
|
|
}
|
|
|
|
|
private Map<Integer, Path> scanChunkFiles(Path meetingDir, boolean includePending) throws IOException {
|
|
|
|
|
Map<Integer, Path> chunkFiles = new TreeMap<>();
|
|
|
|
|
if (!Files.exists(meetingDir)) {
|
|
|
|
|
return chunkFiles;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean isPreferredChunkFile(Path candidate, Path existing) {
|
|
|
|
|
boolean candidatePending = isPendingChunkFile(candidate);
|
|
|
|
|
boolean existingPending = isPendingChunkFile(existing);
|
|
|
|
|
if (candidatePending != existingPending) {
|
|
|
|
|
return !candidatePending;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return Files.getLastModifiedTime(candidate).compareTo(Files.getLastModifiedTime(existing)) >= 0;
|
|
|
|
|
} catch (IOException ex) {
|
|
|
|
|
return candidate.getFileName().toString().compareTo(existing.getFileName().toString()) >= 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Path chunkRoot = meetingDir.resolve(CHUNK_ROOT_DIR);
|
|
|
|
|
if (Files.exists(chunkRoot)) {
|
|
|
|
|
try (var dirs = Files.list(chunkRoot)) {
|
|
|
|
|
dirs.filter(Files::isDirectory).forEach(dir -> {
|
|
|
|
|
Integer chunkIndex = parseChunkDirIndex(dir.getFileName().toString());
|
|
|
|
|
if (chunkIndex == null || chunkFiles.containsKey(chunkIndex)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
Path chunkPath = pickChunkFile(dir, includePending);
|
|
|
|
|
if (chunkPath != null) {
|
|
|
|
|
chunkFiles.put(chunkIndex, chunkPath);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!chunkFiles.isEmpty()) {
|
|
|
|
|
return chunkFiles;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String normalizeChunkStorageFileName(String fileName, boolean includePending) {
|
|
|
|
|
if (fileName == null) {
|
|
|
|
|
return null;
|
|
|
|
|
try (var paths = Files.list(meetingDir)) {
|
|
|
|
|
paths.filter(Files::isRegularFile)
|
|
|
|
|
.forEach(path -> {
|
|
|
|
|
Integer chunkIndex = parseLegacyChunkIndex(path.getFileName().toString());
|
|
|
|
|
if (chunkIndex != null) {
|
|
|
|
|
chunkFiles.put(chunkIndex, path);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return chunkFiles;
|
|
|
|
|
}
|
|
|
|
|
if (fileName.endsWith(".pending")) {
|
|
|
|
|
return includePending ? fileName.substring(0, fileName.length() - ".pending".length()) : null;
|
|
|
|
|
|
|
|
|
|
private Integer parseChunkDirIndex(String directoryName) {
|
|
|
|
|
if (directoryName == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
Matcher matcher = CHUNK_DIR_NAME_PATTERN.matcher(directoryName);
|
|
|
|
|
if (!matcher.matches()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return Integer.parseInt(matcher.group(1));
|
|
|
|
|
}
|
|
|
|
|
return fileName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean isPendingChunkFile(Path path) {
|
|
|
|
|
return path != null && path.getFileName() != null && path.getFileName().toString().endsWith(".pending");
|
|
|
|
|
}
|
|
|
|
|
private Integer parseLegacyChunkIndex(String fileName) {
|
|
|
|
|
if (fileName == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
Matcher matcher = LEGACY_CHUNK_FILE_NAME_PATTERN.matcher(fileName);
|
|
|
|
|
if (!matcher.matches()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return Integer.parseInt(matcher.group(1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Path mergeChunks(AndroidChunkUploadSessionState state, List<Path> chunkPaths) throws IOException {
|
|
|
|
|
private Path pickChunkFile(Path chunkDir, boolean includePending) {
|
|
|
|
|
if (chunkDir == null || !Files.isDirectory(chunkDir)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
try (var files = Files.list(chunkDir)) {
|
|
|
|
|
Path preferred = files
|
|
|
|
|
.filter(Files::isRegularFile)
|
|
|
|
|
.filter(path -> includePending || !isPendingChunkFile(path))
|
|
|
|
|
.findFirst()
|
|
|
|
|
.orElse(null);
|
|
|
|
|
if (preferred != null) {
|
|
|
|
|
return preferred;
|
|
|
|
|
}
|
|
|
|
|
} catch (IOException ex) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
try (var files = Files.list(chunkDir)) {
|
|
|
|
|
return files.filter(Files::isRegularFile).findFirst().orElse(null);
|
|
|
|
|
} catch (IOException ex) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Path mergeChunks(AndroidChunkUploadSessionState state, List<Path> chunkPaths) throws IOException {
|
|
|
|
|
List<Path> mergeableChunkPaths = filterMergeableChunkPaths(chunkPaths);
|
|
|
|
|
Path meetingDir = sessionDir(state.getMeetingId());
|
|
|
|
|
String mergedExtension = resolveMergedExtension(state, chunkPaths);
|
|
|
|
|
String mergedExtension = resolveMergedExtension(state, mergeableChunkPaths);
|
|
|
|
|
Path mergedOutput = meetingDir.resolve("merged" + mergedExtension);
|
|
|
|
|
Path concatList = meetingDir.resolve("concat-inputs.txt");
|
|
|
|
|
Files.deleteIfExists(mergedOutput);
|
|
|
|
|
|
|
|
|
|
if (chunkPaths.isEmpty() || allChunkFilesEmpty(chunkPaths)) {
|
|
|
|
|
Files.write(mergedOutput, new byte[0], StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
|
|
|
|
return mergedOutput;
|
|
|
|
|
}
|
|
|
|
|
if (mergeableChunkPaths.isEmpty() || allChunkFilesEmpty(mergeableChunkPaths)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (chunkPaths.size() == 1) {
|
|
|
|
|
return chunkPaths.get(0);
|
|
|
|
|
}
|
|
|
|
|
if (mergeableChunkPaths.size() == 1) {
|
|
|
|
|
return mergeableChunkPaths.get(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeConcatListFile(concatList, chunkPaths);
|
|
|
|
|
writeConcatListFile(concatList, mergeableChunkPaths);
|
|
|
|
|
executeFfmpegConcat(concatList, mergedOutput);
|
|
|
|
|
return mergedOutput;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean allChunkFilesEmpty(List<Path> chunkPaths) throws IOException {
|
|
|
|
|
for (Path chunkPath : chunkPaths) {
|
|
|
|
|
if (Files.size(chunkPath) > 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
private boolean allChunkFilesEmpty(List<Path> chunkPaths) throws IOException {
|
|
|
|
|
for (Path chunkPath : chunkPaths) {
|
|
|
|
|
if (Files.size(chunkPath) > 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
private List<Path> filterMergeableChunkPaths(List<Path> chunkPaths) {
|
|
|
|
|
if (chunkPaths == null || chunkPaths.isEmpty()) {
|
|
|
|
|
return List.of();
|
|
|
|
|
}
|
|
|
|
|
return chunkPaths.stream()
|
|
|
|
|
.filter(path -> path != null && !isPendingChunkFile(path))
|
|
|
|
|
.toList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private AndroidChunkUploadSessionState getState(Long meetingId) {
|
|
|
|
|
@ -329,22 +339,19 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|
|
|
|
return Paths.get(normalizedBasePath, "android-chunks", String.valueOf(meetingId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String buildStoredChunkFileName(Integer chunkIndex, String originalFileName) {
|
|
|
|
|
String normalizedSourceName = normalizeChunkSourceFileName(originalFileName);
|
|
|
|
|
int extensionIndex = normalizedSourceName.lastIndexOf('.');
|
|
|
|
|
String extension = extensionIndex >= 0 ? normalizedSourceName.substring(extensionIndex) : "";
|
|
|
|
|
String safeExtension = extension.isBlank() ? ".bin" : extension;
|
|
|
|
|
return "chunk-" + (chunkIndex == null ? "unknown" : chunkIndex) + safeExtension;
|
|
|
|
|
private Path resolveChunkFilePath(Long meetingId, Integer chunkIndex, String originalFileName) {
|
|
|
|
|
return sessionDir(meetingId)
|
|
|
|
|
.resolve(CHUNK_ROOT_DIR)
|
|
|
|
|
.resolve("chunk-" + chunkIndex)
|
|
|
|
|
.resolve(originalFileName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String buildMergedOriginalFilename(AndroidChunkUploadSessionState state, Path mergedFile) {
|
|
|
|
|
if (state.getFileName() != null && !state.getFileName().isBlank()) {
|
|
|
|
|
String normalizedSourceName = normalizeChunkSourceFileName(state.getFileName());
|
|
|
|
|
int extensionIndex = normalizedSourceName.lastIndexOf('.');
|
|
|
|
|
if (extensionIndex >= 0) {
|
|
|
|
|
return "merged" + normalizedSourceName.substring(extensionIndex);
|
|
|
|
|
}
|
|
|
|
|
return normalizedSourceName;
|
|
|
|
|
private String resolveMergedOriginalFilename(AndroidChunkUploadSessionState state, List<Path> chunkPaths, Path mergedFile) {
|
|
|
|
|
if (state != null && state.getFileName() != null && !state.getFileName().isBlank()) {
|
|
|
|
|
return state.getFileName();
|
|
|
|
|
}
|
|
|
|
|
if (chunkPaths != null && !chunkPaths.isEmpty() && chunkPaths.get(0).getFileName() != null) {
|
|
|
|
|
return chunkPaths.get(0).getFileName().toString();
|
|
|
|
|
}
|
|
|
|
|
return mergedFile == null || mergedFile.getFileName() == null ? "meeting-audio.bin" : mergedFile.getFileName().toString();
|
|
|
|
|
}
|
|
|
|
|
@ -353,41 +360,56 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|
|
|
|
if (chunkPath == null || chunkPath.getFileName() == null) {
|
|
|
|
|
return ".bin";
|
|
|
|
|
}
|
|
|
|
|
String fileName = normalizeChunkSourceFileName(chunkPath.getFileName().toString());
|
|
|
|
|
String fileName = chunkPath.getFileName().toString();
|
|
|
|
|
int extensionIndex = fileName.lastIndexOf('.');
|
|
|
|
|
return extensionIndex >= 0 ? fileName.substring(extensionIndex) : ".bin";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String resolveMergedExtension(AndroidChunkUploadSessionState state, List<Path> chunkPaths) {
|
|
|
|
|
if (chunkPaths != null && !chunkPaths.isEmpty()) {
|
|
|
|
|
return resolveMergedExtension(chunkPaths.get(0));
|
|
|
|
|
}
|
|
|
|
|
if (state != null && state.getFileName() != null && !state.getFileName().isBlank()) {
|
|
|
|
|
int extensionIndex = state.getFileName().lastIndexOf('.');
|
|
|
|
|
if (extensionIndex >= 0) {
|
|
|
|
|
return state.getFileName().substring(extensionIndex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ".bin";
|
|
|
|
|
private String resolveMergedExtension(AndroidChunkUploadSessionState state, List<Path> chunkPaths) {
|
|
|
|
|
if (chunkPaths != null && !chunkPaths.isEmpty()) {
|
|
|
|
|
return resolveMergedExtension(chunkPaths.get(0));
|
|
|
|
|
}
|
|
|
|
|
if (state != null && state.getFileName() != null && !state.getFileName().isBlank()) {
|
|
|
|
|
int extensionIndex = state.getFileName().lastIndexOf('.');
|
|
|
|
|
if (extensionIndex >= 0) {
|
|
|
|
|
return state.getFileName().substring(extensionIndex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ".bin";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String normalizeChunkSourceFileName(String fileName) {
|
|
|
|
|
if (fileName == null) {
|
|
|
|
|
return "";
|
|
|
|
|
private boolean isPendingChunkFile(Path path) {
|
|
|
|
|
return path != null && path.getFileName() != null && path.getFileName().toString().endsWith(".pending");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String resolveOriginalFileName(String originalFileName) {
|
|
|
|
|
if (originalFileName == null || originalFileName.trim().isEmpty()) {
|
|
|
|
|
throw new RuntimeException("chunk_file原始文件名不能为空");
|
|
|
|
|
}
|
|
|
|
|
String normalized = Paths.get(fileName.trim()).getFileName().toString();
|
|
|
|
|
if (normalized.endsWith(".pending")) {
|
|
|
|
|
return normalized.substring(0, normalized.length() - ".pending".length());
|
|
|
|
|
if (originalFileName.contains("/") || originalFileName.contains("\\")) {
|
|
|
|
|
throw new RuntimeException("chunk_file文件名不合法");
|
|
|
|
|
}
|
|
|
|
|
return originalFileName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void clearChunkDirectory(Path chunkDir) throws IOException {
|
|
|
|
|
if (chunkDir == null || !Files.exists(chunkDir)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try (var paths = Files.walk(chunkDir)) {
|
|
|
|
|
paths.sorted((left, right) -> right.compareTo(left)).forEach(path -> {
|
|
|
|
|
try {
|
|
|
|
|
Files.deleteIfExists(path);
|
|
|
|
|
} catch (IOException ex) {
|
|
|
|
|
throw new RuntimeException(ex);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void writeConcatListFile(Path concatList, List<Path> chunkPaths) throws IOException {
|
|
|
|
|
List<String> lines = new ArrayList<>(chunkPaths.size());
|
|
|
|
|
for (Path chunkPath : chunkPaths) {
|
|
|
|
|
if (chunkPath.getFileName() != null && chunkPath.getFileName().toString().endsWith(".pending")) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
String normalizedPath = chunkPath.toAbsolutePath().toString().replace("'", "'\\''");
|
|
|
|
|
lines.add("file '" + normalizedPath + "'");
|
|
|
|
|
}
|
|
|
|
|
@ -424,37 +446,11 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|
|
|
|
if (process.exitValue() != 0) {
|
|
|
|
|
throw new IOException("音频分片合并失败: " + new String(output, StandardCharsets.UTF_8));
|
|
|
|
|
}
|
|
|
|
|
if (!Files.exists(mergedOutput)) {
|
|
|
|
|
if (!Files.exists(mergedOutput)) {
|
|
|
|
|
throw new IOException("音频分片合并结果为空");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void deleteQuietly(Path path) {
|
|
|
|
|
if (path == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
Files.deleteIfExists(path);
|
|
|
|
|
} catch (IOException ignored) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private LoginUser toLoginUser(AndroidAuthContext authContext) {
|
|
|
|
|
if (authContext == null || authContext.isAnonymous() || authContext.getUserId() == null || authContext.getTenantId() == null) {
|
|
|
|
|
throw new RuntimeException("安卓用户未登录或认证无效");
|
|
|
|
|
}
|
|
|
|
|
LoginUser loginUser = new LoginUser(
|
|
|
|
|
authContext.getUserId(),
|
|
|
|
|
authContext.getTenantId(),
|
|
|
|
|
authContext.getUsername(),
|
|
|
|
|
authContext.getPlatformAdmin(),
|
|
|
|
|
authContext.getTenantAdmin(),
|
|
|
|
|
authContext.getPermissions()
|
|
|
|
|
);
|
|
|
|
|
loginUser.setDisplayName(authContext.getDisplayName());
|
|
|
|
|
return loginUser;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static final class LocalMultipartFile implements MultipartFile {
|
|
|
|
|
private final String originalFilename;
|
|
|
|
|
private final String contentType;
|
|
|
|
|
|