feat: async session cleanup and fix live room scroll
This commit is contained in:
+40
-10
@@ -213,6 +213,38 @@ export interface DeleteCompletedRecordTasksResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export type CleanupOperationKind = "selected" | "conditional" | "empty" | "retention";
|
||||
|
||||
export type CleanupOperationStatus = "queued" | "running" | "completed" | "failed";
|
||||
|
||||
export type CleanupVideoFileCondition = "any" | "allMissing" | "allPresent";
|
||||
|
||||
export interface CleanupOperation {
|
||||
id: string;
|
||||
kind: CleanupOperationKind;
|
||||
status: CleanupOperationStatus;
|
||||
deleteFiles: boolean;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
totalSessionCount: number;
|
||||
processedSessionCount: number;
|
||||
deletedSessionCount: number;
|
||||
deletedTaskCount: number;
|
||||
deletedResultCount: number;
|
||||
deletedLogCount: number;
|
||||
deletedFileCount: number;
|
||||
deletedDanmakuFileCount: number;
|
||||
warnings: string[];
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface DeleteConditionalSessionsRequest {
|
||||
videoFileCondition: CleanupVideoFileCondition;
|
||||
taskStatuses: number[];
|
||||
deleteFiles: boolean;
|
||||
}
|
||||
|
||||
export interface RecordArtifactUploadItemResult {
|
||||
recordTaskId: string;
|
||||
success: boolean;
|
||||
@@ -230,6 +262,12 @@ export interface RecordArtifactUploadBatchResult {
|
||||
items: RecordArtifactUploadItemResult[];
|
||||
}
|
||||
|
||||
export interface ManualSegmentCompletedTriggerResult {
|
||||
recordTaskId: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RecordPreviewTicket {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
@@ -386,6 +424,8 @@ export interface SystemSettings {
|
||||
enableRetentionCleanup: boolean;
|
||||
retentionDays: number;
|
||||
retentionDeleteFiles: boolean;
|
||||
retentionVideoFileCondition: CleanupVideoFileCondition;
|
||||
retentionTaskStatuses: number[];
|
||||
enableAutoReconnect: boolean;
|
||||
reconnectDelayMaxSeconds: number;
|
||||
readWriteTimeoutMilliseconds: number;
|
||||
@@ -482,16 +522,6 @@ export interface WebhookTestResult {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface RetentionCleanupResult {
|
||||
deletedSessionCount: number;
|
||||
deletedTaskCount: number;
|
||||
deletedResultCount: number;
|
||||
deletedLogCount: number;
|
||||
deletedFileCount: number;
|
||||
deletedDanmakuFileCount: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface RecoveryOverview {
|
||||
storage: StorageGuardStatus;
|
||||
liveRooms: RecoverableLiveRoom[];
|
||||
|
||||
@@ -269,9 +269,12 @@ async function syncRoomsTableProxyScroll() {
|
||||
|
||||
const scrollWidth = getRoomsTableContentWidth(wrap);
|
||||
const clientWidth = wrap.clientWidth;
|
||||
const shellClientWidth = roomsTableShellRef.value?.clientWidth ?? clientWidth;
|
||||
const proxyViewportWidth = proxy?.clientWidth ?? shellClientWidth;
|
||||
const canScrollHorizontally = scrollWidth > clientWidth + 1;
|
||||
const proxyContentWidth = scrollWidth + Math.max(0, proxyViewportWidth - clientWidth);
|
||||
|
||||
roomsTableProxyInnerWidth.value = canScrollHorizontally ? scrollWidth : 0;
|
||||
roomsTableProxyInnerWidth.value = canScrollHorizontally ? Math.ceil(proxyContentWidth) : 0;
|
||||
showRoomsTableProxyScroll.value = canScrollHorizontally;
|
||||
|
||||
if (canScrollHorizontally && proxy && Math.abs(proxy.scrollLeft - wrap.scrollLeft) > 1) {
|
||||
|
||||
@@ -10,7 +10,11 @@ import apiClient, {
|
||||
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type {
|
||||
CleanupOperation,
|
||||
CleanupVideoFileCondition,
|
||||
DeleteCompletedRecordTasksResult,
|
||||
DeleteConditionalSessionsRequest,
|
||||
ManualSegmentCompletedTriggerResult,
|
||||
RecordArtifactUploadBatchResult,
|
||||
RecordArtifactUploadItemResult,
|
||||
RecordSession,
|
||||
@@ -25,21 +29,29 @@ import {
|
||||
} from "@/types";
|
||||
|
||||
const router = useRouter();
|
||||
const cleanupOperationStorageKey = "live-recorder-record-tasks-cleanup-operation-id";
|
||||
const loading = ref(false);
|
||||
const deleting = ref(false);
|
||||
const stoppingSessionId = ref<string | null>(null);
|
||||
const uploadingSessionId = ref<string | null>(null);
|
||||
const uploadingTaskId = ref<string | null>(null);
|
||||
const triggeringSegmentCompletedTaskId = ref<string | null>(null);
|
||||
const cleanupOperation = ref<CleanupOperation | null>(null);
|
||||
const deleteDialogVisible = ref(false);
|
||||
const deleteDialogMode = ref<"tasks" | "sessions" | "missing-sessions" | "mixed" | "empty-sessions">("tasks");
|
||||
const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions">("tasks");
|
||||
const deleteDialogTaskIds = ref<string[]>([]);
|
||||
const deleteDialogSessionIds = ref<string[]>([]);
|
||||
|
||||
const conditionalDialogVisible = ref(false);
|
||||
const conditionalFilter = reactive({
|
||||
checkFileExists: true as boolean | null,
|
||||
videoFileCondition: "allMissing" as CleanupVideoFileCondition,
|
||||
taskStatuses: [] as number[]
|
||||
});
|
||||
const conditionalVideoFileOptions = [
|
||||
{ label: "不限文件状态", value: "any" as CleanupVideoFileCondition },
|
||||
{ label: "全部视频文件不存在", value: "allMissing" as CleanupVideoFileCondition },
|
||||
{ label: "全部视频文件存在", value: "allPresent" as CleanupVideoFileCondition }
|
||||
];
|
||||
const conditionalTaskStatusOptions = Object.entries(taskStatusLabelMap)
|
||||
.map(([value, label]) => ({ value: Number(value), label }))
|
||||
.filter(o => o.value !== 1 && o.value !== 2 && o.value !== 3);
|
||||
@@ -53,6 +65,7 @@ const realtimeError = ref("");
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
let sessionsEventSource: EventSource | null = null;
|
||||
let cleanupPollTimer: number | null = null;
|
||||
|
||||
const selectedTasks = computed(() => Object.values(selectedTaskMap.value));
|
||||
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
|
||||
@@ -72,13 +85,85 @@ const mixedSelectionLabel = computed(() => {
|
||||
const deleteDialogTaskCount = computed(() => deleteDialogTaskIds.value.length);
|
||||
const deleteDialogSessionCount = computed(() => deleteDialogSessionIds.value.length);
|
||||
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 48vw, 560px)"));
|
||||
const cleanupStatusLabelMap: Record<CleanupOperation["status"], string> = {
|
||||
queued: "排队中",
|
||||
running: "执行中",
|
||||
completed: "已完成",
|
||||
failed: "已失败"
|
||||
};
|
||||
const cleanupOperationStatusLabel = computed(() =>
|
||||
cleanupOperation.value ? cleanupStatusLabelMap[cleanupOperation.value.status] : ""
|
||||
);
|
||||
const cleanupOperationFinished = computed(() =>
|
||||
cleanupOperation.value?.status === "completed" || cleanupOperation.value?.status === "failed"
|
||||
);
|
||||
const cleanupOperationTagType = computed(() => {
|
||||
if (!cleanupOperation.value) {
|
||||
return "info";
|
||||
}
|
||||
|
||||
if (cleanupOperation.value.status === "failed") {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (cleanupOperation.value.status === "completed" && cleanupOperation.value.warnings.length === 0) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (cleanupOperation.value.warnings.length > 0) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "info";
|
||||
});
|
||||
const cleanupOperationProgressText = computed(() => {
|
||||
if (!cleanupOperation.value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (cleanupOperation.value.totalSessionCount === 0) {
|
||||
return cleanupOperation.value.status === "queued" ? "等待分析候选会话" : "0 / 0";
|
||||
}
|
||||
|
||||
return `${cleanupOperation.value.processedSessionCount} / ${cleanupOperation.value.totalSessionCount}`;
|
||||
});
|
||||
const cleanupOperationSummary = computed(() => {
|
||||
if (!cleanupOperation.value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
`会话 ${cleanupOperation.value.deletedSessionCount}`,
|
||||
`分片 ${cleanupOperation.value.deletedTaskCount}`,
|
||||
`结果 ${cleanupOperation.value.deletedResultCount}`,
|
||||
`日志 ${cleanupOperation.value.deletedLogCount}`,
|
||||
`视频 ${cleanupOperation.value.deletedFileCount}`,
|
||||
`弹幕 ${cleanupOperation.value.deletedDanmakuFileCount}`
|
||||
].join(" · ");
|
||||
});
|
||||
const cleanupOperationWarningsPreview = computed(() => cleanupOperation.value?.warnings.slice(0, 6) ?? []);
|
||||
const conditionalFilterSummary = computed(() => {
|
||||
const parts = [
|
||||
conditionalFilter.videoFileCondition === "allMissing"
|
||||
? "全部视频文件不存在"
|
||||
: conditionalFilter.videoFileCondition === "allPresent"
|
||||
? "全部视频文件存在"
|
||||
: "不限文件状态"
|
||||
];
|
||||
|
||||
if (conditionalFilter.taskStatuses.length > 0) {
|
||||
parts.push(`全部分片状态属于 ${conditionalFilter.taskStatuses.length} 个已选状态`);
|
||||
}
|
||||
|
||||
return parts.join(",");
|
||||
});
|
||||
const deleteDialogEyebrow = computed(() => {
|
||||
if (deleteDialogMode.value === "sessions" || deleteDialogMode.value === "mixed") {
|
||||
return "会话删除";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-sessions") {
|
||||
return "无文件清理";
|
||||
if (deleteDialogMode.value === "conditional-sessions") {
|
||||
return "条件清理";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "empty-sessions") {
|
||||
@@ -96,8 +181,8 @@ const deleteDialogTitle = computed(() => {
|
||||
return "删除录制会话";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-sessions") {
|
||||
return "清理无实体文件会话";
|
||||
if (deleteDialogMode.value === "conditional-sessions") {
|
||||
return "清理命中条件的会话";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "empty-sessions") {
|
||||
@@ -108,15 +193,15 @@ const deleteDialogTitle = computed(() => {
|
||||
});
|
||||
const deleteDialogLead = computed(() => {
|
||||
if (deleteDialogMode.value === "mixed") {
|
||||
return `将删除 ${deleteDialogSessionCount.value} 个会话和 ${deleteDialogTaskCount.value} 个分片任务。会话删除会先停止录制再清理,你也可以选择同时删除本地文件。`;
|
||||
return `将删除 ${deleteDialogSessionCount.value} 个会话和 ${deleteDialogTaskCount.value} 个分片任务。分片任务会立即删除,会话则会创建后台清理任务继续执行,你也可以选择同时删除本地文件。`;
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "sessions") {
|
||||
return `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`;
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-sessions") {
|
||||
return `将自动筛出所有无实体文件的录制会话并批量清理。你也可以选择同时尝试删除残留的本地文件。`;
|
||||
if (deleteDialogMode.value === "conditional-sessions") {
|
||||
return `将按“${conditionalFilterSummary.value}”筛选命中的录制会话并批量清理。只有当会话下所有分片都满足这些条件时,才会进入后台删除任务。`;
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "empty-sessions") {
|
||||
@@ -130,8 +215,8 @@ const deleteDialogNote = computed(() => {
|
||||
return "活跃会话会先尝试优雅停止,超时后再强制结束 ffmpeg 进程。直播间仍保持启用时,后台巡检后续可能重新创建新会话。";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-sessions") {
|
||||
return "只有当会话下所有分片都找不到实体视频文件时,才会命中这类清理。只要仍有任意一个视频文件存在,该会话就不会被误删。";
|
||||
if (deleteDialogMode.value === "conditional-sessions") {
|
||||
return "命中规则固定为“会话下所有分片都满足条件”。空会话不会参与这类条件判断,而是继续通过“清理无分片会话”单独处理。";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "empty-sessions") {
|
||||
@@ -148,6 +233,10 @@ function isDeletableTask(task: RecordTask) {
|
||||
return !isActiveStatus(task.status);
|
||||
}
|
||||
|
||||
function canTriggerSegmentCompleted(task: RecordTask) {
|
||||
return !isActiveStatus(task.status) && Boolean(task.outputFilePath);
|
||||
}
|
||||
|
||||
function selectableTask(row: RecordTask) {
|
||||
return isDeletableTask(row);
|
||||
}
|
||||
@@ -288,6 +377,92 @@ function closeRealtimeUpdates() {
|
||||
sessionsEventSource = null;
|
||||
}
|
||||
|
||||
function persistCleanupOperationId(id: string | null) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
window.sessionStorage.setItem(cleanupOperationStorageKey, id);
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.removeItem(cleanupOperationStorageKey);
|
||||
}
|
||||
|
||||
function stopCleanupPolling() {
|
||||
if (cleanupPollTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(cleanupPollTimer);
|
||||
}
|
||||
|
||||
cleanupPollTimer = null;
|
||||
}
|
||||
|
||||
function scheduleCleanupPolling(operationId: string) {
|
||||
stopCleanupPolling();
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupPollTimer = window.setTimeout(() => {
|
||||
void refreshCleanupOperation(operationId, { silent: true });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function clearTrackedCleanupOperation() {
|
||||
cleanupOperation.value = null;
|
||||
persistCleanupOperationId(null);
|
||||
stopCleanupPolling();
|
||||
}
|
||||
|
||||
async function refreshCleanupOperation(operationId: string, options?: { silent?: boolean }) {
|
||||
try {
|
||||
const { data } = await apiClient.get<CleanupOperation>(`/cleanup-operations/${operationId}`);
|
||||
cleanupOperation.value = data;
|
||||
persistCleanupOperationId(data.id);
|
||||
|
||||
if (data.status === "queued" || data.status === "running") {
|
||||
scheduleCleanupPolling(data.id);
|
||||
return;
|
||||
}
|
||||
|
||||
stopCleanupPolling();
|
||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||
} catch (error) {
|
||||
stopCleanupPolling();
|
||||
|
||||
if (!options?.silent) {
|
||||
ElMessage.error(getApiErrorMessage(error, "清理任务状态加载失败,请稍后重试。"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startCleanupTracking(operation: CleanupOperation) {
|
||||
cleanupOperation.value = operation;
|
||||
persistCleanupOperationId(operation.id);
|
||||
|
||||
if (operation.status === "queued" || operation.status === "running") {
|
||||
scheduleCleanupPolling(operation.id);
|
||||
return;
|
||||
}
|
||||
|
||||
stopCleanupPolling();
|
||||
}
|
||||
|
||||
async function restoreCleanupTracking() {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const operationId = window.sessionStorage.getItem(cleanupOperationStorageKey);
|
||||
if (!operationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshCleanupOperation(operationId, { silent: true });
|
||||
}
|
||||
|
||||
function handleSelectionChange(session: RecordSession, selection: RecordTask[]) {
|
||||
const nextMap = { ...selectedTaskMap.value };
|
||||
session.tasks.forEach((task) => {
|
||||
@@ -345,6 +520,21 @@ async function uploadTask(task: RecordTask) {
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSegmentCompleted(task: RecordTask) {
|
||||
triggeringSegmentCompletedTaskId.value = task.id;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post<ManualSegmentCompletedTriggerResult>(
|
||||
`/record-tasks/${task.id}/trigger-segment-completed`
|
||||
);
|
||||
ElMessage[data.success ? "success" : "warning"](data.message);
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "手动触发分片完成事件失败,请稍后重试。"));
|
||||
} finally {
|
||||
triggeringSegmentCompletedTaskId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(task: RecordTask) {
|
||||
router.push({ name: "record-task-detail", params: { id: task.id } });
|
||||
}
|
||||
@@ -366,7 +556,7 @@ function openMixedDeleteDialog() {
|
||||
}
|
||||
|
||||
function openConditionalDeleteDialog() {
|
||||
conditionalFilter.checkFileExists = true;
|
||||
conditionalFilter.videoFileCondition = "allMissing";
|
||||
conditionalFilter.taskStatuses = [];
|
||||
conditionalDialogVisible.value = true;
|
||||
}
|
||||
@@ -380,9 +570,7 @@ function openDeleteEmptySessionsDialog() {
|
||||
|
||||
async function confirmConditionalDelete() {
|
||||
conditionalDialogVisible.value = false;
|
||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||
|
||||
deleteDialogMode.value = "missing-sessions";
|
||||
deleteDialogMode.value = "conditional-sessions";
|
||||
deleteDialogSessionIds.value = [];
|
||||
deleteDialogTaskIds.value = [];
|
||||
deleteDialogVisible.value = true;
|
||||
@@ -412,13 +600,6 @@ function openDeleteSessionDialog(sessionIds: string[]) {
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openDeleteMissingSessionsDialog() {
|
||||
deleteDialogMode.value = "missing-sessions";
|
||||
deleteDialogSessionIds.value = [];
|
||||
deleteDialogTaskIds.value = [];
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (deleting.value) {
|
||||
return;
|
||||
@@ -453,11 +634,18 @@ function applyDeleteResult(data: DeleteCompletedRecordTasksResult) {
|
||||
}
|
||||
|
||||
async function confirmDelete(deleteFiles: boolean) {
|
||||
const deletingSessions = deleteDialogMode.value === "sessions" || deleteDialogMode.value === "mixed";
|
||||
const deletingMissingSessions = deleteDialogMode.value === "missing-sessions";
|
||||
const deletingEmptySessions = deleteDialogMode.value === "empty-sessions";
|
||||
const selectedIds = deletingSessions ? deleteDialogSessionIds.value : deleteDialogTaskIds.value;
|
||||
if (!deletingMissingSessions && !deletingEmptySessions && selectedIds.length === 0) {
|
||||
const currentMode = deleteDialogMode.value;
|
||||
const deletingSessions = currentMode === "sessions";
|
||||
const deletingConditionalSessions = currentMode === "conditional-sessions";
|
||||
const deletingEmptySessions = currentMode === "empty-sessions";
|
||||
const deletingMixed = currentMode === "mixed";
|
||||
const hasSelection = deletingMixed
|
||||
? deleteDialogSessionIds.value.length > 0 || deleteDialogTaskIds.value.length > 0
|
||||
: deletingSessions
|
||||
? deleteDialogSessionIds.value.length > 0
|
||||
: deleteDialogTaskIds.value.length > 0;
|
||||
|
||||
if (!deletingConditionalSessions && !deletingEmptySessions && !hasSelection) {
|
||||
closeDeleteDialog();
|
||||
return;
|
||||
}
|
||||
@@ -465,46 +653,91 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
deleting.value = true;
|
||||
|
||||
try {
|
||||
const { data } = deletingEmptySessions
|
||||
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete-empty?deleteFiles=" + deleteFiles)
|
||||
: deletingMissingSessions
|
||||
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete-missing-files", {
|
||||
deleteFiles
|
||||
})
|
||||
: deletingSessions
|
||||
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete", {
|
||||
sessionIds: deleteDialogSessionIds.value,
|
||||
deleteFiles
|
||||
})
|
||||
: await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||
taskIds: deleteDialogTaskIds.value,
|
||||
deleteFiles
|
||||
});
|
||||
let deletedTaskResult: DeleteCompletedRecordTasksResult | null = null;
|
||||
let createdCleanupOperation: CleanupOperation | null = null;
|
||||
|
||||
if ((deletingMissingSessions || deletingEmptySessions) && data.deletedSessionIds.length === 0) {
|
||||
ElMessage.info(deletingEmptySessions ? "没有找到无分片的空会话。" : "没有找到符合条件的无实体文件会话。");
|
||||
} else {
|
||||
ElMessage.success(
|
||||
deletingSessions || deletingMissingSessions || deletingEmptySessions
|
||||
? `已删除 ${data.deletedSessionIds.length} 个录制会话。`
|
||||
: `已删除 ${data.deletedTaskIds.length} 个分片任务。`
|
||||
);
|
||||
if (currentMode === "tasks") {
|
||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||
taskIds: deleteDialogTaskIds.value,
|
||||
deleteFiles
|
||||
});
|
||||
deletedTaskResult = data;
|
||||
} else if (deletingSessions) {
|
||||
const { data } = await apiClient.post<CleanupOperation>("/record-sessions/delete", {
|
||||
sessionIds: deleteDialogSessionIds.value,
|
||||
deleteFiles
|
||||
});
|
||||
createdCleanupOperation = data;
|
||||
} else if (deletingConditionalSessions) {
|
||||
const request: DeleteConditionalSessionsRequest = {
|
||||
videoFileCondition: conditionalFilter.videoFileCondition,
|
||||
taskStatuses: [...conditionalFilter.taskStatuses],
|
||||
deleteFiles
|
||||
};
|
||||
const { data } = await apiClient.post<CleanupOperation>("/record-sessions/delete-conditional", request);
|
||||
createdCleanupOperation = data;
|
||||
} else if (deletingEmptySessions) {
|
||||
const { data } = await apiClient.post<CleanupOperation>("/record-sessions/delete-empty", {
|
||||
deleteFiles
|
||||
});
|
||||
createdCleanupOperation = data;
|
||||
} else if (deletingMixed) {
|
||||
if (deleteDialogTaskIds.value.length > 0) {
|
||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||
taskIds: deleteDialogTaskIds.value,
|
||||
deleteFiles
|
||||
});
|
||||
deletedTaskResult = data;
|
||||
}
|
||||
|
||||
if (deleteDialogSessionIds.value.length > 0) {
|
||||
const { data } = await apiClient.post<CleanupOperation>("/record-sessions/delete", {
|
||||
sessionIds: deleteDialogSessionIds.value,
|
||||
deleteFiles
|
||||
});
|
||||
createdCleanupOperation = data;
|
||||
}
|
||||
}
|
||||
|
||||
applyDeleteResult(data);
|
||||
if (deletedTaskResult) {
|
||||
applyDeleteResult(deletedTaskResult);
|
||||
|
||||
if (data.warnings.length > 0) {
|
||||
ElNotification({
|
||||
title: "删除完成,但有提示",
|
||||
message: data.warnings.join("\n"),
|
||||
type: "warning",
|
||||
duration: 8000
|
||||
});
|
||||
if (deletedTaskResult.warnings.length > 0) {
|
||||
ElNotification({
|
||||
title: "删除完成,但有提示",
|
||||
message: deletedTaskResult.warnings.join("\n"),
|
||||
type: "warning",
|
||||
duration: 8000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (createdCleanupOperation) {
|
||||
await startCleanupTracking(createdCleanupOperation);
|
||||
}
|
||||
|
||||
deleteDialogVisible.value = false;
|
||||
resetDeleteDialogState();
|
||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||
|
||||
if (deleteDialogMode.value === "tasks") {
|
||||
ElMessage.success(
|
||||
deletedTaskResult ? `已删除 ${deletedTaskResult.deletedTaskIds.length} 个分片任务。` : "已删除分片任务。"
|
||||
);
|
||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMode === "mixed") {
|
||||
ElMessage.success("已启动会话后台清理;已选分片任务会按当前选项立即删除。");
|
||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessage.success(
|
||||
currentMode === "tasks"
|
||||
? "已删除分片任务。"
|
||||
: "后台清理任务已创建,页面会自动轮询进度。"
|
||||
);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
@@ -568,11 +801,13 @@ function formatFileSize(bytes?: number) {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSessions();
|
||||
await restoreCleanupTracking();
|
||||
connectRealtimeUpdates();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeRealtimeUpdates();
|
||||
stopCleanupPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -614,6 +849,32 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<el-card v-if="cleanupOperation" class="surface-card cleanup-status-card" shadow="never">
|
||||
<div class="cleanup-status-card__header">
|
||||
<div>
|
||||
<div class="cleanup-status-card__eyebrow">后台清理任务</div>
|
||||
<div class="cleanup-status-card__title">当前任务状态:{{ cleanupOperationStatusLabel }}</div>
|
||||
</div>
|
||||
<div class="cleanup-status-card__actions">
|
||||
<el-tag :type="cleanupOperationTagType">{{ cleanupOperationStatusLabel }}</el-tag>
|
||||
<el-button v-if="cleanupOperationFinished" text @click="clearTrackedCleanupOperation">收起</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cleanup-status-card__meta">
|
||||
<span>已处理 {{ cleanupOperationProgressText }}</span>
|
||||
<span>{{ cleanupOperationSummary }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="cleanupOperation.errorMessage" class="cleanup-status-card__error">
|
||||
{{ cleanupOperation.errorMessage }}
|
||||
</div>
|
||||
|
||||
<ul v-if="cleanupOperationWarningsPreview.length > 0" class="cleanup-status-card__warnings">
|
||||
<li v-for="warning in cleanupOperationWarningsPreview" :key="warning">{{ warning }}</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">录制会话</div>
|
||||
@@ -764,6 +1025,14 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="data-card__actions">
|
||||
<el-button size="small" @click="openDetail(task)">详情</el-button>
|
||||
<el-button
|
||||
v-if="canTriggerSegmentCompleted(task)"
|
||||
size="small"
|
||||
:loading="triggeringSegmentCompletedTaskId === task.id"
|
||||
@click="triggerSegmentCompleted(task)"
|
||||
>
|
||||
触发完成事件
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDeletableTask(task)"
|
||||
size="small"
|
||||
@@ -930,10 +1199,18 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="220">
|
||||
<el-table-column label="操作" width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="task-actions-cell">
|
||||
<el-button size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="canTriggerSegmentCompleted(row)"
|
||||
size="small"
|
||||
:loading="triggeringSegmentCompletedTaskId === row.id"
|
||||
@click="triggerSegmentCompleted(row)"
|
||||
>
|
||||
触发事件
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDeletableTask(row)"
|
||||
size="small"
|
||||
@@ -1019,22 +1296,29 @@ onBeforeUnmount(() => {
|
||||
<template #header>
|
||||
<div class="delete-dialog__header">
|
||||
<span class="delete-dialog__eyebrow">按条件清理</span>
|
||||
<h3 class="delete-dialog__title">清理满足条件的无文件会话</h3>
|
||||
<h3 class="delete-dialog__title">按条件清理录制会话</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="delete-dialog__body">
|
||||
<div class="delete-dialog__lead">
|
||||
筛选出所有分片视频文件不存在的会话。可额外按分片状态筛选。
|
||||
条件会同时作用于手动清理和保留清理:只有当会话下所有分片都满足所选条件时,才会命中删除。
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="检查文件存在">
|
||||
<el-switch
|
||||
v-model="conditionalFilter.checkFileExists"
|
||||
active-text="仅删除无实体文件的会话"
|
||||
inactive-text="不检查文件"
|
||||
/>
|
||||
<el-form-item label="视频文件条件">
|
||||
<el-select
|
||||
v-model="conditionalFilter.videoFileCondition"
|
||||
placeholder="请选择文件条件"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in conditionalVideoFileOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="按分片状态筛选(可多选,为空则不限)">
|
||||
@@ -1056,7 +1340,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="delete-dialog__note">
|
||||
<div class="delete-dialog__note-title">提示</div>
|
||||
<p>仅当所有文件检查和不限时全部满足才会命中。此操作不可恢复,请确认后再执行。</p>
|
||||
<p>空会话不会参与这里的文件/状态条件匹配,请继续使用“清理无分片会话”单独处理。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1085,6 +1369,64 @@ onBeforeUnmount(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.cleanup-status-card {
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.cleanup-status-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cleanup-status-card__eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cleanup-status-card__title {
|
||||
margin-top: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cleanup-status-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cleanup-status-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.cleanup-status-card__error {
|
||||
margin-top: 12px;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.cleanup-status-card__warnings {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tasks-table-shell {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
+419
-228
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
namespace LiveRecorder.Application.Models.Cleanup;
|
||||
|
||||
public static class CleanupOperationKinds
|
||||
{
|
||||
public const string Selected = "selected";
|
||||
|
||||
public const string Conditional = "conditional";
|
||||
|
||||
public const string Empty = "empty";
|
||||
|
||||
public const string Retention = "retention";
|
||||
}
|
||||
|
||||
public static class CleanupOperationStatuses
|
||||
{
|
||||
public const string Queued = "queued";
|
||||
|
||||
public const string Running = "running";
|
||||
|
||||
public const string Completed = "completed";
|
||||
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
|
||||
public static class CleanupVideoFileConditions
|
||||
{
|
||||
public const string Any = "any";
|
||||
|
||||
public const string AllMissing = "allMissing";
|
||||
|
||||
public const string AllPresent = "allPresent";
|
||||
}
|
||||
|
||||
public sealed class CleanupOperationDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
|
||||
public required string Kind { get; init; }
|
||||
|
||||
public required string Status { get; init; }
|
||||
|
||||
public bool DeleteFiles { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
public int TotalSessionCount { get; init; }
|
||||
|
||||
public int ProcessedSessionCount { get; init; }
|
||||
|
||||
public int DeletedSessionCount { get; init; }
|
||||
|
||||
public int DeletedTaskCount { get; init; }
|
||||
|
||||
public int DeletedResultCount { get; init; }
|
||||
|
||||
public int DeletedLogCount { get; init; }
|
||||
|
||||
public int DeletedFileCount { get; init; }
|
||||
|
||||
public int DeletedDanmakuFileCount { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using LiveRecorder.Application.Models.Logs;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Application.Models.RecordTasks;
|
||||
@@ -134,3 +135,34 @@ public sealed class DeleteMissingFileRecordSessionsRequest
|
||||
{
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeleteConditionalSessionsRequest
|
||||
{
|
||||
public string VideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
|
||||
|
||||
public IReadOnlyList<int> TaskStatuses { get; set; } = [];
|
||||
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeleteEmptyRecordSessionsRequest
|
||||
{
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RecordSessionDeletionBatchResult
|
||||
{
|
||||
public required IReadOnlyList<Guid> DeletedSessionIds { get; init; }
|
||||
|
||||
public required IReadOnlyList<Guid> DeletedTaskIds { get; init; }
|
||||
|
||||
public int DeletedResultCount { get; init; }
|
||||
|
||||
public int DeletedLogCount { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> DeletedFilePaths { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> DeletedDanmakuPaths { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
|
||||
namespace LiveRecorder.Application.Models.Settings;
|
||||
|
||||
@@ -148,6 +149,10 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public bool RetentionDeleteFiles { get; set; } = false;
|
||||
|
||||
public string RetentionVideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
|
||||
|
||||
public IReadOnlyList<int> RetentionTaskStatuses { get; set; } = [];
|
||||
|
||||
public bool EnableEmailNotification { get; set; } = false;
|
||||
|
||||
public string EmailSmtpHost { get; set; } = string.Empty;
|
||||
@@ -331,6 +336,10 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public bool RetentionDeleteFiles { get; set; } = false;
|
||||
|
||||
public string RetentionVideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
|
||||
|
||||
public IReadOnlyList<int> RetentionTaskStatuses { get; set; } = [];
|
||||
|
||||
public bool EnableEmailNotification { get; set; } = false;
|
||||
|
||||
public string EmailSmtpHost { get; set; } = string.Empty;
|
||||
@@ -508,23 +517,6 @@ public sealed class WebhookTestResultDto
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RetentionCleanupResultDto
|
||||
{
|
||||
public int DeletedSessionCount { get; init; }
|
||||
|
||||
public int DeletedTaskCount { get; init; }
|
||||
|
||||
public int DeletedResultCount { get; init; }
|
||||
|
||||
public int DeletedLogCount { get; init; }
|
||||
|
||||
public int DeletedFileCount { get; init; }
|
||||
|
||||
public int DeletedDanmakuFileCount { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ImportSystemSettingsRequest
|
||||
{
|
||||
public SystemSettingsDto? Settings { get; set; }
|
||||
|
||||
@@ -124,14 +124,109 @@ public sealed class RecordSessionService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sessionIds = request.SessionIds
|
||||
var result = await DeleteSessionsByIdsAsync(request.SessionIds, request.DeleteFiles, cancellationToken);
|
||||
|
||||
if (result.DeletedSessionIds.Count > 0)
|
||||
{
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"RecordSession",
|
||||
$"Deleted {result.DeletedSessionIds.Count} recording session(s).",
|
||||
detail: request.DeleteFiles
|
||||
? $"video-files={result.DeletedFilePaths.Count}; danmaku-files={result.DeletedDanmakuPaths.Count}; tasks={result.DeletedTaskIds.Count}"
|
||||
: $"video-files=0; danmaku-files=0; tasks={result.DeletedTaskIds.Count}",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return new DeleteCompletedRecordTasksResultDto
|
||||
{
|
||||
DeletedTaskIds = result.DeletedTaskIds,
|
||||
DeletedFilePaths = result.DeletedFilePaths,
|
||||
DeletedDanmakuPaths = result.DeletedDanmakuPaths,
|
||||
DeletedSessionIds = result.DeletedSessionIds,
|
||||
Warnings = result.Warnings
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DeleteCompletedRecordTasksResultDto> DeleteMissingFilesAsync(
|
||||
DeleteMissingFileRecordSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
|
||||
await ReconcileActiveSessionsAsync(null, cancellationToken);
|
||||
|
||||
var sessions = await _recordSessionRepository.ListAsync(null, cancellationToken);
|
||||
var missingFileSessionIds = sessions
|
||||
.Where(CanDeleteMissingFileSession)
|
||||
.Select(static item => item.Id)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (missingFileSessionIds.Length == 0)
|
||||
{
|
||||
return RecordService.CreateEmptyDeleteResult();
|
||||
}
|
||||
|
||||
var result = await DeleteSessionsByIdsAsync(missingFileSessionIds, request.DeleteFiles, cancellationToken);
|
||||
return new DeleteCompletedRecordTasksResultDto
|
||||
{
|
||||
DeletedTaskIds = result.DeletedTaskIds,
|
||||
DeletedFilePaths = result.DeletedFilePaths,
|
||||
DeletedDanmakuPaths = result.DeletedDanmakuPaths,
|
||||
DeletedSessionIds = result.DeletedSessionIds,
|
||||
Warnings = result.Warnings
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DeleteCompletedRecordTasksResultDto> DeleteEmptyAsync(
|
||||
bool deleteFiles,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
|
||||
await ReconcileActiveSessionsAsync(null, cancellationToken);
|
||||
|
||||
var emptySessionIds = await _systemLogRepository.ListSessionIdsWithoutTasksAsync(cancellationToken);
|
||||
|
||||
if (emptySessionIds.Count == 0)
|
||||
{
|
||||
return RecordService.CreateEmptyDeleteResult();
|
||||
}
|
||||
|
||||
var result = await DeleteSessionsByIdsAsync(emptySessionIds, deleteFiles, cancellationToken);
|
||||
return new DeleteCompletedRecordTasksResultDto
|
||||
{
|
||||
DeletedTaskIds = result.DeletedTaskIds,
|
||||
DeletedFilePaths = result.DeletedFilePaths,
|
||||
DeletedDanmakuPaths = result.DeletedDanmakuPaths,
|
||||
DeletedSessionIds = result.DeletedSessionIds,
|
||||
Warnings = result.Warnings
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RecordSessionDeletionBatchResult> DeleteSessionsByIdsAsync(
|
||||
IReadOnlyCollection<Guid> requestedSessionIds,
|
||||
bool deleteFiles,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sessionIds = requestedSessionIds
|
||||
.Where(static item => item != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (sessionIds.Length == 0)
|
||||
{
|
||||
return RecordService.CreateEmptyDeleteResult();
|
||||
return new RecordSessionDeletionBatchResult
|
||||
{
|
||||
DeletedSessionIds = [],
|
||||
DeletedTaskIds = [],
|
||||
DeletedResultCount = 0,
|
||||
DeletedLogCount = 0,
|
||||
DeletedFilePaths = [],
|
||||
DeletedDanmakuPaths = [],
|
||||
Warnings = []
|
||||
};
|
||||
}
|
||||
|
||||
var warnings = new List<string>();
|
||||
@@ -139,6 +234,8 @@ public sealed class RecordSessionService
|
||||
var deletedTaskIds = new List<Guid>();
|
||||
var deletedFilePaths = new List<string>();
|
||||
var deletedDanmakuPaths = new List<string>();
|
||||
var deletedResultCount = 0;
|
||||
var deletedLogCount = 0;
|
||||
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
@@ -197,7 +294,7 @@ public sealed class RecordSessionService
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (request.DeleteFiles)
|
||||
if (deleteFiles)
|
||||
{
|
||||
foreach (var recordTask in session.RecordTasks)
|
||||
{
|
||||
@@ -208,6 +305,7 @@ public sealed class RecordSessionService
|
||||
var relatedLogs = await ListRelatedLogsAsync(session.Id, taskIds, cancellationToken);
|
||||
if (relatedLogs.Count > 0)
|
||||
{
|
||||
deletedLogCount += relatedLogs.Count;
|
||||
_systemLogRepository.RemoveRange(relatedLogs);
|
||||
}
|
||||
|
||||
@@ -217,6 +315,7 @@ public sealed class RecordSessionService
|
||||
.ToArray();
|
||||
if (results.Length > 0)
|
||||
{
|
||||
deletedResultCount += results.Length;
|
||||
_recordResultRepository.RemoveRange(results);
|
||||
}
|
||||
|
||||
@@ -231,81 +330,18 @@ public sealed class RecordSessionService
|
||||
deletedSessionIds.Add(session.Id);
|
||||
}
|
||||
|
||||
if (deletedSessionIds.Count > 0)
|
||||
{
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"RecordSession",
|
||||
$"Deleted {deletedSessionIds.Count} recording session(s).",
|
||||
detail: request.DeleteFiles
|
||||
? $"video-files={deletedFilePaths.Count}; danmaku-files={deletedDanmakuPaths.Count}; tasks={deletedTaskIds.Count}"
|
||||
: $"video-files=0; danmaku-files=0; tasks={deletedTaskIds.Count}",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return new DeleteCompletedRecordTasksResultDto
|
||||
return new RecordSessionDeletionBatchResult
|
||||
{
|
||||
DeletedSessionIds = deletedSessionIds,
|
||||
DeletedTaskIds = deletedTaskIds,
|
||||
DeletedResultCount = deletedResultCount,
|
||||
DeletedLogCount = deletedLogCount,
|
||||
DeletedFilePaths = deletedFilePaths,
|
||||
DeletedDanmakuPaths = deletedDanmakuPaths,
|
||||
DeletedSessionIds = deletedSessionIds,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DeleteCompletedRecordTasksResultDto> DeleteMissingFilesAsync(
|
||||
DeleteMissingFileRecordSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
|
||||
await ReconcileActiveSessionsAsync(null, cancellationToken);
|
||||
|
||||
var sessions = await _recordSessionRepository.ListAsync(null, cancellationToken);
|
||||
var missingFileSessionIds = sessions
|
||||
.Where(CanDeleteMissingFileSession)
|
||||
.Select(static item => item.Id)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (missingFileSessionIds.Length == 0)
|
||||
{
|
||||
return RecordService.CreateEmptyDeleteResult();
|
||||
}
|
||||
|
||||
return await DeleteAsync(
|
||||
new DeleteRecordSessionsRequest
|
||||
{
|
||||
SessionIds = missingFileSessionIds,
|
||||
DeleteFiles = request.DeleteFiles
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DeleteCompletedRecordTasksResultDto> DeleteEmptyAsync(
|
||||
bool deleteFiles,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
|
||||
await ReconcileActiveSessionsAsync(null, cancellationToken);
|
||||
|
||||
var emptySessionIds = await _systemLogRepository.ListSessionIdsWithoutTasksAsync(cancellationToken);
|
||||
|
||||
if (emptySessionIds.Count == 0)
|
||||
{
|
||||
return RecordService.CreateEmptyDeleteResult();
|
||||
}
|
||||
|
||||
return await DeleteAsync(
|
||||
new DeleteRecordSessionsRequest
|
||||
{
|
||||
SessionIds = emptySessionIds.ToArray(),
|
||||
DeleteFiles = deleteFiles
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
|
||||
{
|
||||
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LiveRecorder.Application.Services;
|
||||
|
||||
@@ -71,6 +73,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
private const string EnableRetentionCleanupKey = "retention.cleanup.enabled";
|
||||
private const string RetentionDaysKey = "retention.cleanup.days";
|
||||
private const string RetentionDeleteFilesKey = "retention.cleanup.delete_files";
|
||||
private const string RetentionVideoFileConditionKey = "retention.cleanup.video_file_condition";
|
||||
private const string RetentionTaskStatusesKey = "retention.cleanup.task_statuses";
|
||||
private const string EnableEmailNotificationKey = "notification.email.enabled";
|
||||
private const string EmailSmtpHostKey = "notification.email.smtp_host";
|
||||
private const string EmailSmtpPortKey = "notification.email.smtp_port";
|
||||
@@ -214,6 +218,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
EnableRetentionCleanup = bool.TryParse(GetValue(lookup, EnableRetentionCleanupKey, "false"), out var enableRetentionCleanup) && enableRetentionCleanup,
|
||||
RetentionDays = GetIntValue(lookup, RetentionDaysKey, 30, 1, 3650),
|
||||
RetentionDeleteFiles = bool.TryParse(GetValue(lookup, RetentionDeleteFilesKey, "false"), out var retentionDeleteFiles) && retentionDeleteFiles,
|
||||
RetentionVideoFileCondition = NormalizeCleanupVideoFileCondition(GetValue(lookup, RetentionVideoFileConditionKey, CleanupVideoFileConditions.Any)),
|
||||
RetentionTaskStatuses = GetIntListValue(lookup, RetentionTaskStatusesKey),
|
||||
EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification,
|
||||
EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty),
|
||||
EmailSmtpPort = GetIntValue(lookup, EmailSmtpPortKey, 587, 1, 65535),
|
||||
@@ -368,6 +374,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(EnableRetentionCleanupKey, request.EnableRetentionCleanup.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RetentionDaysKey, Math.Clamp(request.RetentionDays, 1, 3650).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RetentionDeleteFilesKey, request.RetentionDeleteFiles.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RetentionVideoFileConditionKey, NormalizeCleanupVideoFileCondition(request.RetentionVideoFileCondition), now, cancellationToken);
|
||||
await UpsertAsync(RetentionTaskStatusesKey, SerializeIntList(request.RetentionTaskStatuses), now, cancellationToken);
|
||||
await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(EmailSmtpPortKey, request.EmailSmtpPort.ToString(), now, cancellationToken);
|
||||
@@ -435,6 +443,36 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
return Math.Clamp(parsedValue, minimum, maximum);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<int> GetIntListValue(IReadOnlyDictionary<string, string> lookup, string key)
|
||||
{
|
||||
if (!lookup.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<List<int>>(raw);
|
||||
return parsed is null
|
||||
? []
|
||||
: parsed
|
||||
.Distinct()
|
||||
.OrderBy(static item => item)
|
||||
.ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return raw
|
||||
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static item => int.TryParse(item, out var value) ? value : (int?)null)
|
||||
.Where(static item => item.HasValue)
|
||||
.Select(static item => item!.Value)
|
||||
.Distinct()
|
||||
.OrderBy(static item => item)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeEventScriptMode(string? value) =>
|
||||
string.Equals(value?.Trim(), EventScriptSourceModes.Inline, StringComparison.OrdinalIgnoreCase)
|
||||
? EventScriptSourceModes.Inline
|
||||
@@ -445,6 +483,21 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
.Replace("Detected At (UTC)", "Detected At (Beijing Time)", StringComparison.Ordinal)
|
||||
.Replace("Occurred At (UTC)", "Occurred At (Beijing Time)", StringComparison.Ordinal);
|
||||
|
||||
private static string NormalizeCleanupVideoFileCondition(string? value) =>
|
||||
value?.Trim() switch
|
||||
{
|
||||
var item when string.Equals(item, CleanupVideoFileConditions.AllMissing, StringComparison.OrdinalIgnoreCase) => CleanupVideoFileConditions.AllMissing,
|
||||
var item when string.Equals(item, CleanupVideoFileConditions.AllPresent, StringComparison.OrdinalIgnoreCase) => CleanupVideoFileConditions.AllPresent,
|
||||
_ => CleanupVideoFileConditions.Any
|
||||
};
|
||||
|
||||
private static string SerializeIntList(IReadOnlyList<int>? values) =>
|
||||
JsonSerializer.Serialize(
|
||||
(values ?? [])
|
||||
.Distinct()
|
||||
.OrderBy(static item => item)
|
||||
.ToArray());
|
||||
|
||||
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Domain.Entities;
|
||||
|
||||
public class CleanupOperation
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private CleanupOperation()
|
||||
{
|
||||
}
|
||||
|
||||
public CleanupOperation(
|
||||
CleanupOperationKind kind,
|
||||
bool deleteFiles,
|
||||
string? filtersJson,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
Kind = kind;
|
||||
Status = CleanupOperationStatus.Queued;
|
||||
DeleteFiles = deleteFiles;
|
||||
FiltersJson = string.IsNullOrWhiteSpace(filtersJson) ? "{}" : filtersJson;
|
||||
WarningsJson = "[]";
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
|
||||
public CleanupOperationKind Kind { get; private set; }
|
||||
|
||||
public CleanupOperationStatus Status { get; private set; }
|
||||
|
||||
public bool DeleteFiles { get; private set; }
|
||||
|
||||
public string FiltersJson { get; private set; } = "{}";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset? StartedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; private set; }
|
||||
|
||||
public int TotalSessionCount { get; private set; }
|
||||
|
||||
public int ProcessedSessionCount { get; private set; }
|
||||
|
||||
public int DeletedSessionCount { get; private set; }
|
||||
|
||||
public int DeletedTaskCount { get; private set; }
|
||||
|
||||
public int DeletedResultCount { get; private set; }
|
||||
|
||||
public int DeletedLogCount { get; private set; }
|
||||
|
||||
public int DeletedFileCount { get; private set; }
|
||||
|
||||
public int DeletedDanmakuFileCount { get; private set; }
|
||||
|
||||
public string WarningsJson { get; private set; } = "[]";
|
||||
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
public void MarkRunning(DateTimeOffset startedAt)
|
||||
{
|
||||
Status = CleanupOperationStatus.Running;
|
||||
StartedAt = startedAt;
|
||||
CompletedAt = null;
|
||||
ErrorMessage = null;
|
||||
}
|
||||
|
||||
public void SetTotalSessionCount(int totalSessionCount)
|
||||
{
|
||||
TotalSessionCount = Math.Max(0, totalSessionCount);
|
||||
if (ProcessedSessionCount > TotalSessionCount)
|
||||
{
|
||||
ProcessedSessionCount = TotalSessionCount;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkCompleted(DateTimeOffset completedAt)
|
||||
{
|
||||
Status = CleanupOperationStatus.Completed;
|
||||
CompletedAt = completedAt;
|
||||
ErrorMessage = null;
|
||||
}
|
||||
|
||||
public void MarkFailed(string errorMessage, DateTimeOffset completedAt)
|
||||
{
|
||||
Status = CleanupOperationStatus.Failed;
|
||||
CompletedAt = completedAt;
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage)
|
||||
? "Cleanup operation failed."
|
||||
: errorMessage.Trim();
|
||||
}
|
||||
|
||||
public void Requeue(string warning)
|
||||
{
|
||||
Status = CleanupOperationStatus.Queued;
|
||||
StartedAt = null;
|
||||
CompletedAt = null;
|
||||
ErrorMessage = null;
|
||||
AppendWarnings([warning]);
|
||||
}
|
||||
|
||||
public void ApplyBatchProgress(
|
||||
int processedSessionCount,
|
||||
int deletedSessionCount,
|
||||
int deletedTaskCount,
|
||||
int deletedResultCount,
|
||||
int deletedLogCount,
|
||||
int deletedFileCount,
|
||||
int deletedDanmakuFileCount,
|
||||
IReadOnlyCollection<string>? warnings = null)
|
||||
{
|
||||
ProcessedSessionCount += Math.Max(0, processedSessionCount);
|
||||
DeletedSessionCount += Math.Max(0, deletedSessionCount);
|
||||
DeletedTaskCount += Math.Max(0, deletedTaskCount);
|
||||
DeletedResultCount += Math.Max(0, deletedResultCount);
|
||||
DeletedLogCount += Math.Max(0, deletedLogCount);
|
||||
DeletedFileCount += Math.Max(0, deletedFileCount);
|
||||
DeletedDanmakuFileCount += Math.Max(0, deletedDanmakuFileCount);
|
||||
|
||||
if (ProcessedSessionCount > TotalSessionCount)
|
||||
{
|
||||
ProcessedSessionCount = TotalSessionCount;
|
||||
}
|
||||
|
||||
if (warnings is { Count: > 0 })
|
||||
{
|
||||
AppendWarnings(warnings);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetWarnings()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(WarningsJson))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(WarningsJson, JsonOptions) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendWarnings(IReadOnlyCollection<string> warnings)
|
||||
{
|
||||
if (warnings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var merged = GetWarnings().Concat(warnings.Where(static item => !string.IsNullOrWhiteSpace(item)))
|
||||
.Select(static item => item.Trim())
|
||||
.ToList();
|
||||
WarningsJson = JsonSerializer.Serialize(merged, JsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LiveRecorder.Domain.Enums;
|
||||
|
||||
public enum CleanupOperationKind
|
||||
{
|
||||
Selected = 0,
|
||||
Conditional = 1,
|
||||
Empty = 2,
|
||||
Retention = 3
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LiveRecorder.Domain.Enums;
|
||||
|
||||
public enum CleanupOperationStatus
|
||||
{
|
||||
Queued = 0,
|
||||
Running = 1,
|
||||
Completed = 2,
|
||||
Failed = 3
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LiveRecorder.Domain.Enums;
|
||||
|
||||
public enum CleanupVideoFileCondition
|
||||
{
|
||||
Any = 0,
|
||||
AllMissing = 1,
|
||||
AllPresent = 2
|
||||
}
|
||||
@@ -21,6 +21,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
||||
|
||||
public DbSet<SystemLogEntry> SystemLogEntries => Set<SystemLogEntry>();
|
||||
|
||||
public DbSet<CleanupOperation> CleanupOperations => Set<CleanupOperation>();
|
||||
|
||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||
|
||||
public DbSet<UserAccount> UserAccounts => Set<UserAccount>();
|
||||
@@ -128,6 +130,18 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
||||
builder.HasIndex(static x => x.RecordSessionId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CleanupOperation>(builder =>
|
||||
{
|
||||
builder.ToTable("CleanupOperations");
|
||||
builder.HasKey(static x => x.Id);
|
||||
builder.Property(static x => x.Kind).HasConversion<int>();
|
||||
builder.Property(static x => x.Status).HasConversion<int>();
|
||||
builder.Property(static x => x.FiltersJson).HasColumnType("text");
|
||||
builder.Property(static x => x.WarningsJson).HasColumnType("text");
|
||||
builder.Property(static x => x.ErrorMessage).HasColumnType("text");
|
||||
builder.HasIndex(static x => new { x.Status, x.CreatedAt });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AppSetting>(builder =>
|
||||
{
|
||||
builder.ToTable("AppSettings");
|
||||
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(LiveRecorderDbContext))]
|
||||
[Migration("20260508090000_AddCleanupOperations")]
|
||||
partial class AddCleanupOperations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.AppSetting", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AppSettings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.CleanupOperation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("DeleteFiles")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("DeletedDanmakuFileCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedFileCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedLogCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedResultCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedSessionCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedTaskCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FiltersJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ProcessedSessionCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalSessionCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("WarningsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("CleanupOperations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Alias")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("AnchorId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("AnchorName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("AvailabilityStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool?>("DanmakuIncludeNonChatEventsOverride")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("DanmakuMinPollIntervalMillisecondsOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("DanmakuRetryDelayMaxSecondsOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool?>("EnableAutoReconnectOverride")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool?>("EnableDanmakuRecordingOverride")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HasSentLiveNotificationForCurrentSession")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IsPinned")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsPriority")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAutoStartDecisionAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastAutoStartDecisionCode")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("LastAutoStartDecisionDetail")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("LastAutoStartDecisionSummary")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastCheckedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastStartRecordingTriggeredAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<int?>("OutputFormatOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("PollingIntervalSecondsOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("PreferredQualityOverride")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int?>("ReadWriteTimeoutMillisecondsOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ReconnectDelayMaxSecondsOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("RecordingTemplateOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("RoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int?>("SaveModeOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SegmentDurationMinutesOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Platform", "RoomId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("LiveRooms", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DanmakuFilePath")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DanmakuMessageCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("DeletedLocalFilesAfterUpload")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<long?>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("FinalStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("LastUploadProvider")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastUploadedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("RecordTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RemoteDanmakuPath")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("RemoteVideoPath")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("UploadErrorMessage")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("UploadStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecordTaskId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RecordResults", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ActiveSegmentIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("EndedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("LiveRoomId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("OutputFormat")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OutputPathPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("PreferredQuality")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int?>("RecorderProcessId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SaveMode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SegmentCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("StreamUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LiveRoomId");
|
||||
|
||||
b.ToTable("RecordSessions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset?>("EndedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("LiveRoomId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("OutputFilePath")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("OutputFormat")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("PreferredQuality")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("RecordSessionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int?>("RecorderProcessId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SegmentIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("StreamUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LiveRoomId");
|
||||
|
||||
b.HasIndex("RecordSessionId", "SegmentIndex");
|
||||
|
||||
b.ToTable("RecordTasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.SystemLogEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Detail")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("LiveRoomId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid?>("RecordSessionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("RecordTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RecordSessionId");
|
||||
|
||||
b.ToTable("SystemLogEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserAccount", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserAccounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserAccountId");
|
||||
|
||||
b.ToTable("UserSessions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
|
||||
{
|
||||
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
|
||||
.WithOne("Result")
|
||||
.HasForeignKey("LiveRecorder.Domain.Entities.RecordResult", "RecordTaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("RecordTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
|
||||
{
|
||||
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
|
||||
.WithMany()
|
||||
.HasForeignKey("LiveRoomId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("LiveRoom");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
|
||||
{
|
||||
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
|
||||
.WithMany("RecordTasks")
|
||||
.HasForeignKey("LiveRoomId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LiveRecorder.Domain.Entities.RecordSession", "RecordSession")
|
||||
.WithMany("RecordTasks")
|
||||
.HasForeignKey("RecordSessionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("LiveRoom");
|
||||
|
||||
b.Navigation("RecordSession");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
|
||||
{
|
||||
b.HasOne("LiveRecorder.Domain.Entities.UserAccount", "UserAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("UserAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
|
||||
{
|
||||
b.Navigation("RecordTasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
|
||||
{
|
||||
b.Navigation("RecordTasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
|
||||
{
|
||||
b.Navigation("Result");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCleanupOperations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CleanupOperations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Kind = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
DeleteFiles = table.Column<bool>(type: "boolean", nullable: false),
|
||||
FiltersJson = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
TotalSessionCount = table.Column<int>(type: "integer", nullable: false),
|
||||
ProcessedSessionCount = table.Column<int>(type: "integer", nullable: false),
|
||||
DeletedSessionCount = table.Column<int>(type: "integer", nullable: false),
|
||||
DeletedTaskCount = table.Column<int>(type: "integer", nullable: false),
|
||||
DeletedResultCount = table.Column<int>(type: "integer", nullable: false),
|
||||
DeletedLogCount = table.Column<int>(type: "integer", nullable: false),
|
||||
DeletedFileCount = table.Column<int>(type: "integer", nullable: false),
|
||||
DeletedDanmakuFileCount = table.Column<int>(type: "integer", nullable: false),
|
||||
WarningsJson = table.Column<string>(type: "text", nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CleanupOperations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CleanupOperations_Status_CreatedAt",
|
||||
table: "CleanupOperations",
|
||||
columns: new[] { "Status", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CleanupOperations");
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -48,6 +48,72 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("AppSettings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.CleanupOperation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("DeleteFiles")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("DeletedDanmakuFileCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedFileCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedLogCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedResultCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedSessionCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeletedTaskCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FiltersJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ProcessedSessionCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalSessionCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("WarningsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("CleanupOperations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class CleanupOperationBackgroundService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(2);
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<CleanupOperationBackgroundService> _logger;
|
||||
|
||||
public CleanupOperationBackgroundService(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILogger<CleanupOperationBackgroundService> logger)
|
||||
{
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var startupScope = _serviceScopeFactory.CreateScope();
|
||||
var coordinator = startupScope.ServiceProvider.GetRequiredService<CleanupOperationCoordinator>();
|
||||
await coordinator.RequeueRunningOperationsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to requeue interrupted cleanup operations at startup");
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var coordinator = scope.ServiceProvider.GetRequiredService<CleanupOperationCoordinator>();
|
||||
var processed = await coordinator.ProcessNextQueuedOperationAsync(stoppingToken);
|
||||
if (processed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Cleanup operation background worker failed");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(IdleDelay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class CleanupOperationCoordinator
|
||||
{
|
||||
private const int SessionDeleteBatchSize = 32;
|
||||
private readonly LiveRecorderDbContext _dbContext;
|
||||
private readonly RecordSessionCleanupResolver _cleanupResolver;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<CleanupOperationCoordinator> _logger;
|
||||
|
||||
public CleanupOperationCoordinator(
|
||||
LiveRecorderDbContext dbContext,
|
||||
RecordSessionCleanupResolver cleanupResolver,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILogger<CleanupOperationCoordinator> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_cleanupResolver = cleanupResolver;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CleanupOperationDto> EnqueueSelectedAsync(
|
||||
DeleteRecordSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var operation = new CleanupOperation(
|
||||
CleanupOperationKind.Selected,
|
||||
request.DeleteFiles,
|
||||
JsonSerializer.Serialize(
|
||||
new SelectedCleanupOperationFilters
|
||||
{
|
||||
SessionIds = request.SessionIds
|
||||
},
|
||||
CleanupOperationSupport.JsonOptions),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
await _dbContext.CleanupOperations.AddAsync(operation, cancellationToken);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return CleanupOperationSupport.Map(operation);
|
||||
}
|
||||
|
||||
public async Task<CleanupOperationDto> EnqueueConditionalAsync(
|
||||
DeleteConditionalSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var operation = new CleanupOperation(
|
||||
CleanupOperationKind.Conditional,
|
||||
request.DeleteFiles,
|
||||
JsonSerializer.Serialize(
|
||||
new ConditionalCleanupOperationFilters
|
||||
{
|
||||
VideoFileCondition = request.VideoFileCondition,
|
||||
TaskStatuses = request.TaskStatuses
|
||||
},
|
||||
CleanupOperationSupport.JsonOptions),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
await _dbContext.CleanupOperations.AddAsync(operation, cancellationToken);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return CleanupOperationSupport.Map(operation);
|
||||
}
|
||||
|
||||
public async Task<CleanupOperationDto> EnqueueEmptyAsync(
|
||||
DeleteEmptyRecordSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var operation = new CleanupOperation(
|
||||
CleanupOperationKind.Empty,
|
||||
request.DeleteFiles,
|
||||
JsonSerializer.Serialize(new EmptyCleanupOperationFilters(), CleanupOperationSupport.JsonOptions),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
await _dbContext.CleanupOperations.AddAsync(operation, cancellationToken);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return CleanupOperationSupport.Map(operation);
|
||||
}
|
||||
|
||||
public async Task<CleanupOperationDto> EnqueueRetentionAsync(
|
||||
SystemSettingsDto settings,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
var existing = await _dbContext.CleanupOperations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Kind == CleanupOperationKind.Retention &&
|
||||
(item.Status == CleanupOperationStatus.Queued || item.Status == CleanupOperationStatus.Running))
|
||||
.OrderByDescending(static item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return CleanupOperationSupport.Map(existing);
|
||||
}
|
||||
|
||||
var operation = new CleanupOperation(
|
||||
CleanupOperationKind.Retention,
|
||||
settings.RetentionDeleteFiles,
|
||||
JsonSerializer.Serialize(
|
||||
new RetentionCleanupOperationFilters
|
||||
{
|
||||
RetentionDays = settings.RetentionDays,
|
||||
VideoFileCondition = settings.RetentionVideoFileCondition,
|
||||
TaskStatuses = settings.RetentionTaskStatuses
|
||||
},
|
||||
CleanupOperationSupport.JsonOptions),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
await _dbContext.CleanupOperations.AddAsync(operation, cancellationToken);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return CleanupOperationSupport.Map(operation);
|
||||
}
|
||||
|
||||
public async Task<CleanupOperationDto?> GetAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var operation = await _dbContext.CleanupOperations
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||
|
||||
return operation is null ? null : CleanupOperationSupport.Map(operation);
|
||||
}
|
||||
|
||||
public async Task RequeueRunningOperationsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var runningOperations = await _dbContext.CleanupOperations
|
||||
.Where(item => item.Status == CleanupOperationStatus.Running)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (runningOperations.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var operation in runningOperations)
|
||||
{
|
||||
operation.Requeue("Cleanup operation was interrupted by an application restart and has been queued again.");
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ProcessNextQueuedOperationAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var operation = await _dbContext.CleanupOperations
|
||||
.OrderBy(static item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(item => item.Status == CleanupOperationStatus.Queued, cancellationToken);
|
||||
|
||||
if (operation is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
operation.MarkRunning(DateTimeOffset.UtcNow);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var sessionIds = await _cleanupResolver.ResolveSessionIdsAsync(operation, cancellationToken);
|
||||
operation.SetTotalSessionCount(sessionIds.Count);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (var batch in sessionIds.Chunk(SessionDeleteBatchSize))
|
||||
{
|
||||
using var batchScope = _serviceScopeFactory.CreateScope();
|
||||
var batchDbContext = batchScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var recordSessionService = batchScope.ServiceProvider.GetRequiredService<RecordSessionService>();
|
||||
var trackedOperation = await batchDbContext.CleanupOperations
|
||||
.FirstAsync(item => item.Id == operation.Id, cancellationToken);
|
||||
|
||||
var batchResult = await recordSessionService.DeleteSessionsByIdsAsync(batch, trackedOperation.DeleteFiles, cancellationToken);
|
||||
trackedOperation.ApplyBatchProgress(
|
||||
processedSessionCount: batch.Length,
|
||||
deletedSessionCount: batchResult.DeletedSessionIds.Count,
|
||||
deletedTaskCount: batchResult.DeletedTaskIds.Count,
|
||||
deletedResultCount: batchResult.DeletedResultCount,
|
||||
deletedLogCount: batchResult.DeletedLogCount,
|
||||
deletedFileCount: batchResult.DeletedFilePaths.Count,
|
||||
deletedDanmakuFileCount: batchResult.DeletedDanmakuPaths.Count,
|
||||
warnings: batchResult.Warnings);
|
||||
|
||||
await batchDbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
using var completionScope = _serviceScopeFactory.CreateScope();
|
||||
var completionDbContext = completionScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var completedOperation = await completionDbContext.CleanupOperations
|
||||
.FirstAsync(item => item.Id == operation.Id, cancellationToken);
|
||||
completedOperation.MarkCompleted(DateTimeOffset.UtcNow);
|
||||
await completionDbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Cleanup operation {CleanupOperationId} failed", operation.Id);
|
||||
|
||||
using var failureScope = _serviceScopeFactory.CreateScope();
|
||||
var failureDbContext = failureScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var failedOperation = await failureDbContext.CleanupOperations
|
||||
.FirstAsync(item => item.Id == operation.Id, CancellationToken.None);
|
||||
failedOperation.MarkFailed(ex.ToString(), DateTimeOffset.UtcNow);
|
||||
await failureDbContext.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
internal static class CleanupOperationSupport
|
||||
{
|
||||
internal static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
internal static CleanupOperationDto Map(CleanupOperation operation) => new()
|
||||
{
|
||||
Id = operation.Id,
|
||||
Kind = MapKind(operation.Kind),
|
||||
Status = MapStatus(operation.Status),
|
||||
DeleteFiles = operation.DeleteFiles,
|
||||
CreatedAt = operation.CreatedAt,
|
||||
StartedAt = operation.StartedAt,
|
||||
CompletedAt = operation.CompletedAt,
|
||||
TotalSessionCount = operation.TotalSessionCount,
|
||||
ProcessedSessionCount = operation.ProcessedSessionCount,
|
||||
DeletedSessionCount = operation.DeletedSessionCount,
|
||||
DeletedTaskCount = operation.DeletedTaskCount,
|
||||
DeletedResultCount = operation.DeletedResultCount,
|
||||
DeletedLogCount = operation.DeletedLogCount,
|
||||
DeletedFileCount = operation.DeletedFileCount,
|
||||
DeletedDanmakuFileCount = operation.DeletedDanmakuFileCount,
|
||||
Warnings = operation.GetWarnings(),
|
||||
ErrorMessage = operation.ErrorMessage
|
||||
};
|
||||
|
||||
internal static string MapKind(CleanupOperationKind kind) =>
|
||||
kind switch
|
||||
{
|
||||
CleanupOperationKind.Conditional => CleanupOperationKinds.Conditional,
|
||||
CleanupOperationKind.Empty => CleanupOperationKinds.Empty,
|
||||
CleanupOperationKind.Retention => CleanupOperationKinds.Retention,
|
||||
_ => CleanupOperationKinds.Selected
|
||||
};
|
||||
|
||||
internal static string MapStatus(CleanupOperationStatus status) =>
|
||||
status switch
|
||||
{
|
||||
CleanupOperationStatus.Running => CleanupOperationStatuses.Running,
|
||||
CleanupOperationStatus.Completed => CleanupOperationStatuses.Completed,
|
||||
CleanupOperationStatus.Failed => CleanupOperationStatuses.Failed,
|
||||
_ => CleanupOperationStatuses.Queued
|
||||
};
|
||||
|
||||
internal static CleanupVideoFileCondition ParseVideoFileCondition(string? value) =>
|
||||
value?.Trim() switch
|
||||
{
|
||||
var item when string.Equals(item, CleanupVideoFileConditions.AllMissing, StringComparison.OrdinalIgnoreCase) => CleanupVideoFileCondition.AllMissing,
|
||||
var item when string.Equals(item, CleanupVideoFileConditions.AllPresent, StringComparison.OrdinalIgnoreCase) => CleanupVideoFileCondition.AllPresent,
|
||||
_ => CleanupVideoFileCondition.Any
|
||||
};
|
||||
|
||||
internal static IReadOnlySet<RecordTaskStatus> ParseTaskStatuses(IReadOnlyCollection<int>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return new HashSet<RecordTaskStatus>();
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static value => Enum.IsDefined(typeof(RecordTaskStatus), value))
|
||||
.Select(static value => (RecordTaskStatus)value)
|
||||
.ToHashSet();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SelectedCleanupOperationFilters
|
||||
{
|
||||
public IReadOnlyList<Guid> SessionIds { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class ConditionalCleanupOperationFilters
|
||||
{
|
||||
public string VideoFileCondition { get; init; } = CleanupVideoFileConditions.Any;
|
||||
|
||||
public IReadOnlyList<int> TaskStatuses { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class RetentionCleanupOperationFilters : ConditionalCleanupOperationFilters
|
||||
{
|
||||
public int RetentionDays { get; init; } = 30;
|
||||
}
|
||||
|
||||
internal sealed class EmptyCleanupOperationFilters
|
||||
{
|
||||
}
|
||||
|
||||
internal sealed class CleanupTaskCandidate
|
||||
{
|
||||
public Guid SessionId { get; init; }
|
||||
|
||||
public RecordTaskStatus Status { get; init; }
|
||||
|
||||
public string? ResultFilePath { get; init; }
|
||||
|
||||
public string? OutputFilePath { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class RecordSessionCleanupResolver
|
||||
{
|
||||
private const int CandidateBatchSize = 128;
|
||||
private readonly LiveRecorderDbContext _dbContext;
|
||||
private readonly IFfmpegService _ffmpegService;
|
||||
|
||||
public RecordSessionCleanupResolver(
|
||||
LiveRecorderDbContext dbContext,
|
||||
IFfmpegService ffmpegService)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_ffmpegService = ffmpegService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Guid>> ResolveSessionIdsAsync(
|
||||
CleanupOperation operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return operation.Kind switch
|
||||
{
|
||||
CleanupOperationKind.Selected => ResolveSelectedSessionIds(operation),
|
||||
CleanupOperationKind.Conditional => await ResolveConditionalSessionIdsAsync(
|
||||
Deserialize<ConditionalCleanupOperationFilters>(operation.FiltersJson),
|
||||
createdBeforeUtc: null,
|
||||
cancellationToken),
|
||||
CleanupOperationKind.Empty => await ResolveEmptySessionIdsAsync(
|
||||
createdBeforeUtc: null,
|
||||
cancellationToken),
|
||||
CleanupOperationKind.Retention => await ResolveRetentionSessionIdsAsync(
|
||||
Deserialize<RetentionCleanupOperationFilters>(operation.FiltersJson),
|
||||
cancellationToken),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<Guid>> ResolveRetentionSessionIdsAsync(
|
||||
RetentionCleanupOperationFilters filters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-Math.Max(1, filters.RetentionDays));
|
||||
var nonEmptySessionIds = await ResolveConditionalSessionIdsAsync(filters, cutoff, cancellationToken);
|
||||
var emptySessionIds = await ResolveEmptySessionIdsAsync(cutoff, cancellationToken);
|
||||
|
||||
return nonEmptySessionIds
|
||||
.Concat(emptySessionIds)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IReadOnlyList<Guid> ResolveSelectedSessionIds(CleanupOperation operation)
|
||||
{
|
||||
var filters = Deserialize<SelectedCleanupOperationFilters>(operation.FiltersJson);
|
||||
return filters.SessionIds
|
||||
.Where(static item => item != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<Guid>> ResolveConditionalSessionIdsAsync(
|
||||
ConditionalCleanupOperationFilters filters,
|
||||
DateTimeOffset? createdBeforeUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ReconcileActiveSessionsAsync(cancellationToken);
|
||||
|
||||
IQueryable<RecordSession> query = _dbContext.RecordSessions
|
||||
.AsNoTracking()
|
||||
.Where(static item => item.Status != RecordSessionStatus.Starting &&
|
||||
item.Status != RecordSessionStatus.Running &&
|
||||
item.Status != RecordSessionStatus.Stopping)
|
||||
.Where(item => _dbContext.RecordTasks.Any(task => task.RecordSessionId == item.Id));
|
||||
|
||||
if (createdBeforeUtc.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.CreatedAt < createdBeforeUtc.Value);
|
||||
}
|
||||
|
||||
var candidateSessionIds = await query
|
||||
.OrderBy(static item => item.CreatedAt)
|
||||
.Select(static item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (candidateSessionIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var matchedSessionIds = new List<Guid>();
|
||||
var allowedStatuses = CleanupOperationSupport.ParseTaskStatuses(filters.TaskStatuses);
|
||||
var videoFileCondition = CleanupOperationSupport.ParseVideoFileCondition(filters.VideoFileCondition);
|
||||
|
||||
foreach (var batch in candidateSessionIds.Chunk(CandidateBatchSize))
|
||||
{
|
||||
var taskCandidates = await _dbContext.RecordTasks
|
||||
.AsNoTracking()
|
||||
.Where(item => batch.Contains(item.RecordSessionId))
|
||||
.Select(item => new CleanupTaskCandidate
|
||||
{
|
||||
SessionId = item.RecordSessionId,
|
||||
Status = item.Status,
|
||||
ResultFilePath = item.Result != null ? item.Result.FilePath : null,
|
||||
OutputFilePath = item.OutputFilePath
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var group in taskCandidates.GroupBy(static item => item.SessionId))
|
||||
{
|
||||
if (MatchesAllTaskConditions(group, allowedStatuses, videoFileCondition))
|
||||
{
|
||||
matchedSessionIds.Add(group.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchedSessionIds;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<Guid>> ResolveEmptySessionIdsAsync(
|
||||
DateTimeOffset? createdBeforeUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ReconcileActiveSessionsAsync(cancellationToken);
|
||||
|
||||
IQueryable<RecordSession> query = _dbContext.RecordSessions
|
||||
.AsNoTracking()
|
||||
.Where(static item => item.Status != RecordSessionStatus.Starting &&
|
||||
item.Status != RecordSessionStatus.Running &&
|
||||
item.Status != RecordSessionStatus.Stopping)
|
||||
.Where(item => !_dbContext.RecordTasks.Any(task => task.RecordSessionId == item.Id));
|
||||
|
||||
if (createdBeforeUtc.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.CreatedAt < createdBeforeUtc.Value);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(static item => item.CreatedAt)
|
||||
.Select(static item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ReconcileActiveSessionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var activeSessionIds = await _dbContext.RecordSessions
|
||||
.AsNoTracking()
|
||||
.Where(static item => item.Status == RecordSessionStatus.Starting ||
|
||||
item.Status == RecordSessionStatus.Running ||
|
||||
item.Status == RecordSessionStatus.Stopping)
|
||||
.Select(static item => item.Id)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var activeSessionId in activeSessionIds)
|
||||
{
|
||||
await _ffmpegService.TryReconcileInactiveSessionAsync(activeSessionId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesAllTaskConditions(
|
||||
IEnumerable<CleanupTaskCandidate> tasks,
|
||||
IReadOnlySet<RecordTaskStatus> allowedStatuses,
|
||||
CleanupVideoFileCondition videoFileCondition)
|
||||
{
|
||||
var taskList = tasks.ToList();
|
||||
if (taskList.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allowedStatuses.Count > 0 && !taskList.All(task => allowedStatuses.Contains(task.Status)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return videoFileCondition switch
|
||||
{
|
||||
CleanupVideoFileCondition.AllMissing => taskList.All(static task => !HasExistingVideoFile(task)),
|
||||
CleanupVideoFileCondition.AllPresent => taskList.All(HasExistingVideoFile),
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasExistingVideoFile(CleanupTaskCandidate task)
|
||||
{
|
||||
var candidatePath = !string.IsNullOrWhiteSpace(task.ResultFilePath)
|
||||
? task.ResultFilePath
|
||||
: 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 T Deserialize<T>(string? json)
|
||||
where T : new()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(json, CleanupOperationSupport.JsonOptions) ?? new T();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -29,14 +26,8 @@ public sealed class RetentionCleanupBackgroundService : BackgroundService
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||
var cleanupService = scope.ServiceProvider.GetRequiredService<RetentionCleanupService>();
|
||||
var settings = await settingsService.GetAsync(stoppingToken);
|
||||
|
||||
if (settings.EnableRetentionCleanup)
|
||||
{
|
||||
await cleanupService.RunAsync(ignoreEnabledSetting: false, stoppingToken);
|
||||
}
|
||||
await cleanupService.TryEnqueueAsync(ignoreEnabledSetting: false, cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -45,22 +36,6 @@ public sealed class RetentionCleanupBackgroundService : BackgroundService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Retention cleanup background task failed");
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Error,
|
||||
"Retention",
|
||||
"Retention cleanup background task failed.",
|
||||
ex.ToString(),
|
||||
cancellationToken: CancellationToken.None);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
_logger.LogWarning(logEx, "Failed to persist retention cleanup background error log");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -1,283 +1,31 @@
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class RetentionCleanupService
|
||||
{
|
||||
private readonly LiveRecorderDbContext _dbContext;
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
|
||||
|
||||
public RetentionCleanupService(
|
||||
LiveRecorderDbContext dbContext,
|
||||
ISystemSettingsService systemSettingsService,
|
||||
ISystemLogService systemLogService)
|
||||
CleanupOperationCoordinator cleanupOperationCoordinator)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_systemLogService = systemLogService;
|
||||
_cleanupOperationCoordinator = cleanupOperationCoordinator;
|
||||
}
|
||||
|
||||
public async Task<RetentionCleanupResultDto> RunAsync(
|
||||
public async Task<CleanupOperationDto?> TryEnqueueAsync(
|
||||
bool ignoreEnabledSetting = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableRetentionCleanup && !ignoreEnabledSetting)
|
||||
{
|
||||
return CreateEmptyResult();
|
||||
return null;
|
||||
}
|
||||
|
||||
var warnings = new List<string>();
|
||||
var deletedFilePaths = new List<string>();
|
||||
var deletedDanmakuPaths = new List<string>();
|
||||
var deletedTaskIds = new HashSet<Guid>();
|
||||
var deletedSessionIds = new HashSet<Guid>();
|
||||
var deletedResultIds = new HashSet<Guid>();
|
||||
var deletedLogIds = new HashSet<Guid>();
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-Math.Max(1, settings.RetentionDays));
|
||||
|
||||
var staleTasks = await _dbContext.RecordTasks
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordSession)
|
||||
.Include(item => item.Result)
|
||||
.Where(item => item.CreatedAt < cutoff &&
|
||||
item.Status != RecordTaskStatus.Starting &&
|
||||
item.Status != RecordTaskStatus.Running &&
|
||||
item.Status != RecordTaskStatus.Stopping &&
|
||||
item.Status != RecordTaskStatus.Processing)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var task in staleTasks)
|
||||
{
|
||||
if (settings.RetentionDeleteFiles)
|
||||
{
|
||||
TryDeleteRecordOutput(task, warnings, deletedFilePaths, deletedDanmakuPaths);
|
||||
}
|
||||
|
||||
var taskLogs = await _dbContext.SystemLogEntries
|
||||
.Where(item => item.RecordTaskId == task.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var log in taskLogs)
|
||||
{
|
||||
deletedLogIds.Add(log.Id);
|
||||
}
|
||||
|
||||
if (task.Result is not null)
|
||||
{
|
||||
deletedResultIds.Add(task.Result.Id);
|
||||
_dbContext.RecordResults.Remove(task.Result);
|
||||
}
|
||||
|
||||
if (taskLogs.Count > 0)
|
||||
{
|
||||
_dbContext.SystemLogEntries.RemoveRange(taskLogs);
|
||||
}
|
||||
|
||||
deletedTaskIds.Add(task.Id);
|
||||
_dbContext.RecordTasks.Remove(task);
|
||||
}
|
||||
|
||||
if (deletedTaskIds.Count > 0 || deletedResultIds.Count > 0 || deletedLogIds.Count > 0)
|
||||
{
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var staleSessions = await _dbContext.RecordSessions
|
||||
.Include(item => item.RecordTasks)
|
||||
.Where(item => item.CreatedAt < cutoff &&
|
||||
item.Status != RecordSessionStatus.Starting &&
|
||||
item.Status != RecordSessionStatus.Running &&
|
||||
item.Status != RecordSessionStatus.Stopping)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var session in staleSessions.Where(static item => item.RecordTasks.Count == 0))
|
||||
{
|
||||
var sessionLogs = await _dbContext.SystemLogEntries
|
||||
.Where(item => item.RecordSessionId == session.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var log in sessionLogs)
|
||||
{
|
||||
deletedLogIds.Add(log.Id);
|
||||
}
|
||||
|
||||
if (sessionLogs.Count > 0)
|
||||
{
|
||||
_dbContext.SystemLogEntries.RemoveRange(sessionLogs);
|
||||
}
|
||||
|
||||
deletedSessionIds.Add(session.Id);
|
||||
_dbContext.RecordSessions.Remove(session);
|
||||
}
|
||||
|
||||
var staleGlobalLogs = await _dbContext.SystemLogEntries
|
||||
.Where(item => item.CreatedAt < cutoff)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var log in staleGlobalLogs)
|
||||
{
|
||||
deletedLogIds.Add(log.Id);
|
||||
}
|
||||
|
||||
if (staleGlobalLogs.Count > 0)
|
||||
{
|
||||
_dbContext.SystemLogEntries.RemoveRange(staleGlobalLogs);
|
||||
}
|
||||
|
||||
if (deletedSessionIds.Count > 0 || staleGlobalLogs.Count > 0)
|
||||
{
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var result = new RetentionCleanupResultDto
|
||||
{
|
||||
DeletedSessionCount = deletedSessionIds.Count,
|
||||
DeletedTaskCount = deletedTaskIds.Count,
|
||||
DeletedResultCount = deletedResultIds.Count,
|
||||
DeletedLogCount = deletedLogIds.Count,
|
||||
DeletedFileCount = deletedFilePaths.Count,
|
||||
DeletedDanmakuFileCount = deletedDanmakuPaths.Count,
|
||||
Warnings = warnings
|
||||
};
|
||||
|
||||
if (deletedSessionIds.Count > 0 ||
|
||||
deletedTaskIds.Count > 0 ||
|
||||
deletedResultIds.Count > 0 ||
|
||||
deletedLogIds.Count > 0 ||
|
||||
deletedFilePaths.Count > 0 ||
|
||||
deletedDanmakuPaths.Count > 0 ||
|
||||
warnings.Count > 0)
|
||||
{
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"Retention",
|
||||
"Retention cleanup completed.",
|
||||
$"sessions={result.DeletedSessionCount}; tasks={result.DeletedTaskCount}; results={result.DeletedResultCount}; logs={result.DeletedLogCount}; video-files={result.DeletedFileCount}; danmaku-files={result.DeletedDanmakuFileCount}; warnings={warnings.Count}",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static RetentionCleanupResultDto CreateEmptyResult() => new()
|
||||
{
|
||||
DeletedSessionCount = 0,
|
||||
DeletedTaskCount = 0,
|
||||
DeletedResultCount = 0,
|
||||
DeletedLogCount = 0,
|
||||
DeletedFileCount = 0,
|
||||
DeletedDanmakuFileCount = 0,
|
||||
Warnings = []
|
||||
};
|
||||
|
||||
private static void TryDeleteRecordOutput(
|
||||
RecordTask recordTask,
|
||||
List<string> warnings,
|
||||
List<string> deletedFilePaths,
|
||||
List<string> deletedDanmakuPaths)
|
||||
{
|
||||
var outputPath = recordTask.Result?.FilePath ?? recordTask.OutputFilePath;
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
TryDeletePath(outputPath, warnings, deletedFilePaths, $"output for task {recordTask.Id}");
|
||||
TryDeleteIntermediateRecordingArtifacts(outputPath, recordTask.OutputFormat, warnings, deletedFilePaths, recordTask.Id);
|
||||
}
|
||||
|
||||
var danmakuPath = recordTask.Result?.DanmakuFilePath;
|
||||
if (!string.IsNullOrWhiteSpace(danmakuPath))
|
||||
{
|
||||
TryDeletePath(danmakuPath, warnings, deletedDanmakuPaths, $"danmaku for task {recordTask.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteIntermediateRecordingArtifacts(
|
||||
string finalOutputPath,
|
||||
RecordOutputFormat outputFormat,
|
||||
List<string> warnings,
|
||||
List<string> deletedFilePaths,
|
||||
Guid recordTaskId)
|
||||
{
|
||||
if (outputFormat != RecordOutputFormat.Mp4)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var absoluteFinalPath = Path.IsPathRooted(finalOutputPath)
|
||||
? finalOutputPath
|
||||
: Path.GetFullPath(finalOutputPath, AppContext.BaseDirectory);
|
||||
|
||||
var intermediateCandidates = new[]
|
||||
{
|
||||
Path.ChangeExtension(absoluteFinalPath, ".ts"),
|
||||
Path.Combine(
|
||||
Path.GetDirectoryName(absoluteFinalPath) ?? string.Empty,
|
||||
$"{Path.GetFileNameWithoutExtension(absoluteFinalPath)}.recording.ts")
|
||||
};
|
||||
|
||||
foreach (var candidate in intermediateCandidates
|
||||
.Where(static path => !string.IsNullOrWhiteSpace(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(candidate, absoluteFinalPath, StringComparison.OrdinalIgnoreCase) || !File.Exists(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryDeletePath(candidate, warnings, deletedFilePaths, $"intermediate output for task {recordTaskId}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeletePath(
|
||||
string path,
|
||||
List<string> warnings,
|
||||
List<string> deletedPaths,
|
||||
string label)
|
||||
{
|
||||
var absolutePath = Path.IsPathRooted(path)
|
||||
? path
|
||||
: Path.GetFullPath(path, AppContext.BaseDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
if (IsUnsafeDeletionTarget(absolutePath))
|
||||
{
|
||||
warnings.Add($"Skipped deleting suspicious path: {absolutePath}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (File.Exists(absolutePath))
|
||||
{
|
||||
File.Delete(absolutePath);
|
||||
deletedPaths.Add(absolutePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Directory.Exists(absolutePath))
|
||||
{
|
||||
Directory.Delete(absolutePath, true);
|
||||
deletedPaths.Add(absolutePath);
|
||||
return;
|
||||
}
|
||||
|
||||
warnings.Add($"Path not found for {label}: {absolutePath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"Failed to delete {label}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUnsafeDeletionTarget(string absolutePath)
|
||||
{
|
||||
var normalized = absolutePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var root = Path.GetPathRoot(normalized)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.IsNullOrWhiteSpace(normalized) ||
|
||||
normalized.Length < 4 ||
|
||||
string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase);
|
||||
return await _cleanupOperationCoordinator.EnqueueRetentionAsync(settings, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LiveRecorder.WebApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/cleanup-operations")]
|
||||
public sealed class CleanupOperationsController : ControllerBase
|
||||
{
|
||||
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
|
||||
|
||||
public CleanupOperationsController(CleanupOperationCoordinator cleanupOperationCoordinator)
|
||||
{
|
||||
_cleanupOperationCoordinator = cleanupOperationCoordinator;
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<CleanupOperationDto>> Get(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var operation = await _cleanupOperationCoordinator.GetAsync(id, cancellationToken);
|
||||
return operation is null ? NotFound() : Ok(operation);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
@@ -14,13 +15,16 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
private static readonly JsonSerializerOptions StreamJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly RecordSessionService _recordSessionService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
|
||||
|
||||
public RecordSessionsController(
|
||||
RecordSessionService recordSessionService,
|
||||
RecordUploadService recordUploadService)
|
||||
RecordUploadService recordUploadService,
|
||||
CleanupOperationCoordinator cleanupOperationCoordinator)
|
||||
{
|
||||
_recordSessionService = recordSessionService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_cleanupOperationCoordinator = cleanupOperationCoordinator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -76,22 +80,22 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
Ok(await _recordSessionService.StopAsync(id, cancellationToken));
|
||||
|
||||
[HttpPost("delete")]
|
||||
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> Delete(
|
||||
public async Task<ActionResult<CleanupOperationDto>> Delete(
|
||||
[FromBody] DeleteRecordSessionsRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _recordSessionService.DeleteAsync(request, cancellationToken));
|
||||
Ok(await _cleanupOperationCoordinator.EnqueueSelectedAsync(request, cancellationToken));
|
||||
|
||||
[HttpPost("delete-missing-files")]
|
||||
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteMissingFiles(
|
||||
[FromBody] DeleteMissingFileRecordSessionsRequest request,
|
||||
[HttpPost("delete-conditional")]
|
||||
public async Task<ActionResult<CleanupOperationDto>> DeleteConditional(
|
||||
[FromBody] DeleteConditionalSessionsRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _recordSessionService.DeleteMissingFilesAsync(request, cancellationToken));
|
||||
Ok(await _cleanupOperationCoordinator.EnqueueConditionalAsync(request, cancellationToken));
|
||||
|
||||
[HttpPost("delete-empty")]
|
||||
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteEmpty(
|
||||
[FromQuery] bool deleteFiles = false,
|
||||
public async Task<ActionResult<CleanupOperationDto>> DeleteEmpty(
|
||||
[FromBody] DeleteEmptyRecordSessionsRequest request,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Ok(await _recordSessionService.DeleteEmptyAsync(deleteFiles, cancellationToken));
|
||||
Ok(await _cleanupOperationCoordinator.EnqueueEmptyAsync(request, cancellationToken));
|
||||
|
||||
[HttpPost("{id:guid}/upload")]
|
||||
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Abstractions.Scripting;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
@@ -91,6 +92,10 @@ public sealed class SettingsController : ControllerBase
|
||||
Ok(await _webhookNotificationService.SendTestAsync(request, cancellationToken));
|
||||
|
||||
[HttpPost("retention/run-now")]
|
||||
public async Task<ActionResult<RetentionCleanupResultDto>> RunRetentionCleanupNow(CancellationToken cancellationToken) =>
|
||||
Ok(await _retentionCleanupService.RunAsync(ignoreEnabledSetting: true, cancellationToken));
|
||||
public async Task<ActionResult<CleanupOperationDto>> RunRetentionCleanupNow(CancellationToken cancellationToken)
|
||||
{
|
||||
var operation = await _retentionCleanupService.TryEnqueueAsync(ignoreEnabledSetting: true, cancellationToken: cancellationToken)
|
||||
?? throw new InvalidOperationException("Retention cleanup is disabled.");
|
||||
return Ok(operation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,8 @@ builder.Services.AddScoped<TranscodeTaskService>();
|
||||
builder.Services.AddScoped<MediaBrowserService>();
|
||||
builder.Services.AddScoped<SessionAnalyticsService>();
|
||||
builder.Services.AddScoped<RecoveryService>();
|
||||
builder.Services.AddScoped<RecordSessionCleanupResolver>();
|
||||
builder.Services.AddScoped<CleanupOperationCoordinator>();
|
||||
builder.Services.AddScoped<RetentionCleanupService>();
|
||||
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
||||
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
||||
@@ -193,6 +195,7 @@ builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
|
||||
builder.Services.AddSingleton<LiveRoomPollingBackgroundService>();
|
||||
builder.Services.AddSingleton<ILiveRoomPollingSignal>(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
|
||||
builder.Services.AddHostedService(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
|
||||
builder.Services.AddHostedService<CleanupOperationBackgroundService>();
|
||||
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
Reference in New Issue
Block a user