feat: batch-delete recording segments whose files are missing
Add a segment-level cleanup alongside the existing session-level one. DeleteMissingFileTasksAsync scans all non-active record tasks, keeps those whose video file no longer exists on disk, and deletes them individually via DeleteTasksAsync (which also drops any session left empty). Exposed as POST /record-tasks/delete-missing-files and a new "清理无文件分片" action in the record tasks view. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,7 @@ const uploadingTaskId = ref<string | null>(null);
|
|||||||
const triggeringSegmentCompletedTaskId = ref<string | null>(null);
|
const triggeringSegmentCompletedTaskId = ref<string | null>(null);
|
||||||
const cleanupOperation = ref<CleanupOperation | null>(null);
|
const cleanupOperation = ref<CleanupOperation | null>(null);
|
||||||
const deleteDialogVisible = ref(false);
|
const deleteDialogVisible = ref(false);
|
||||||
const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions">("tasks");
|
const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions" | "missing-file-tasks">("tasks");
|
||||||
const deleteDialogTaskIds = ref<string[]>([]);
|
const deleteDialogTaskIds = ref<string[]>([]);
|
||||||
const deleteDialogSessionIds = ref<string[]>([]);
|
const deleteDialogSessionIds = ref<string[]>([]);
|
||||||
|
|
||||||
@@ -174,6 +174,10 @@ const deleteDialogEyebrow = computed(() => {
|
|||||||
return "空闲会话清理";
|
return "空闲会话清理";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||||
|
return "无文件分片清理";
|
||||||
|
}
|
||||||
|
|
||||||
return "删除确认";
|
return "删除确认";
|
||||||
});
|
});
|
||||||
const deleteDialogTitle = computed(() => {
|
const deleteDialogTitle = computed(() => {
|
||||||
@@ -193,6 +197,10 @@ const deleteDialogTitle = computed(() => {
|
|||||||
return "清理无分片会话";
|
return "清理无分片会话";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||||
|
return "清理无文件分片";
|
||||||
|
}
|
||||||
|
|
||||||
return "删除分片任务";
|
return "删除分片任务";
|
||||||
});
|
});
|
||||||
const deleteDialogLead = computed(() => {
|
const deleteDialogLead = computed(() => {
|
||||||
@@ -212,6 +220,10 @@ const deleteDialogLead = computed(() => {
|
|||||||
return `将自动找出所有没有任何分片任务的录制会话并批量删除。你也可以选择同时删除可能残留的本地文件。`;
|
return `将自动找出所有没有任何分片任务的录制会话并批量删除。你也可以选择同时删除可能残留的本地文件。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||||
|
return `将自动找出所有视频文件已丢失的分片任务并批量删除(不限会话,只删命中的分片本身)。删除后若某个会话下不再有任何分片,会话也会一并清理;你也可以选择同时清理残留的弹幕 XML 文件。`;
|
||||||
|
}
|
||||||
|
|
||||||
return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`;
|
return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`;
|
||||||
});
|
});
|
||||||
const deleteDialogNote = computed(() => {
|
const deleteDialogNote = computed(() => {
|
||||||
@@ -227,6 +239,10 @@ const deleteDialogNote = computed(() => {
|
|||||||
return "仅清理没有任何关联分片的空会话,不影响有录制产物的会话。";
|
return "仅清理没有任何关联分片的空会话,不影响有录制产物的会话。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||||
|
return "以分片为单位判定:仅当分片的视频文件在磁盘上不存在时才会删除。正在录制或处理中的分片会自动跳过,有视频文件的分片不受影响。";
|
||||||
|
}
|
||||||
|
|
||||||
return "记录加文件会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
|
return "记录加文件会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
|
||||||
});
|
});
|
||||||
function isActiveStatus(status: number) {
|
function isActiveStatus(status: number) {
|
||||||
@@ -572,6 +588,13 @@ function openDeleteEmptySessionsDialog() {
|
|||||||
deleteDialogVisible.value = true;
|
deleteDialogVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDeleteMissingFileTasksDialog() {
|
||||||
|
deleteDialogMode.value = "missing-file-tasks";
|
||||||
|
deleteDialogSessionIds.value = [];
|
||||||
|
deleteDialogTaskIds.value = [];
|
||||||
|
deleteDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmConditionalDelete() {
|
async function confirmConditionalDelete() {
|
||||||
conditionalDialogVisible.value = false;
|
conditionalDialogVisible.value = false;
|
||||||
deleteDialogMode.value = "conditional-sessions";
|
deleteDialogMode.value = "conditional-sessions";
|
||||||
@@ -642,6 +665,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
|||||||
const deletingSessions = currentMode === "sessions";
|
const deletingSessions = currentMode === "sessions";
|
||||||
const deletingConditionalSessions = currentMode === "conditional-sessions";
|
const deletingConditionalSessions = currentMode === "conditional-sessions";
|
||||||
const deletingEmptySessions = currentMode === "empty-sessions";
|
const deletingEmptySessions = currentMode === "empty-sessions";
|
||||||
|
const deletingMissingFileTasks = currentMode === "missing-file-tasks";
|
||||||
const deletingMixed = currentMode === "mixed";
|
const deletingMixed = currentMode === "mixed";
|
||||||
const hasSelection = deletingMixed
|
const hasSelection = deletingMixed
|
||||||
? deleteDialogSessionIds.value.length > 0 || deleteDialogTaskIds.value.length > 0
|
? deleteDialogSessionIds.value.length > 0 || deleteDialogTaskIds.value.length > 0
|
||||||
@@ -649,7 +673,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
|||||||
? deleteDialogSessionIds.value.length > 0
|
? deleteDialogSessionIds.value.length > 0
|
||||||
: deleteDialogTaskIds.value.length > 0;
|
: deleteDialogTaskIds.value.length > 0;
|
||||||
|
|
||||||
if (!deletingConditionalSessions && !deletingEmptySessions && !hasSelection) {
|
if (!deletingConditionalSessions && !deletingEmptySessions && !deletingMissingFileTasks && !hasSelection) {
|
||||||
closeDeleteDialog();
|
closeDeleteDialog();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -685,6 +709,11 @@ async function confirmDelete(deleteFiles: boolean) {
|
|||||||
deleteFiles
|
deleteFiles
|
||||||
});
|
});
|
||||||
createdCleanupOperation = data;
|
createdCleanupOperation = data;
|
||||||
|
} else if (deletingMissingFileTasks) {
|
||||||
|
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete-missing-files", {
|
||||||
|
deleteFiles
|
||||||
|
});
|
||||||
|
deletedTaskResult = data;
|
||||||
} else if (deletingMixed) {
|
} else if (deletingMixed) {
|
||||||
if (deleteDialogTaskIds.value.length > 0) {
|
if (deleteDialogTaskIds.value.length > 0) {
|
||||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||||
@@ -723,7 +752,17 @@ async function confirmDelete(deleteFiles: boolean) {
|
|||||||
deleteDialogVisible.value = false;
|
deleteDialogVisible.value = false;
|
||||||
resetDeleteDialogState();
|
resetDeleteDialogState();
|
||||||
|
|
||||||
if (deleteDialogMode.value === "tasks") {
|
if (currentMode === "missing-file-tasks") {
|
||||||
|
ElMessage.success(
|
||||||
|
deletedTaskResult
|
||||||
|
? `已清理 ${deletedTaskResult.deletedTaskIds.length} 个无文件分片。`
|
||||||
|
: "未发现可清理的无文件分片。"
|
||||||
|
);
|
||||||
|
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentMode === "tasks") {
|
||||||
ElMessage.success(
|
ElMessage.success(
|
||||||
deletedTaskResult ? `已删除 ${deletedTaskResult.deletedTaskIds.length} 个分片任务。` : "已删除分片任务。"
|
deletedTaskResult ? `已删除 ${deletedTaskResult.deletedTaskIds.length} 个分片任务。` : "已删除分片任务。"
|
||||||
);
|
);
|
||||||
@@ -737,11 +776,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ElMessage.success(
|
ElMessage.success("后台清理任务已创建,页面会自动轮询进度。");
|
||||||
currentMode === "tasks"
|
|
||||||
? "已删除分片任务。"
|
|
||||||
: "后台清理任务已创建,页面会自动轮询进度。"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false;
|
deleting.value = false;
|
||||||
}
|
}
|
||||||
@@ -931,6 +966,9 @@ onBeforeUnmount(() => {
|
|||||||
<el-button plain :loading="deleting" @click="openDeleteEmptySessionsDialog">
|
<el-button plain :loading="deleting" @click="openDeleteEmptySessionsDialog">
|
||||||
清理无分片会话
|
清理无分片会话
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button plain :loading="deleting" @click="openDeleteMissingFileTasksDialog">
|
||||||
|
清理无文件分片
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ public sealed class DeleteCompletedRecordTasksRequest
|
|||||||
public bool DeleteFiles { get; set; }
|
public bool DeleteFiles { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class DeleteMissingFileRecordTasksRequest
|
||||||
|
{
|
||||||
|
public bool DeleteFiles { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class DeleteCompletedRecordTasksResultDto
|
public sealed class DeleteCompletedRecordTasksResultDto
|
||||||
{
|
{
|
||||||
public required IReadOnlyList<Guid> DeletedTaskIds { get; init; }
|
public required IReadOnlyList<Guid> DeletedTaskIds { get; init; }
|
||||||
|
|||||||
@@ -504,6 +504,32 @@ public sealed class RecordService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<DeleteCompletedRecordTasksResultDto> DeleteMissingFileTasksAsync(
|
||||||
|
DeleteMissingFileRecordTasksRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var tasks = await _recordTaskRepository.ListAsync(null, cancellationToken);
|
||||||
|
var missingFileTaskIds = tasks
|
||||||
|
.Where(static task => !IsActiveTaskStatus(task.Status) && !HasExistingVideoFile(task))
|
||||||
|
.Select(static task => task.Id)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (missingFileTaskIds.Length == 0)
|
||||||
|
{
|
||||||
|
return CreateEmptyDeleteResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await DeleteTasksAsync(
|
||||||
|
new DeleteCompletedRecordTasksRequest
|
||||||
|
{
|
||||||
|
TaskIds = missingFileTaskIds,
|
||||||
|
DeleteFiles = request.DeleteFiles
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<RecordPreviewTicketDto> CreatePreviewTicketAsync(Guid id, string mediaBaseUrl, CancellationToken cancellationToken = default)
|
public async Task<RecordPreviewTicketDto> CreatePreviewTicketAsync(Guid id, string mediaBaseUrl, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken);
|
var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken);
|
||||||
@@ -823,6 +849,24 @@ public sealed class RecordService
|
|||||||
or RecordTaskStatus.Stopping
|
or RecordTaskStatus.Stopping
|
||||||
or RecordTaskStatus.Processing;
|
or RecordTaskStatus.Processing;
|
||||||
|
|
||||||
|
private static bool HasExistingVideoFile(RecordTask task)
|
||||||
|
{
|
||||||
|
var candidatePath = !string.IsNullOrWhiteSpace(task.Result?.FilePath)
|
||||||
|
? task.Result!.FilePath
|
||||||
|
: task.OutputFilePath;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(candidatePath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedPath = Path.IsPathRooted(candidatePath)
|
||||||
|
? candidatePath
|
||||||
|
: Path.GetFullPath(candidatePath, AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
return File.Exists(resolvedPath);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
|
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
|
||||||
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ public sealed class RecordTasksController : ControllerBase
|
|||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
Ok(await _recordService.DeleteTasksAsync(request, cancellationToken));
|
Ok(await _recordService.DeleteTasksAsync(request, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("delete-missing-files")]
|
||||||
|
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteMissingFiles(
|
||||||
|
[FromBody] DeleteMissingFileRecordTasksRequest request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _recordService.DeleteMissingFileTasksAsync(request, cancellationToken));
|
||||||
|
|
||||||
[HttpPost("{id:guid}/preview-ticket")]
|
[HttpPost("{id:guid}/preview-ticket")]
|
||||||
public async Task<ActionResult<RecordPreviewTicketDto>> CreatePreviewTicket(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult<RecordPreviewTicketDto>> CreatePreviewTicket(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user