feat: improve polling recovery and session cleanup
This commit is contained in:
@@ -572,7 +572,9 @@ export const autoStartDecisionLabelMap: Record<string, string> = {
|
|||||||
skipped_active_session: "已有活动会话",
|
skipped_active_session: "已有活动会话",
|
||||||
skipped_offline: "房间未开播",
|
skipped_offline: "房间未开播",
|
||||||
skipped_debounce: "触发防抖中",
|
skipped_debounce: "触发防抖中",
|
||||||
failed_startup: "启动失败"
|
failed_startup: "启动失败",
|
||||||
|
poll_failed_transient: "轮询临时失败",
|
||||||
|
poll_failed: "轮询失败"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const platformLabelMap: Record<number, string> = {
|
export const platformLabelMap: Record<number, string> = {
|
||||||
|
|||||||
@@ -517,11 +517,16 @@ function autoStartDecisionTagType(code?: string) {
|
|||||||
return "success";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code === "failed_startup") {
|
if (code === "failed_startup" || code === "poll_failed") {
|
||||||
return "danger";
|
return "danger";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code === "skipped_storage" || code === "skipped_active_session" || code === "skipped_debounce") {
|
if (
|
||||||
|
code === "skipped_storage" ||
|
||||||
|
code === "skipped_active_session" ||
|
||||||
|
code === "skipped_debounce" ||
|
||||||
|
code === "poll_failed_transient"
|
||||||
|
) {
|
||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1242,8 +1247,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rooms-table-shell {
|
.rooms-table-shell {
|
||||||
margin-inline: -4px;
|
overflow: visible;
|
||||||
padding-inline: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rooms-table {
|
.rooms-table {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||||
import { useViewport } from "@/composables/useViewport";
|
import { useViewport } from "@/composables/useViewport";
|
||||||
import type { SystemLog } from "@/types";
|
import type { SystemLog } from "@/types";
|
||||||
import { logLevelLabelMap } from "@/types";
|
import { logLevelLabelMap } from "@/types";
|
||||||
|
|
||||||
|
const AUTO_REFRESH_INTERVAL_MS = 15000;
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const logs = ref<SystemLog[]>([]);
|
const logs = ref<SystemLog[]>([]);
|
||||||
const loadError = ref("");
|
const loadError = ref("");
|
||||||
@@ -26,7 +28,13 @@ const totalWarnings = computed(() => logs.value.filter((item) => item.level ===
|
|||||||
const totalErrors = computed(() => logs.value.filter((item) => item.level === 3).length);
|
const totalErrors = computed(() => logs.value.filter((item) => item.level === 3).length);
|
||||||
const newestLogTime = computed(() => (logs.value[0] ? formatDate(logs.value[0].createdAt) : "-"));
|
const newestLogTime = computed(() => (logs.value[0] ? formatDate(logs.value[0].createdAt) : "-"));
|
||||||
|
|
||||||
|
let autoRefreshTimer: number | null = null;
|
||||||
|
|
||||||
async function loadLogs() {
|
async function loadLogs() {
|
||||||
|
if (loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
loadError.value = "";
|
loadError.value = "";
|
||||||
|
|
||||||
@@ -48,6 +56,34 @@ async function loadLogs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function autoRefreshLogs() {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoRefresh() {
|
||||||
|
stopAutoRefresh();
|
||||||
|
autoRefreshTimer = window.setInterval(() => {
|
||||||
|
void autoRefreshLogs();
|
||||||
|
}, AUTO_REFRESH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (autoRefreshTimer !== null) {
|
||||||
|
window.clearInterval(autoRefreshTimer);
|
||||||
|
autoRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
void autoRefreshLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function levelTagType(level: number) {
|
function levelTagType(level: number) {
|
||||||
if (level === 3) {
|
if (level === 3) {
|
||||||
return "danger";
|
return "danger";
|
||||||
@@ -64,7 +100,16 @@ function formatDate(value?: string) {
|
|||||||
return value ? new Date(value).toLocaleString() : "-";
|
return value ? new Date(value).toLocaleString() : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadLogs);
|
onMounted(async () => {
|
||||||
|
await loadLogs();
|
||||||
|
startAutoRefresh();
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -74,7 +119,7 @@ onMounted(loadLogs);
|
|||||||
<div class="page-kicker">排障中心</div>
|
<div class="page-kicker">排障中心</div>
|
||||||
<h1 class="page-title">系统日志</h1>
|
<h1 class="page-title">系统日志</h1>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
面向排障的原始事件流。这里保留最近发生的关键日志,可以按直播间、任务和级别快速收缩范围。
|
面向排障的原始事件流。这里保留最近发生的关键日志,可以按直播间、任务和级别快速缩小范围。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,7 +148,7 @@ onMounted(loadLogs);
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-card__label">最新事件</div>
|
<div class="stat-card__label">最新事件</div>
|
||||||
<div class="stat-card__value" style="font-size: 18px; letter-spacing: -0.03em;">{{ newestLogTime }}</div>
|
<div class="stat-card__value" style="font-size: 18px;">{{ newestLogTime }}</div>
|
||||||
<div class="stat-card__hint">默认按时间倒序,优先展示最近发生的事件。</div>
|
<div class="stat-card__hint">默认按时间倒序,优先展示最近发生的事件。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +191,7 @@ onMounted(loadLogs);
|
|||||||
<div class="toolbar-row">
|
<div class="toolbar-row">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">日志列表</h3>
|
<h3 class="section-title">日志列表</h3>
|
||||||
<p class="section-subtitle">桌面端保留高密度表格,移动端切成事件卡片,便于单手排查。</p>
|
<p class="section-subtitle">页面会自动刷新,移动端改成事件卡片,桌面端保留高密度表格。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElNotification } from "element-plus";
|
import { ElMessage, ElNotification } from "element-plus";
|
||||||
@@ -30,12 +30,13 @@ const stoppingSessionId = ref<string | null>(null);
|
|||||||
const uploadingSessionId = ref<string | null>(null);
|
const uploadingSessionId = ref<string | null>(null);
|
||||||
const uploadingTaskId = ref<string | null>(null);
|
const uploadingTaskId = ref<string | null>(null);
|
||||||
const deleteDialogVisible = ref(false);
|
const deleteDialogVisible = ref(false);
|
||||||
const deleteDialogMode = ref<"tasks" | "sessions">("tasks");
|
const deleteDialogMode = ref<"tasks" | "sessions" | "missing-sessions">("tasks");
|
||||||
const deleteDialogTaskIds = ref<string[]>([]);
|
const deleteDialogTaskIds = ref<string[]>([]);
|
||||||
const deleteDialogSessionIds = ref<string[]>([]);
|
const deleteDialogSessionIds = ref<string[]>([]);
|
||||||
const sessions = ref<RecordSession[]>([]);
|
const sessions = ref<RecordSession[]>([]);
|
||||||
const loadError = ref("");
|
const loadError = ref("");
|
||||||
const activeSessionPanels = ref<string[]>([]);
|
const activeSessionPanels = ref<string[]>([]);
|
||||||
|
const selectedSessionIds = ref<string[]>([]);
|
||||||
const selectedTaskMap = ref<Record<string, RecordTask>>({});
|
const selectedTaskMap = ref<Record<string, RecordTask>>({});
|
||||||
const realtimeConnected = ref(false);
|
const realtimeConnected = ref(false);
|
||||||
const realtimeError = ref("");
|
const realtimeError = ref("");
|
||||||
@@ -47,22 +48,54 @@ const selectedTasks = computed(() => Object.values(selectedTaskMap.value));
|
|||||||
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
|
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
|
||||||
const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum + item.tasks.length, 0));
|
const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum + item.tasks.length, 0));
|
||||||
const totalDanmakuCount = computed(() => sessions.value.reduce((sum, item) => sum + item.totalDanmakuMessageCount, 0));
|
const totalDanmakuCount = computed(() => sessions.value.reduce((sum, item) => sum + item.totalDanmakuMessageCount, 0));
|
||||||
|
const selectedSessionCount = computed(() => selectedSessionIds.value.length);
|
||||||
const deleteDialogTaskCount = computed(() => deleteDialogTaskIds.value.length);
|
const deleteDialogTaskCount = computed(() => deleteDialogTaskIds.value.length);
|
||||||
const deleteDialogSessionCount = computed(() => deleteDialogSessionIds.value.length);
|
const deleteDialogSessionCount = computed(() => deleteDialogSessionIds.value.length);
|
||||||
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 48vw, 560px)"));
|
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 48vw, 560px)"));
|
||||||
const deleteDialogEyebrow = computed(() => (deleteDialogMode.value === "sessions" ? "会话删除" : "删除确认"));
|
const deleteDialogEyebrow = computed(() => {
|
||||||
const deleteDialogTitle = computed(() => (deleteDialogMode.value === "sessions" ? "删除录制会话" : "删除分片任务"));
|
if (deleteDialogMode.value === "sessions") {
|
||||||
const deleteDialogLead = computed(() =>
|
return "会话删除";
|
||||||
deleteDialogMode.value === "sessions"
|
}
|
||||||
? `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`
|
|
||||||
: `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`
|
|
||||||
);
|
|
||||||
const deleteDialogNote = computed(() =>
|
|
||||||
deleteDialogMode.value === "sessions"
|
|
||||||
? "活跃会话会先尝试优雅停止,超时后再强制结束 ffmpeg 进程。直播间仍保持启用时,后台巡检后续可能重新创建新会话。"
|
|
||||||
: "“记录 + 文件”会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。"
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (deleteDialogMode.value === "missing-sessions") {
|
||||||
|
return "无文件清理";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "删除确认";
|
||||||
|
});
|
||||||
|
const deleteDialogTitle = computed(() => {
|
||||||
|
if (deleteDialogMode.value === "sessions") {
|
||||||
|
return "删除录制会话";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteDialogMode.value === "missing-sessions") {
|
||||||
|
return "清理无实体文件会话";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "删除分片任务";
|
||||||
|
});
|
||||||
|
const deleteDialogLead = computed(() => {
|
||||||
|
if (deleteDialogMode.value === "sessions") {
|
||||||
|
return `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteDialogMode.value === "missing-sessions") {
|
||||||
|
return "将自动筛出所有“分片视频文件都不存在”的录制会话,并批量清理这些会话及其分片记录。你也可以选择同时尝试删除残留的 XML 或其他本地文件。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`;
|
||||||
|
});
|
||||||
|
const deleteDialogNote = computed(() => {
|
||||||
|
if (deleteDialogMode.value === "sessions") {
|
||||||
|
return "活跃会话会先尝试优雅停止,超时后再强制结束 ffmpeg 进程。直播间仍保持启用时,后台巡检后续可能重新创建新会话。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteDialogMode.value === "missing-sessions") {
|
||||||
|
return "只有当会话下所有分片都找不到实体视频文件时,才会命中这类清理。只要仍有任意一个视频文件存在,该会话就不会被误删。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "“记录 + 文件”会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
|
||||||
|
});
|
||||||
function isActiveStatus(status: number) {
|
function isActiveStatus(status: number) {
|
||||||
return status === 1 || status === 2 || status === 3 || status === 7;
|
return status === 1 || status === 2 || status === 3 || status === 7;
|
||||||
}
|
}
|
||||||
@@ -75,6 +108,22 @@ function selectableTask(row: RecordTask) {
|
|||||||
return isDeletableTask(row);
|
return isDeletableTask(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSessionSelected(sessionId: string) {
|
||||||
|
return selectedSessionIds.value.includes(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionSelected(sessionId: string, selected: boolean) {
|
||||||
|
if (selected) {
|
||||||
|
if (!selectedSessionIds.value.includes(sessionId)) {
|
||||||
|
selectedSessionIds.value = [...selectedSessionIds.value, sessionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedSessionIds.value = selectedSessionIds.value.filter((value) => value !== sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
function applySessionsSnapshot(
|
function applySessionsSnapshot(
|
||||||
data: RecordSession[],
|
data: RecordSession[],
|
||||||
options?: {
|
options?: {
|
||||||
@@ -103,10 +152,13 @@ function applySessionsSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options?.resetSelection) {
|
if (options?.resetSelection) {
|
||||||
|
selectedSessionIds.value = [];
|
||||||
selectedTaskMap.value = {};
|
selectedTaskMap.value = {};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedSessionIds.value = selectedSessionIds.value.filter((sessionId) => nextSessionIds.has(sessionId));
|
||||||
|
|
||||||
selectedTaskMap.value = Object.fromEntries(
|
selectedTaskMap.value = Object.fromEntries(
|
||||||
Object.entries(selectedTaskMap.value)
|
Object.entries(selectedTaskMap.value)
|
||||||
.map(([taskId]) => {
|
.map(([taskId]) => {
|
||||||
@@ -281,6 +333,13 @@ function openDeleteSessionDialog(sessionIds: string[]) {
|
|||||||
deleteDialogVisible.value = true;
|
deleteDialogVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDeleteMissingSessionsDialog() {
|
||||||
|
deleteDialogMode.value = "missing-sessions";
|
||||||
|
deleteDialogSessionIds.value = [];
|
||||||
|
deleteDialogTaskIds.value = [];
|
||||||
|
deleteDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function closeDeleteDialog() {
|
function closeDeleteDialog() {
|
||||||
if (deleting.value) {
|
if (deleting.value) {
|
||||||
return;
|
return;
|
||||||
@@ -308,6 +367,7 @@ function applyDeleteResult(data: DeleteCompletedRecordTasksResult) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
activeSessionPanels.value = activeSessionPanels.value.filter((sessionId) => !deletedSessionIds.has(sessionId));
|
activeSessionPanels.value = activeSessionPanels.value.filter((sessionId) => !deletedSessionIds.has(sessionId));
|
||||||
|
selectedSessionIds.value = selectedSessionIds.value.filter((sessionId) => !deletedSessionIds.has(sessionId));
|
||||||
selectedTaskMap.value = Object.fromEntries(
|
selectedTaskMap.value = Object.fromEntries(
|
||||||
Object.entries(selectedTaskMap.value).filter(([taskId]) => !deletedTaskIds.has(taskId))
|
Object.entries(selectedTaskMap.value).filter(([taskId]) => !deletedTaskIds.has(taskId))
|
||||||
);
|
);
|
||||||
@@ -315,8 +375,9 @@ function applyDeleteResult(data: DeleteCompletedRecordTasksResult) {
|
|||||||
|
|
||||||
async function confirmDelete(deleteFiles: boolean) {
|
async function confirmDelete(deleteFiles: boolean) {
|
||||||
const deletingSessions = deleteDialogMode.value === "sessions";
|
const deletingSessions = deleteDialogMode.value === "sessions";
|
||||||
|
const deletingMissingSessions = deleteDialogMode.value === "missing-sessions";
|
||||||
const selectedIds = deletingSessions ? deleteDialogSessionIds.value : deleteDialogTaskIds.value;
|
const selectedIds = deletingSessions ? deleteDialogSessionIds.value : deleteDialogTaskIds.value;
|
||||||
if (selectedIds.length === 0) {
|
if (!deletingMissingSessions && selectedIds.length === 0) {
|
||||||
closeDeleteDialog();
|
closeDeleteDialog();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -324,7 +385,11 @@ async function confirmDelete(deleteFiles: boolean) {
|
|||||||
deleting.value = true;
|
deleting.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = deletingSessions
|
const { data } = deletingMissingSessions
|
||||||
|
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete-missing-files", {
|
||||||
|
deleteFiles
|
||||||
|
})
|
||||||
|
: deletingSessions
|
||||||
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete", {
|
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete", {
|
||||||
sessionIds: deleteDialogSessionIds.value,
|
sessionIds: deleteDialogSessionIds.value,
|
||||||
deleteFiles
|
deleteFiles
|
||||||
@@ -334,11 +399,15 @@ async function confirmDelete(deleteFiles: boolean) {
|
|||||||
deleteFiles
|
deleteFiles
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (deletingMissingSessions && data.deletedSessionIds.length === 0) {
|
||||||
|
ElMessage.info("没有找到符合条件的无实体文件会话。");
|
||||||
|
} else {
|
||||||
ElMessage.success(
|
ElMessage.success(
|
||||||
deletingSessions
|
deletingSessions || deletingMissingSessions
|
||||||
? `已删除 ${data.deletedSessionIds.length} 个录制会话。`
|
? `已删除 ${data.deletedSessionIds.length} 个录制会话。`
|
||||||
: `已删除 ${data.deletedTaskIds.length} 个分片任务。`
|
: `已删除 ${data.deletedTaskIds.length} 个分片任务。`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
applyDeleteResult(data);
|
applyDeleteResult(data);
|
||||||
|
|
||||||
@@ -492,6 +561,21 @@ onBeforeUnmount(() => {
|
|||||||
会话层代表整场录制,分片层代表单个视频文件。停止入口作用于整个会话,详情入口作用于单个分片。
|
会话层代表整场录制,分片层代表单个视频文件。停止入口作用于整个会话,详情入口作用于单个分片。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toolbar-row__actions">
|
||||||
|
<span class="batch-actions__count">已选 {{ selectedSessionCount }} 个</span>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:disabled="selectedSessionCount === 0"
|
||||||
|
:loading="deleting"
|
||||||
|
@click="openDeleteSessionDialog(selectedSessionIds)"
|
||||||
|
>
|
||||||
|
删除已选会话{{ selectedSessionCount > 0 ? `(${selectedSessionCount})` : "" }}
|
||||||
|
</el-button>
|
||||||
|
<el-button plain :loading="deleting" @click="openDeleteMissingSessionsDialog()">
|
||||||
|
清理无文件会话
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-empty v-if="!loading && sessions.length === 0" description="暂无录制会话" />
|
<el-empty v-if="!loading && sessions.length === 0" description="暂无录制会话" />
|
||||||
@@ -626,6 +710,12 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="session-title">
|
<div class="session-title">
|
||||||
|
<div class="session-title__select" @click.stop>
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="isSessionSelected(session.id)"
|
||||||
|
@change="setSessionSelected(session.id, Boolean($event))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="session-title__main">
|
<div class="session-title__main">
|
||||||
<div class="session-title__name">{{ session.liveRoomTitle }}</div>
|
<div class="session-title__name">{{ session.liveRoomTitle }}</div>
|
||||||
<div class="session-title__meta">
|
<div class="session-title__meta">
|
||||||
@@ -692,7 +782,7 @@ onBeforeUnmount(() => {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-scroll-shell tasks-table-shell">
|
<div class="tasks-table-shell">
|
||||||
<el-table
|
<el-table
|
||||||
:data="session.tasks"
|
:data="session.tasks"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
@@ -850,8 +940,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tasks-table-shell {
|
.tasks-table-shell {
|
||||||
margin-inline: -4px;
|
overflow: visible;
|
||||||
padding-inline: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sessions-card :deep(.el-card__body) {
|
.sessions-card :deep(.el-card__body) {
|
||||||
@@ -868,6 +957,14 @@ onBeforeUnmount(() => {
|
|||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-row__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.session-collapse {
|
.session-collapse {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -904,8 +1001,15 @@ onBeforeUnmount(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-title__select {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.session-title__main {
|
.session-title__main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-title__name {
|
.session-title__name {
|
||||||
@@ -1187,3 +1291,4 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -138,11 +138,16 @@ function autoStartDecisionTagType(code?: string) {
|
|||||||
return "success";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code === "failed_startup") {
|
if (code === "failed_startup" || code === "poll_failed") {
|
||||||
return "danger";
|
return "danger";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code === "skipped_storage" || code === "skipped_active_session") {
|
if (
|
||||||
|
code === "skipped_storage" ||
|
||||||
|
code === "skipped_active_session" ||
|
||||||
|
code === "skipped_debounce" ||
|
||||||
|
code === "poll_failed_transient"
|
||||||
|
) {
|
||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const form = reactive<SystemSettings>({
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Title:</strong> {{title}}</li>
|
<li><strong>Title:</strong> {{title}}</li>
|
||||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -142,7 +142,7 @@ const form = reactive<SystemSettings>({
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -197,7 +197,7 @@ const eventScriptEnvironmentExamples = [
|
|||||||
{ name: "LIVE_RECORDER_TITLE", example: "今晚划水聊天", scope: "全部事件" },
|
{ name: "LIVE_RECORDER_TITLE", example: "今晚划水聊天", scope: "全部事件" },
|
||||||
{ name: "LIVE_RECORDER_ANCHOR", example: "布莱克🌊", scope: "全部事件" },
|
{ name: "LIVE_RECORDER_ANCHOR", example: "布莱克🌊", scope: "全部事件" },
|
||||||
{ name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "全部事件" },
|
{ name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "全部事件" },
|
||||||
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T12:34:56.7890000+00:00", scope: "全部事件" },
|
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T20:34:56.7890000+08:00", scope: "全部事件" },
|
||||||
{
|
{
|
||||||
name: "LIVE_RECORDER_SCRIPT_LOG_PATH",
|
name: "LIVE_RECORDER_SCRIPT_LOG_PATH",
|
||||||
example: "/tmp/live-recorder-script-log-7a13c2c5e5cd4f2d8ec2c3b2d5f3f1aa.txt",
|
example: "/tmp/live-recorder-script-log-7a13c2c5e5cd4f2d8ec2c3b2d5f3f1aa.txt",
|
||||||
@@ -755,6 +755,10 @@ onMounted(loadSettings);
|
|||||||
<code>{fileStem}</code> 只用于目录模板,表示按文件模板渲染后的基础名;<code>{segmentSuffix}</code> 只用于文件名模板,分段模式下会展开成 <code>_00001</code> 这类后缀,单文件模式下为空。
|
<code>{fileStem}</code> 只用于目录模板,表示按文件模板渲染后的基础名;<code>{segmentSuffix}</code> 只用于文件名模板,分段模式下会展开成 <code>_00001</code> 这类后缀,单文件模式下为空。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="helper-panel">
|
||||||
|
目录模板和文件名模板中的时间变量统一按北京时间(UTC+8)渲染,示例路径也按北京时间展示。
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="token-list">
|
<div class="token-list">
|
||||||
<span v-for="token in outputTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
|
<span v-for="token in outputTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1156,6 +1160,10 @@ onMounted(loadSettings);
|
|||||||
如果脚本想把自定义内容写进系统日志,请把文本写入 <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code> 指向的临时文件。
|
如果脚本想把自定义内容写进系统日志,请把文本写入 <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code> 指向的临时文件。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="event-script-help__intro">
|
||||||
|
虽然变量名仍然保留 <code>LIVE_RECORDER_OCCURRED_AT_UTC</code>,但实际传入的时间值已经统一改成北京时间(UTC+8)。
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="event-script-help__intro">
|
<div class="event-script-help__intro">
|
||||||
官方 Docker 镜像默认内置 <code>curl</code> 和 <code>jq</code>;如果你使用宿主机直跑或自定义镜像,实际可用命令以运行环境为准。
|
官方 Docker 镜像默认内置 <code>curl</code> 和 <code>jq</code>;如果你使用宿主机直跑或自定义镜像,实际可用命令以运行环境为准。
|
||||||
</div>
|
</div>
|
||||||
@@ -1352,6 +1360,10 @@ onMounted(loadSettings);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="helper-panel">
|
||||||
|
邮件模板里的 <code v-pre>{{detectedAtUtc}}</code> 和 <code v-pre>{{occurredAtUtc}}</code> 字段名保持不变,但实际渲染值已经统一改成北京时间(UTC+8)。
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="action-strip">
|
<div class="action-strip">
|
||||||
<div class="helper-text">测试发送不会自动保存配置,邮件里会同时渲染“开播提醒示例”和“异常提醒示例”。</div>
|
<div class="helper-text">测试发送不会自动保存配置,邮件里会同时渲染“开播提醒示例”和“异常提醒示例”。</div>
|
||||||
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试邮件</el-button>
|
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试邮件</el-button>
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ public static class AutoStartDecisionCodes
|
|||||||
public const string SkippedDebounce = "skipped_debounce";
|
public const string SkippedDebounce = "skipped_debounce";
|
||||||
|
|
||||||
public const string FailedStartup = "failed_startup";
|
public const string FailedStartup = "failed_startup";
|
||||||
|
|
||||||
|
public const string PollFailedTransient = "poll_failed_transient";
|
||||||
|
|
||||||
|
public const string PollFailed = "poll_failed";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace LiveRecorder.Application.Common;
|
||||||
|
|
||||||
|
public static class ChinaTime
|
||||||
|
{
|
||||||
|
private static readonly Lazy<TimeZoneInfo> TimeZoneInfoLazy = new(ResolveTimeZoneInfo);
|
||||||
|
|
||||||
|
public static TimeZoneInfo Zone => TimeZoneInfoLazy.Value;
|
||||||
|
|
||||||
|
public static DateTimeOffset ToBeijingTime(DateTimeOffset value)
|
||||||
|
{
|
||||||
|
var local = TimeZoneInfo.ConvertTime(value, Zone);
|
||||||
|
return new DateTimeOffset(local.DateTime, Zone.GetUtcOffset(local.DateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeZoneInfo ResolveTimeZoneInfo()
|
||||||
|
{
|
||||||
|
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (InvalidTimeZoneException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeZoneInfo.CreateCustomTimeZone(
|
||||||
|
"UTC+08",
|
||||||
|
TimeSpan.FromHours(8),
|
||||||
|
"UTC+08",
|
||||||
|
"UTC+08");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,3 +129,8 @@ public sealed class DeleteRecordSessionsRequest
|
|||||||
|
|
||||||
public bool DeleteFiles { get; set; }
|
public bool DeleteFiles { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class DeleteMissingFileRecordSessionsRequest
|
||||||
|
{
|
||||||
|
public bool DeleteFiles { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ public sealed class SystemSettingsDto
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Title:</strong> {{title}}</li>
|
<li><strong>Title:</strong> {{title}}</li>
|
||||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +199,7 @@ public sealed class SystemSettingsDto
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,7 +362,7 @@ public sealed class UpdateSystemSettingsRequest
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Title:</strong> {{title}}</li>
|
<li><strong>Title:</strong> {{title}}</li>
|
||||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,7 +380,7 @@ public sealed class UpdateSystemSettingsRequest
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +435,7 @@ public sealed class SendTestEmailRequest
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Title:</strong> {{title}}</li>
|
<li><strong>Title:</strong> {{title}}</li>
|
||||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,7 +453,7 @@ public sealed class SendTestEmailRequest
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -774,6 +774,7 @@ public sealed class RecordService
|
|||||||
RecordSaveMode saveMode,
|
RecordSaveMode saveMode,
|
||||||
DateTimeOffset now)
|
DateTimeOffset now)
|
||||||
{
|
{
|
||||||
|
var localNow = ChinaTime.ToBeijingTime(now);
|
||||||
var safeRoomId = SanitizeFileName(roomId, "room");
|
var safeRoomId = SanitizeFileName(roomId, "room");
|
||||||
var effectiveFileNameTemplate = EnsureSegmentSuffixTemplate(outputFileNameTemplate, saveMode);
|
var effectiveFileNameTemplate = EnsureSegmentSuffixTemplate(outputFileNameTemplate, saveMode);
|
||||||
var baseFileStem = BuildFileNameStem(
|
var baseFileStem = BuildFileNameStem(
|
||||||
@@ -782,7 +783,7 @@ public sealed class RecordService
|
|||||||
safeRoomId,
|
safeRoomId,
|
||||||
anchorName,
|
anchorName,
|
||||||
title,
|
title,
|
||||||
now,
|
localNow,
|
||||||
segmentSuffix: string.Empty);
|
segmentSuffix: string.Empty);
|
||||||
var directoryPath = BuildDirectoryPath(
|
var directoryPath = BuildDirectoryPath(
|
||||||
outputDirectoryTemplate,
|
outputDirectoryTemplate,
|
||||||
@@ -790,7 +791,7 @@ public sealed class RecordService
|
|||||||
safeRoomId,
|
safeRoomId,
|
||||||
anchorName,
|
anchorName,
|
||||||
title,
|
title,
|
||||||
now,
|
localNow,
|
||||||
baseFileStem);
|
baseFileStem);
|
||||||
var fileNameStem = BuildFileNameStem(
|
var fileNameStem = BuildFileNameStem(
|
||||||
effectiveFileNameTemplate,
|
effectiveFileNameTemplate,
|
||||||
@@ -798,7 +799,7 @@ public sealed class RecordService
|
|||||||
safeRoomId,
|
safeRoomId,
|
||||||
anchorName,
|
anchorName,
|
||||||
title,
|
title,
|
||||||
now,
|
localNow,
|
||||||
saveMode == RecordSaveMode.Segmented ? "_%05d" : string.Empty);
|
saveMode == RecordSaveMode.Segmented ? "_%05d" : string.Empty);
|
||||||
var folder = Path.Combine(outputRoot, directoryPath);
|
var folder = Path.Combine(outputRoot, directoryPath);
|
||||||
var extension = outputFormat == RecordOutputFormat.Ts ? "ts" : "mp4";
|
var extension = outputFormat == RecordOutputFormat.Ts ? "ts" : "mp4";
|
||||||
|
|||||||
@@ -253,6 +253,36 @@ public sealed class RecordSessionService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
|
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
|
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
|
||||||
@@ -271,6 +301,39 @@ public sealed class RecordSessionService
|
|||||||
private static bool IsActiveStatus(RecordSessionStatus status) =>
|
private static bool IsActiveStatus(RecordSessionStatus status) =>
|
||||||
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||||
|
|
||||||
|
private static bool CanDeleteMissingFileSession(RecordSession session)
|
||||||
|
{
|
||||||
|
if (IsActiveStatus(session.Status))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.RecordTasks.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.RecordTasks.All(static task => !HasExistingVideoFile(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasExistingVideoFile(RecordTask task)
|
||||||
|
{
|
||||||
|
var candidatePath = !string.IsNullOrWhiteSpace(task.Result?.FilePath)
|
||||||
|
? task.Result!.FilePath
|
||||||
|
: task.OutputFilePath;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(candidatePath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedPath = Path.IsPathRooted(candidatePath)
|
||||||
|
? candidatePath
|
||||||
|
: Path.GetFullPath(candidatePath, AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
return File.Exists(resolvedPath);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<SystemLogEntry>> ListRelatedLogsAsync(
|
private async Task<IReadOnlyList<SystemLogEntry>> ListRelatedLogsAsync(
|
||||||
Guid recordSessionId,
|
Guid recordSessionId,
|
||||||
IReadOnlyCollection<Guid> taskIds,
|
IReadOnlyCollection<Guid> taskIds,
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
NotifyOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyOnLiveStartedKey, "true"), out var notifyOnLiveStarted) && notifyOnLiveStarted,
|
NotifyOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyOnLiveStartedKey, "true"), out var notifyOnLiveStarted) && notifyOnLiveStarted,
|
||||||
NotifyOnException = bool.TryParse(GetValue(lookup, NotifyOnExceptionKey, "true"), out var notifyOnException) && notifyOnException,
|
NotifyOnException = bool.TryParse(GetValue(lookup, NotifyOnExceptionKey, "true"), out var notifyOnException) && notifyOnException,
|
||||||
EmailLiveStartedSubjectTemplate = GetValue(lookup, EmailLiveStartedSubjectTemplateKey, "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"),
|
EmailLiveStartedSubjectTemplate = GetValue(lookup, EmailLiveStartedSubjectTemplateKey, "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"),
|
||||||
EmailLiveStartedBodyTemplateHtml = GetValue(
|
EmailLiveStartedBodyTemplateHtml = NormalizeBeijingTimeTemplate(GetValue(
|
||||||
lookup,
|
lookup,
|
||||||
EmailLiveStartedBodyTemplateHtmlKey,
|
EmailLiveStartedBodyTemplateHtmlKey,
|
||||||
"""
|
"""
|
||||||
@@ -237,13 +237,13 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Title:</strong> {{title}}</li>
|
<li><strong>Title:</strong> {{title}}</li>
|
||||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||||
</div>
|
</div>
|
||||||
"""),
|
""")),
|
||||||
EmailExceptionSubjectTemplate = GetValue(lookup, EmailExceptionSubjectTemplateKey, "[{{appName}}] Exception: {{source}}"),
|
EmailExceptionSubjectTemplate = GetValue(lookup, EmailExceptionSubjectTemplateKey, "[{{appName}}] Exception: {{source}}"),
|
||||||
EmailExceptionBodyTemplateHtml = GetValue(
|
EmailExceptionBodyTemplateHtml = NormalizeBeijingTimeTemplate(GetValue(
|
||||||
lookup,
|
lookup,
|
||||||
EmailExceptionBodyTemplateHtmlKey,
|
EmailExceptionBodyTemplateHtmlKey,
|
||||||
"""
|
"""
|
||||||
@@ -256,11 +256,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||||
</div>
|
</div>
|
||||||
"""),
|
""")),
|
||||||
EnableWebhookNotification = bool.TryParse(GetValue(lookup, EnableWebhookNotificationKey, "false"), out var enableWebhookNotification) && enableWebhookNotification,
|
EnableWebhookNotification = bool.TryParse(GetValue(lookup, EnableWebhookNotificationKey, "false"), out var enableWebhookNotification) && enableWebhookNotification,
|
||||||
WebhookUrl = GetValue(lookup, WebhookUrlKey, string.Empty),
|
WebhookUrl = GetValue(lookup, WebhookUrlKey, string.Empty),
|
||||||
WebhookHeaders = GetValue(lookup, WebhookHeadersKey, string.Empty),
|
WebhookHeaders = GetValue(lookup, WebhookHeadersKey, string.Empty),
|
||||||
@@ -437,6 +437,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
? EventScriptSourceModes.Inline
|
? EventScriptSourceModes.Inline
|
||||||
: EventScriptSourceModes.Path;
|
: EventScriptSourceModes.Path;
|
||||||
|
|
||||||
|
private static string NormalizeBeijingTimeTemplate(string value) =>
|
||||||
|
value
|
||||||
|
.Replace("Detected At (UTC)", "Detected At (Beijing Time)", StringComparison.Ordinal)
|
||||||
|
.Replace("Occurred At (UTC)", "Occurred At (Beijing Time)", StringComparison.Ordinal);
|
||||||
|
|
||||||
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);
|
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ public sealed class DatabaseInitializer
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Title:</strong> {{title}}</li>
|
<li><strong>Title:</strong> {{title}}</li>
|
||||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +123,7 @@ public sealed class DatabaseInitializer
|
|||||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using LiveRecorder.Application.Abstractions.Notifications;
|
using LiveRecorder.Application.Abstractions.Notifications;
|
||||||
using LiveRecorder.Application.Abstractions.Settings;
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Application.Common;
|
||||||
using LiveRecorder.Application.Models.Settings;
|
using LiveRecorder.Application.Models.Settings;
|
||||||
using LiveRecorder.Domain.Entities;
|
using LiveRecorder.Domain.Entities;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -41,7 +42,7 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
["title"] = liveRoom.Title,
|
["title"] = liveRoom.Title,
|
||||||
["anchor"] = liveRoom.AnchorName,
|
["anchor"] = liveRoom.AnchorName,
|
||||||
["sourceUrl"] = liveRoom.SourceUrl,
|
["sourceUrl"] = liveRoom.SourceUrl,
|
||||||
["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
|
["detectedAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O")
|
||||||
});
|
});
|
||||||
|
|
||||||
var subject = RenderSubject(settings.EmailLiveStartedSubjectTemplate, tokens);
|
var subject = RenderSubject(settings.EmailLiveStartedSubjectTemplate, tokens);
|
||||||
@@ -73,7 +74,7 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
["roomId"] = liveRoom?.RoomId,
|
["roomId"] = liveRoom?.RoomId,
|
||||||
["recordTaskId"] = recordTask?.Id.ToString(),
|
["recordTaskId"] = recordTask?.Id.ToString(),
|
||||||
["taskStatus"] = recordTask?.Status.ToString(),
|
["taskStatus"] = recordTask?.Status.ToString(),
|
||||||
["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
|
["occurredAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O")
|
||||||
});
|
});
|
||||||
|
|
||||||
var subject = RenderSubject(settings.EmailExceptionSubjectTemplate, tokens);
|
var subject = RenderSubject(settings.EmailExceptionSubjectTemplate, tokens);
|
||||||
@@ -112,7 +113,7 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
["title"] = "Sample Live Title",
|
["title"] = "Sample Live Title",
|
||||||
["anchor"] = "Sample Anchor",
|
["anchor"] = "Sample Anchor",
|
||||||
["sourceUrl"] = "https://live.douyin.com/123456789",
|
["sourceUrl"] = "https://live.douyin.com/123456789",
|
||||||
["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
|
["detectedAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O")
|
||||||
});
|
});
|
||||||
var sampleExceptionTokens = CreateTokenMap(new Dictionary<string, string?>
|
var sampleExceptionTokens = CreateTokenMap(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
@@ -123,7 +124,7 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
["roomId"] = "123456789",
|
["roomId"] = "123456789",
|
||||||
["recordTaskId"] = Guid.NewGuid().ToString(),
|
["recordTaskId"] = Guid.NewGuid().ToString(),
|
||||||
["taskStatus"] = "Running",
|
["taskStatus"] = "Running",
|
||||||
["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
|
["occurredAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O")
|
||||||
});
|
});
|
||||||
|
|
||||||
var body = $$"""
|
var body = $$"""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||||||
using LiveRecorder.Application.Abstractions.Logging;
|
using LiveRecorder.Application.Abstractions.Logging;
|
||||||
using LiveRecorder.Application.Abstractions.Scripting;
|
using LiveRecorder.Application.Abstractions.Scripting;
|
||||||
using LiveRecorder.Application.Abstractions.Settings;
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Application.Common;
|
||||||
using LiveRecorder.Application.Models.Settings;
|
using LiveRecorder.Application.Models.Settings;
|
||||||
using LiveRecorder.Domain.Entities;
|
using LiveRecorder.Domain.Entities;
|
||||||
using LiveRecorder.Domain.Enums;
|
using LiveRecorder.Domain.Enums;
|
||||||
@@ -354,6 +355,7 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
|
|
||||||
private static Dictionary<string, string> BuildLiveRoomEnvironment(LiveRoom? liveRoom, DateTimeOffset occurredAt)
|
private static Dictionary<string, string> BuildLiveRoomEnvironment(LiveRoom? liveRoom, DateTimeOffset occurredAt)
|
||||||
{
|
{
|
||||||
|
var localOccurredAt = ChinaTime.ToBeijingTime(occurredAt);
|
||||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["LIVE_RECORDER_EVENT"] = string.Empty,
|
["LIVE_RECORDER_EVENT"] = string.Empty,
|
||||||
@@ -363,12 +365,13 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
["LIVE_RECORDER_TITLE"] = liveRoom?.Title ?? string.Empty,
|
["LIVE_RECORDER_TITLE"] = liveRoom?.Title ?? string.Empty,
|
||||||
["LIVE_RECORDER_ANCHOR"] = liveRoom?.AnchorName ?? string.Empty,
|
["LIVE_RECORDER_ANCHOR"] = liveRoom?.AnchorName ?? string.Empty,
|
||||||
["LIVE_RECORDER_SOURCE_URL"] = liveRoom?.SourceUrl ?? liveRoom?.NormalizedUrl ?? string.Empty,
|
["LIVE_RECORDER_SOURCE_URL"] = liveRoom?.SourceUrl ?? liveRoom?.NormalizedUrl ?? string.Empty,
|
||||||
["LIVE_RECORDER_OCCURRED_AT_UTC"] = occurredAt.ToString("O")
|
["LIVE_RECORDER_OCCURRED_AT_UTC"] = localOccurredAt.ToString("O")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyDictionary<string, string> BuildTestEnvironment(string eventName, DateTimeOffset occurredAt)
|
private static IReadOnlyDictionary<string, string> BuildTestEnvironment(string eventName, DateTimeOffset occurredAt)
|
||||||
{
|
{
|
||||||
|
var localOccurredAt = ChinaTime.ToBeijingTime(occurredAt);
|
||||||
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["LIVE_RECORDER_EVENT"] = eventName,
|
["LIVE_RECORDER_EVENT"] = eventName,
|
||||||
@@ -378,7 +381,7 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
["LIVE_RECORDER_TITLE"] = "Sample live title",
|
["LIVE_RECORDER_TITLE"] = "Sample live title",
|
||||||
["LIVE_RECORDER_ANCHOR"] = "Sample anchor",
|
["LIVE_RECORDER_ANCHOR"] = "Sample anchor",
|
||||||
["LIVE_RECORDER_SOURCE_URL"] = "https://live.douyin.com/676493068539",
|
["LIVE_RECORDER_SOURCE_URL"] = "https://live.douyin.com/676493068539",
|
||||||
["LIVE_RECORDER_OCCURRED_AT_UTC"] = occurredAt.ToString("O")
|
["LIVE_RECORDER_OCCURRED_AT_UTC"] = localOccurredAt.ToString("O")
|
||||||
};
|
};
|
||||||
|
|
||||||
if (string.Equals(eventName, "segment_completed", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(eventName, "segment_completed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -780,6 +780,11 @@ public sealed partial class FfmpegService
|
|||||||
session.Id,
|
session.Id,
|
||||||
currentTask.Id);
|
currentTask.Id);
|
||||||
|
|
||||||
|
if (!runtime.StopRequested && session.LiveRoomId != Guid.Empty)
|
||||||
|
{
|
||||||
|
_liveRoomPollingSignal.RequestImmediatePoll(session.LiveRoomId, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
if (session.Status == RecordSessionStatus.Failed && session.LiveRoom is not null)
|
if (session.Status == RecordSessionStatus.Failed && session.LiveRoom is not null)
|
||||||
{
|
{
|
||||||
await emailNotificationService.SendExceptionAsync(
|
await emailNotificationService.SendExceptionAsync(
|
||||||
|
|||||||
@@ -31,15 +31,18 @@ public sealed partial class FfmpegService : IFfmpegService
|
|||||||
private int _maxConcurrentTranscodeTasks = 1;
|
private int _maxConcurrentTranscodeTasks = 1;
|
||||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
private readonly IStorageGuardService _storageGuardService;
|
private readonly IStorageGuardService _storageGuardService;
|
||||||
|
private readonly ILiveRoomPollingSignal _liveRoomPollingSignal;
|
||||||
private readonly ILogger<FfmpegService> _logger;
|
private readonly ILogger<FfmpegService> _logger;
|
||||||
|
|
||||||
public FfmpegService(
|
public FfmpegService(
|
||||||
IServiceScopeFactory serviceScopeFactory,
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
IStorageGuardService storageGuardService,
|
IStorageGuardService storageGuardService,
|
||||||
|
ILiveRoomPollingSignal liveRoomPollingSignal,
|
||||||
ILogger<FfmpegService> logger)
|
ILogger<FfmpegService> logger)
|
||||||
{
|
{
|
||||||
_serviceScopeFactory = serviceScopeFactory;
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
_storageGuardService = storageGuardService;
|
_storageGuardService = storageGuardService;
|
||||||
|
_liveRoomPollingSignal = liveRoomPollingSignal;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
|
public interface ILiveRoomPollingSignal
|
||||||
|
{
|
||||||
|
void RequestImmediatePoll(Guid liveRoomId, TimeSpan? delay = null);
|
||||||
|
}
|
||||||
@@ -19,8 +19,9 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace LiveRecorder.Infrastructure.Services;
|
namespace LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveRoomPollingSignal
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan TransientRetryDelay = TimeSpan.FromSeconds(10);
|
||||||
private static readonly TimeSpan OfflineGracefulStopTimeout = TimeSpan.FromSeconds(20);
|
private static readonly TimeSpan OfflineGracefulStopTimeout = TimeSpan.FromSeconds(20);
|
||||||
private static readonly TimeSpan OfflineForcedStopTimeout = TimeSpan.FromSeconds(8);
|
private static readonly TimeSpan OfflineForcedStopTimeout = TimeSpan.FromSeconds(8);
|
||||||
private static readonly TimeSpan ExceptionEmailCooldown = TimeSpan.FromHours(6);
|
private static readonly TimeSpan ExceptionEmailCooldown = TimeSpan.FromHours(6);
|
||||||
@@ -32,6 +33,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
private readonly ILogger<LiveRoomPollingBackgroundService> _logger;
|
private readonly ILogger<LiveRoomPollingBackgroundService> _logger;
|
||||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _exceptionEmailSentAt = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, DateTimeOffset> _exceptionEmailSentAt = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _nextPollDueAt = new();
|
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _nextPollDueAt = new();
|
||||||
|
private readonly SemaphoreSlim _wakeSignal = new(0);
|
||||||
|
|
||||||
public LiveRoomPollingBackgroundService(
|
public LiveRoomPollingBackgroundService(
|
||||||
IServiceScopeFactory serviceScopeFactory,
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
@@ -41,6 +43,23 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RequestImmediatePoll(Guid liveRoomId, TimeSpan? delay = null)
|
||||||
|
{
|
||||||
|
var requestedDueAt = DateTimeOffset.UtcNow + (delay ?? MinimumIdleDelay);
|
||||||
|
_nextPollDueAt.AddOrUpdate(
|
||||||
|
liveRoomId,
|
||||||
|
requestedDueAt,
|
||||||
|
(_, current) => requestedDueAt < current ? requestedDueAt : current);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_wakeSignal.Release();
|
||||||
|
}
|
||||||
|
catch (SemaphoreFullException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
@@ -61,7 +80,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
|
|
||||||
if (!settings.EnableBackgroundPolling)
|
if (!settings.EnableBackgroundPolling)
|
||||||
{
|
{
|
||||||
await DelayAsync(TimeSpan.FromSeconds(Math.Clamp(settings.PollingIntervalSeconds, 10, 3600)), stoppingToken);
|
await WaitForNextRunAsync(TimeSpan.FromSeconds(Math.Clamp(settings.PollingIntervalSeconds, 10, 3600)), stoppingToken);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,12 +179,23 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await DelayAsync(delay, stoppingToken);
|
await WaitForNextRunAsync(delay, stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
|
private async Task WaitForNextRunAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||||
Task.Delay(ClampDelay(delay), cancellationToken);
|
{
|
||||||
|
while (_wakeSignal.CurrentCount > 0)
|
||||||
|
{
|
||||||
|
await _wakeSignal.WaitAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var effectiveDelay = ClampDelay(delay);
|
||||||
|
var delayTask = Task.Delay(effectiveDelay, cancellationToken);
|
||||||
|
var wakeTask = _wakeSignal.WaitAsync(cancellationToken);
|
||||||
|
var completedTask = await Task.WhenAny(delayTask, wakeTask);
|
||||||
|
await completedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private static TimeSpan ClampDelay(TimeSpan delay)
|
private static TimeSpan ClampDelay(TimeSpan delay)
|
||||||
{
|
{
|
||||||
@@ -254,6 +284,8 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nextDueAt = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(intervalSeconds, 10, 3600));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
|
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
|
||||||
@@ -380,6 +412,21 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
_logger.LogWarning(ex, "Polling room {RoomId} failed", liveRoom.RoomId);
|
_logger.LogWarning(ex, "Polling room {RoomId} failed", liveRoom.RoomId);
|
||||||
|
|
||||||
var isTransient = IsTransientPollingException(ex, cancellationToken);
|
var isTransient = IsTransientPollingException(ex, cancellationToken);
|
||||||
|
if (isTransient)
|
||||||
|
{
|
||||||
|
nextDueAt = DateTimeOffset.UtcNow.Add(TransientRetryDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
dbContext,
|
||||||
|
liveRoom,
|
||||||
|
isTransient ? AutoStartDecisionCodes.PollFailedTransient : AutoStartDecisionCodes.PollFailed,
|
||||||
|
isTransient
|
||||||
|
? "Auto-start is pending because live status polling failed temporarily."
|
||||||
|
: "Auto-start failed because live status polling failed.",
|
||||||
|
BuildPollingFailureDetail(ex),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
await logService.WriteAsync(
|
await logService.WriteAsync(
|
||||||
isTransient ? SystemLogLevel.Warning : SystemLogLevel.Error,
|
isTransient ? SystemLogLevel.Warning : SystemLogLevel.Error,
|
||||||
"Scheduler",
|
"Scheduler",
|
||||||
@@ -408,7 +455,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_nextPollDueAt[liveRoomId] = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(intervalSeconds, 10, 3600));
|
_nextPollDueAt[liveRoomId] = nextDueAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +652,12 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
IsTransientPollingException(exception.InnerException, cancellationToken);
|
IsTransientPollingException(exception.InnerException, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildPollingFailureDetail(Exception exception)
|
||||||
|
{
|
||||||
|
var root = exception.GetBaseException();
|
||||||
|
return Truncate($"{root.GetType().Name}: {root.Message}", 2048) ?? exception.GetType().Name;
|
||||||
|
}
|
||||||
|
|
||||||
private bool ShouldSendExceptionEmail(string key)
|
private bool ShouldSendExceptionEmail(string key)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ public sealed class RecordSessionsController : ControllerBase
|
|||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
Ok(await _recordSessionService.DeleteAsync(request, cancellationToken));
|
Ok(await _recordSessionService.DeleteAsync(request, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("delete-missing-files")]
|
||||||
|
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteMissingFiles(
|
||||||
|
[FromBody] DeleteMissingFileRecordSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _recordSessionService.DeleteMissingFilesAsync(request, cancellationToken));
|
||||||
|
|
||||||
[HttpPost("{id:guid}/upload")]
|
[HttpPost("{id:guid}/upload")]
|
||||||
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||||
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
|
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
|
||||||
|
|||||||
@@ -167,7 +167,9 @@ builder.Services.AddScoped<ILiveDanmakuAdapterFactory, LiveDanmakuAdapterFactory
|
|||||||
builder.Services.AddSingleton<IFfmpegService, FfmpegService>();
|
builder.Services.AddSingleton<IFfmpegService, FfmpegService>();
|
||||||
builder.Services.AddSingleton<IStorageGuardService, StorageGuardService>();
|
builder.Services.AddSingleton<IStorageGuardService, StorageGuardService>();
|
||||||
builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
|
builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
|
||||||
builder.Services.AddHostedService<LiveRoomPollingBackgroundService>();
|
builder.Services.AddSingleton<LiveRoomPollingBackgroundService>();
|
||||||
|
builder.Services.AddSingleton<ILiveRoomPollingSignal>(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
|
||||||
|
builder.Services.AddHostedService(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
|
||||||
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
|
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|||||||
Reference in New Issue
Block a user