Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a062bf84bf | ||
|
|
df70b64956 | ||
|
|
5196ffa0f0 | ||
|
|
6da0690fd3 | ||
|
|
e640d5b5cc | ||
|
|
0831b33ea0 | ||
|
|
f5ad1dec00 | ||
|
|
a819322559 | ||
|
|
2b0345722e | ||
|
|
9e5bbbb948 | ||
|
|
f9016931e8 | ||
|
|
46da2d78f0 | ||
|
|
524a053263 | ||
|
|
24e5cf2a06 | ||
|
|
cbee29bef9 | ||
|
|
a5c2cc3202 | ||
|
|
b2aaef093d | ||
|
|
f7fa02d13c | ||
|
|
b82110461a |
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
||||
pipeline {
|
||||
agent { label '閺嬪嫬缂撻張?' }
|
||||
agent { label '\u6784\u5efa\u673a1' }
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ElNotification } from "element-plus";
|
||||
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
|
||||
import { isNoBackendPreviewMode } from "@/utils/devPreview";
|
||||
|
||||
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const apiBaseUrl = "/api";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
@@ -154,6 +154,7 @@ function notifyBackendUnavailable(message: string) {
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
console.log("[API DEBUG]", config.method?.toUpperCase(), config.baseURL || "", config.url || "", "→", (config.baseURL || "") + (config.url || ""));
|
||||
const token = localStorage.getItem("live-recorder-token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useViewport } from "@/composables/useViewport";
|
||||
import {
|
||||
ArrowDown,
|
||||
Bell,
|
||||
DataAnalysis,
|
||||
Document,
|
||||
Fold,
|
||||
House,
|
||||
@@ -36,6 +37,7 @@ const navigationGroups = [
|
||||
key: "monitor",
|
||||
title: "直播监控",
|
||||
items: [
|
||||
{ index: "/", label: "仪表盘", icon: DataAnalysis },
|
||||
{ index: "/live-rooms", label: "直播间", icon: House },
|
||||
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
|
||||
{ index: "/recovery", label: "恢复中心", icon: RefreshRight }
|
||||
@@ -72,6 +74,8 @@ const backendStatusDescription = computed(() =>
|
||||
const backendStatusTone = computed(() => (backendUnavailable.value ? "is-danger" : "is-healthy"));
|
||||
const pageEyebrow = computed(() => {
|
||||
switch (route.name) {
|
||||
case "dashboard":
|
||||
return "系统概览";
|
||||
case "live-rooms":
|
||||
return "直播间监控";
|
||||
case "record-tasks":
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { createDanmakuEngine } from "./danmakuEngine";
|
||||
import type { DanmakuEngine } from "./danmakuEngine";
|
||||
import type { DanmakuEvent } from "@/types";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
videoSrc: string;
|
||||
danmakuEvents: DanmakuEvent[];
|
||||
autoplay?: boolean;
|
||||
}>(),
|
||||
{
|
||||
autoplay: false,
|
||||
}
|
||||
);
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const overlayRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
let engine: DanmakuEngine | null = null;
|
||||
const isPlaying = ref(false);
|
||||
const hasError = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
function onPlay(): void {
|
||||
isPlaying.value = true;
|
||||
engine?.start();
|
||||
}
|
||||
|
||||
function onPause(): void {
|
||||
isPlaying.value = false;
|
||||
engine?.stop();
|
||||
}
|
||||
|
||||
function onSeeking(): void {
|
||||
engine?.stop();
|
||||
}
|
||||
|
||||
function onSeeked(): void {
|
||||
if (!videoRef.value) return;
|
||||
lastKnownTime = videoRef.value.currentTime;
|
||||
if (isPlaying.value) {
|
||||
engine?.start();
|
||||
}
|
||||
}
|
||||
|
||||
function onEnded(): void {
|
||||
isPlaying.value = false;
|
||||
engine?.stop();
|
||||
}
|
||||
|
||||
function onVideoError(): void {
|
||||
hasError.value = true;
|
||||
errorMessage.value = "视频加载失败,请刷新预览票据后重试。";
|
||||
}
|
||||
|
||||
function onLoadedMetadata(): void {
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
}
|
||||
|
||||
let lastKnownTime = 0;
|
||||
|
||||
function initEngine(): void {
|
||||
if (!videoRef.value || !overlayRef.value) return;
|
||||
|
||||
// Clean up previous engine
|
||||
engine?.destroy();
|
||||
|
||||
engine = createDanmakuEngine(
|
||||
videoRef.value,
|
||||
props.danmakuEvents,
|
||||
overlayRef.value
|
||||
);
|
||||
|
||||
if (isPlaying.value) {
|
||||
engine.start();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.danmakuEvents,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (videoRef.value && overlayRef.value) {
|
||||
initEngine();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.videoSrc,
|
||||
() => {
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
isPlaying.value = false;
|
||||
engine?.destroy();
|
||||
engine = null;
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (videoRef.value && overlayRef.value) {
|
||||
initEngine();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
engine?.destroy();
|
||||
engine = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="danmaku-player" :class="{ 'has-error': hasError }">
|
||||
<!-- Error overlay -->
|
||||
<div v-if="hasError" class="danmaku-player__error">
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Video element -->
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="videoSrc"
|
||||
:autoplay="autoplay"
|
||||
:key="videoSrc"
|
||||
class="danmaku-player__video"
|
||||
controls
|
||||
preload="auto"
|
||||
crossorigin="anonymous"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@seeking="onSeeking"
|
||||
@seeked="onSeeked"
|
||||
@ended="onEnded"
|
||||
@error="onVideoError"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
/>
|
||||
|
||||
<!-- Danmaku overlay -->
|
||||
<div
|
||||
ref="overlayRef"
|
||||
class="danmaku-player__overlay"
|
||||
:class="{ 'is-paused': !isPlaying && !hasError }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.danmaku-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #09121d;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-soft, 0 4px 16px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.danmaku-player__video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 72vh;
|
||||
background: #09121d;
|
||||
}
|
||||
|
||||
.danmaku-player__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.danmaku-player__overlay.is-paused :deep(.danmaku-chat) {
|
||||
animation-play-state: paused !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.danmaku-player__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(9, 18, 29, 0.92);
|
||||
color: #f0aa6b;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Danmaku chat messages (global styles since DOM elements are created by engine) */
|
||||
:deep(.danmaku-chat) {
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
text-shadow:
|
||||
1px 1px 2px rgba(0, 0, 0, 0.9),
|
||||
-1px -1px 2px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: auto;
|
||||
font-weight: 700;
|
||||
will-change: transform;
|
||||
line-height: 1.2;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
/* Danmaku event notifications (global styles since DOM elements are created by engine) */
|
||||
:deep(.danmaku-event-notification) {
|
||||
position: absolute;
|
||||
bottom: 64px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 18px;
|
||||
border-radius: 24px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 12;
|
||||
animation: danmaku-event-in 0.3s ease-out, danmaku-event-out 0.4s ease-in 3.1s forwards;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--gift) {
|
||||
border-left: 3px solid #f0aa6b;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--like) {
|
||||
border-left: 3px solid #f25d8e;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--member) {
|
||||
border-left: 3px solid #ffc53d;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--superchat) {
|
||||
border-left: 3px solid #5dade2;
|
||||
}
|
||||
|
||||
@keyframes danmaku-event-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes danmaku-event-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.danmaku-player__video {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
:deep(.danmaku-chat) {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification) {
|
||||
bottom: 56px;
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { DanmakuEvent } from "@/types";
|
||||
|
||||
const CHAT_TRACK_COUNT = 12;
|
||||
const TRACK_HEIGHT_PX = 32;
|
||||
const CHAT_SCROLL_DURATION_SECONDS = 8;
|
||||
const MAX_ACTIVE_CHATS = 80;
|
||||
const MAX_ACTIVE_EVENTS = 3;
|
||||
const EVENT_DISPLAY_DURATION_MS = 3500;
|
||||
const LOOKBACK_SECONDS = 0.15;
|
||||
const LOOKAHEAD_SECONDS = 0.1;
|
||||
|
||||
interface ActiveChat {
|
||||
id: string;
|
||||
element: HTMLSpanElement;
|
||||
trackIndex: number;
|
||||
spawnTime: number;
|
||||
}
|
||||
|
||||
interface ActiveEventNotification {
|
||||
id: string;
|
||||
element: HTMLDivElement;
|
||||
spawnTime: number;
|
||||
}
|
||||
|
||||
export interface DanmakuEngine {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
reset(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createDanmakuEngine(
|
||||
video: HTMLVideoElement,
|
||||
events: DanmakuEvent[],
|
||||
overlay: HTMLElement
|
||||
): DanmakuEngine {
|
||||
let animationId = 0;
|
||||
let running = false;
|
||||
|
||||
// Pre-sort events by offsetSeconds
|
||||
const sortedEvents = [...events].sort(
|
||||
(a, b) => a.offsetSeconds - b.offsetSeconds
|
||||
);
|
||||
|
||||
// Separate chats and non-chat events
|
||||
const chatEvents = sortedEvents.filter((e) => e.type === "chat");
|
||||
const nonChatEvents = sortedEvents.filter((e) => e.type !== "chat");
|
||||
|
||||
let nextChatIndex = 0;
|
||||
let nextEventIndex = 0;
|
||||
|
||||
// Track occupancy
|
||||
const activeChats: ActiveChat[] = [];
|
||||
const activeEventNotifications: ActiveEventNotification[] = [];
|
||||
|
||||
let lastTime = 0;
|
||||
|
||||
function spawnChat(event: DanmakuEvent, currentTime: number): void {
|
||||
// Garbage collect finished chats first
|
||||
while (
|
||||
activeChats.length > 0 &&
|
||||
currentTime - activeChats[0].spawnTime > CHAT_SCROLL_DURATION_SECONDS
|
||||
) {
|
||||
const finished = activeChats.shift()!;
|
||||
if (finished.element.parentNode) {
|
||||
finished.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Cap active chats
|
||||
if (activeChats.length >= MAX_ACTIVE_CHATS) {
|
||||
const oldest = activeChats.shift()!;
|
||||
if (oldest.element.parentNode) {
|
||||
oldest.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the least-occupied track
|
||||
const trackUsage = new Array<number>(CHAT_TRACK_COUNT).fill(0);
|
||||
for (const chat of activeChats) {
|
||||
if (chat.trackIndex < CHAT_TRACK_COUNT) {
|
||||
trackUsage[chat.trackIndex]++;
|
||||
}
|
||||
}
|
||||
let bestTrack = 0;
|
||||
let minUsage = Infinity;
|
||||
// Add some randomness to avoid all chats on the same "best" track
|
||||
const startTrack = Math.floor(Math.random() * CHAT_TRACK_COUNT);
|
||||
for (let offset = 0; offset < CHAT_TRACK_COUNT; offset++) {
|
||||
const trackIndex = (startTrack + offset) % CHAT_TRACK_COUNT;
|
||||
if (trackUsage[trackIndex] < minUsage) {
|
||||
minUsage = trackUsage[trackIndex];
|
||||
bestTrack = trackIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const element = document.createElement("span");
|
||||
element.className = "danmaku-chat";
|
||||
element.textContent = event.content || "";
|
||||
|
||||
const color = event.color || "FFFFFF";
|
||||
const fontSize = event.fontSize || 25;
|
||||
const topPx = bestTrack * TRACK_HEIGHT_PX;
|
||||
|
||||
element.style.cssText = [
|
||||
`color: #${color}`,
|
||||
`font-size: ${fontSize}px`,
|
||||
`top: ${topPx}px`,
|
||||
"position: absolute",
|
||||
"white-space: nowrap",
|
||||
"text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8)",
|
||||
"pointer-events: auto",
|
||||
"font-weight: 700",
|
||||
"will-change: transform",
|
||||
"left: 100%",
|
||||
`transform: translateX(0)`,
|
||||
].join("; ");
|
||||
|
||||
overlay.appendChild(element);
|
||||
|
||||
activeChats.push({
|
||||
id: `chat-${event.offsetSeconds}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
element,
|
||||
trackIndex: bestTrack,
|
||||
spawnTime: currentTime,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnEventNotification(event: DanmakuEvent): void {
|
||||
// Garbage collect finished notifications
|
||||
const now = performance.now();
|
||||
while (
|
||||
activeEventNotifications.length > 0 &&
|
||||
now - activeEventNotifications[0].spawnTime > EVENT_DISPLAY_DURATION_MS
|
||||
) {
|
||||
const finished = activeEventNotifications.shift()!;
|
||||
if (finished.element.parentNode) {
|
||||
finished.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Cap active notifications
|
||||
if (activeEventNotifications.length >= MAX_ACTIVE_EVENTS) {
|
||||
const oldest = activeEventNotifications.shift()!;
|
||||
if (oldest.element.parentNode) {
|
||||
oldest.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const element = document.createElement("div");
|
||||
element.className = `danmaku-event-notification danmaku-event-notification--${event.type}`;
|
||||
|
||||
const userLabel = event.user ? `<strong>${escapeHtml(event.user)}</strong>` : "";
|
||||
const contentLabel = escapeHtml(event.content || "");
|
||||
|
||||
let typeIcon = "";
|
||||
switch (event.type) {
|
||||
case "gift":
|
||||
typeIcon = "🎁 ";
|
||||
break;
|
||||
case "like":
|
||||
typeIcon = "❤️ ";
|
||||
break;
|
||||
case "member":
|
||||
typeIcon = "⭐ ";
|
||||
break;
|
||||
case "enter":
|
||||
typeIcon = "👤 ";
|
||||
break;
|
||||
case "superchat":
|
||||
typeIcon = "💬 ";
|
||||
break;
|
||||
default:
|
||||
typeIcon = "📌 ";
|
||||
break;
|
||||
}
|
||||
|
||||
element.innerHTML = `${typeIcon}${userLabel} ${contentLabel}`;
|
||||
|
||||
overlay.appendChild(element);
|
||||
|
||||
activeEventNotifications.push({
|
||||
id: `event-${event.offsetSeconds}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
element,
|
||||
spawnTime: performance.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function binarySearchFirst(
|
||||
arr: DanmakuEvent[],
|
||||
startIndex: number,
|
||||
target: number
|
||||
): number {
|
||||
let low = startIndex;
|
||||
let high = arr.length - 1;
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (arr[mid].offsetSeconds < target) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function tick(): void {
|
||||
if (!running) return;
|
||||
|
||||
const currentTime = video.currentTime;
|
||||
|
||||
// If seeking backwards, reset
|
||||
if (currentTime < lastTime - 0.5) {
|
||||
resetState();
|
||||
}
|
||||
lastTime = currentTime;
|
||||
|
||||
const lookback = currentTime - LOOKBACK_SECONDS;
|
||||
const lookahead = currentTime + LOOKAHEAD_SECONDS;
|
||||
|
||||
// Binary search to find the start of new chats
|
||||
nextChatIndex = binarySearchFirst(chatEvents, nextChatIndex, lookback);
|
||||
// Spawn chats within the window
|
||||
while (nextChatIndex < chatEvents.length && chatEvents[nextChatIndex].offsetSeconds <= lookahead) {
|
||||
spawnChat(chatEvents[nextChatIndex], currentTime);
|
||||
nextChatIndex++;
|
||||
}
|
||||
|
||||
// Same for non-chat events
|
||||
nextEventIndex = binarySearchFirst(nonChatEvents, nextEventIndex, lookback);
|
||||
while (nextEventIndex < nonChatEvents.length && nonChatEvents[nextEventIndex].offsetSeconds <= lookahead) {
|
||||
spawnEventNotification(nonChatEvents[nextEventIndex]);
|
||||
nextEventIndex++;
|
||||
}
|
||||
|
||||
// Update chat positions based on elapsed time since spawn
|
||||
const overlayWidth = overlay.clientWidth || video.clientWidth || 640;
|
||||
for (let i = activeChats.length - 1; i >= 0; i--) {
|
||||
const chat = activeChats[i];
|
||||
const elapsed = currentTime - chat.spawnTime;
|
||||
const progress = Math.max(0, Math.min(1, elapsed / CHAT_SCROLL_DURATION_SECONDS));
|
||||
const translateX = -overlayWidth * progress;
|
||||
chat.element.style.transform = `translateX(${translateX}px)`;
|
||||
|
||||
// Remove finished chats
|
||||
if (elapsed > CHAT_SCROLL_DURATION_SECONDS + 0.5) {
|
||||
if (chat.element.parentNode) {
|
||||
chat.element.remove();
|
||||
}
|
||||
activeChats.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Garbage collect finished event notifications
|
||||
const now = performance.now();
|
||||
for (let i = activeEventNotifications.length - 1; i >= 0; i--) {
|
||||
const notif = activeEventNotifications[i];
|
||||
if (now - notif.spawnTime > EVENT_DISPLAY_DURATION_MS) {
|
||||
if (notif.element.parentNode) {
|
||||
notif.element.remove();
|
||||
}
|
||||
activeEventNotifications.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function resetState(): void {
|
||||
// Clear all active elements
|
||||
for (const chat of activeChats) {
|
||||
if (chat.element.parentNode) {
|
||||
chat.element.remove();
|
||||
}
|
||||
}
|
||||
activeChats.length = 0;
|
||||
|
||||
for (const notif of activeEventNotifications) {
|
||||
if (notif.element.parentNode) {
|
||||
notif.element.remove();
|
||||
}
|
||||
}
|
||||
activeEventNotifications.length = 0;
|
||||
|
||||
// Reset indices to the beginning
|
||||
nextChatIndex = 0;
|
||||
nextEventIndex = 0;
|
||||
// Reset lastTime so a seek to 0 doesn't trigger another reset
|
||||
lastTime = 0;
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (running) return;
|
||||
running = true;
|
||||
resetState();
|
||||
lastTime = video.currentTime;
|
||||
animationId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
running = false;
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
stop();
|
||||
resetState();
|
||||
start();
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
stop();
|
||||
resetState();
|
||||
}
|
||||
|
||||
return { start, stop, reset, destroy };
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ref } from "vue";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import type { DanmakuEvent, DanmakuResponse, SessionDanmakuResponse } from "@/types";
|
||||
|
||||
export function useDanmakuPlayer() {
|
||||
const danmakuEvents = ref<DanmakuEvent[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function loadTaskDanmaku(taskId: string): Promise<boolean> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<DanmakuResponse>(`/record-tasks/${taskId}/danmaku`);
|
||||
danmakuEvents.value = data.events;
|
||||
return data.events.length > 0;
|
||||
} catch (err) {
|
||||
if ((err as { response?: { status?: number } })?.response?.status === 404) {
|
||||
error.value = "该分片没有弹幕数据。";
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, "弹幕数据加载失败。");
|
||||
}
|
||||
danmakuEvents.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionDanmaku(sessionId: string): Promise<boolean> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<SessionDanmakuResponse>(`/record-sessions/${sessionId}/danmaku`);
|
||||
// Flatten all task events into a single list with session-level offsets
|
||||
const allEvents: DanmakuEvent[] = [];
|
||||
for (const task of data.tasks) {
|
||||
allEvents.push(...task.events);
|
||||
}
|
||||
// Sort by offset for proper playback order
|
||||
allEvents.sort((a, b) => a.offsetSeconds - b.offsetSeconds);
|
||||
danmakuEvents.value = allEvents;
|
||||
return allEvents.length > 0;
|
||||
} catch (err) {
|
||||
if ((err as { response?: { status?: number } })?.response?.status === 404) {
|
||||
error.value = "该场次没有弹幕数据。";
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, "弹幕数据加载失败。");
|
||||
}
|
||||
danmakuEvents.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
danmakuEvents.value = [];
|
||||
error.value = "";
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
return { danmakuEvents, loading, error, loadTaskDanmaku, loadSessionDanmaku, clear };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const LoginView = () => import("@/views/LoginView.vue");
|
||||
const MainLayout = () => import("@/components/layout/MainLayout.vue");
|
||||
const DashboardView = () => import("@/views/DashboardView.vue");
|
||||
const LiveRoomsView = () => import("@/views/LiveRoomsView.vue");
|
||||
const RecordTasksView = () => import("@/views/RecordTasksView.vue");
|
||||
const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue");
|
||||
@@ -29,7 +30,8 @@ const router = createRouter({
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
redirect: "/live-rooms"
|
||||
name: "dashboard",
|
||||
component: DashboardView
|
||||
},
|
||||
{
|
||||
path: "live-rooms",
|
||||
|
||||
@@ -61,9 +61,17 @@
|
||||
--el-border-color-light: var(--border-subtle);
|
||||
--el-border-radius-base: var(--radius-sm);
|
||||
--el-bg-color: transparent;
|
||||
--el-bg-color-page: var(--bg-base);
|
||||
--el-bg-color-overlay: var(--surface);
|
||||
--el-fill-color: var(--surface-muted);
|
||||
--el-fill-color-blank: var(--surface);
|
||||
--el-fill-color-light: var(--surface-muted);
|
||||
--el-fill-color-lighter: var(--surface-strong);
|
||||
--el-fill-color-dark: var(--surface-raised);
|
||||
--el-fill-color-darker: var(--surface-strong);
|
||||
--el-disabled-bg-color: var(--surface-muted);
|
||||
--el-disabled-text-color: var(--text-soft);
|
||||
--el-text-color-placeholder: var(--text-soft);
|
||||
--el-mask-color: rgba(15, 23, 42, 0.54);
|
||||
}
|
||||
|
||||
@@ -123,9 +131,17 @@ html[data-theme="dark"] {
|
||||
--el-text-color-secondary: var(--text-muted);
|
||||
--el-border-color: var(--border-base);
|
||||
--el-border-color-light: var(--border-subtle);
|
||||
--el-bg-color-page: var(--bg-base);
|
||||
--el-bg-color-overlay: var(--surface);
|
||||
--el-fill-color: var(--surface-muted);
|
||||
--el-fill-color-blank: var(--surface);
|
||||
--el-fill-color-light: var(--surface-muted);
|
||||
--el-fill-color-lighter: var(--surface-strong);
|
||||
--el-fill-color-dark: var(--surface-raised);
|
||||
--el-fill-color-darker: var(--surface-strong);
|
||||
--el-disabled-bg-color: var(--surface-muted);
|
||||
--el-disabled-text-color: var(--text-soft);
|
||||
--el-text-color-placeholder: var(--text-soft);
|
||||
--el-mask-color: rgba(3, 7, 14, 0.72);
|
||||
}
|
||||
|
||||
@@ -255,6 +271,7 @@ select:focus-visible {
|
||||
.surface-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
--el-card-bg-color: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
@@ -573,9 +590,33 @@ html[data-theme="dark"] .stat-card {
|
||||
|
||||
.el-card {
|
||||
--el-card-border-color: transparent;
|
||||
--el-card-bg-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-tabs--border-card {
|
||||
border-color: var(--border-subtle);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.el-tabs--border-card > .el-tabs__content {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.el-tabs--border-card > .el-tabs__header {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-tabs--border-card > .el-tabs__header .el-tabs__item {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
min-height: var(--control-height);
|
||||
padding: 0 15px;
|
||||
@@ -802,6 +843,17 @@ html[data-theme="dark"] .stat-card {
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.el-message-box,
|
||||
.el-popover.el-popper,
|
||||
.el-select__popper.el-popper,
|
||||
.el-picker__popper.el-popper,
|
||||
.el-dropdown__popper.el-popper .el-dropdown-menu {
|
||||
border-color: var(--border-subtle);
|
||||
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
margin: 0;
|
||||
padding: 22px 24px 10px;
|
||||
|
||||
@@ -421,6 +421,8 @@ export interface SystemSettings {
|
||||
enableStorageGuard: boolean;
|
||||
pauseRecordingWhenFreeSpaceBelowMegabytes: number;
|
||||
resumeRecordingWhenFreeSpaceAboveMegabytes: number;
|
||||
storageGreenThresholdPercent: number;
|
||||
storageRedThresholdPercent: number;
|
||||
enableRetentionCleanup: boolean;
|
||||
retentionDays: number;
|
||||
retentionDeleteFiles: boolean;
|
||||
@@ -458,6 +460,8 @@ export interface SystemSettings {
|
||||
segmentCompletedScriptPath: string;
|
||||
segmentCompletedScriptContent: string;
|
||||
eventScriptTimeoutSeconds: number;
|
||||
eventScriptRetryAttempts: number;
|
||||
eventScriptRetryDelaySeconds: number;
|
||||
enableEmailNotification: boolean;
|
||||
emailSmtpHost: string;
|
||||
emailSmtpPort: number;
|
||||
@@ -775,3 +779,124 @@ export const uploadStatusLabelMap: Record<number, string> = {
|
||||
1: "已上传",
|
||||
2: "上传失败"
|
||||
};
|
||||
|
||||
export interface DanmakuEvent {
|
||||
offsetSeconds: number;
|
||||
type: string;
|
||||
content: string;
|
||||
user?: string;
|
||||
userId?: string;
|
||||
color?: string;
|
||||
fontSize?: number;
|
||||
mode?: number;
|
||||
timestampMs?: number;
|
||||
giftName?: string;
|
||||
count?: number;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
export interface DanmakuResponse {
|
||||
recordTaskId: string;
|
||||
segmentIndex: number;
|
||||
platform?: string;
|
||||
roomId?: string;
|
||||
liveRoomId?: string;
|
||||
recordSessionId: string;
|
||||
startedAt?: string;
|
||||
events: DanmakuEvent[];
|
||||
}
|
||||
|
||||
export interface SessionDanmakuResponse {
|
||||
recordSessionId: string;
|
||||
tasks: DanmakuResponse[];
|
||||
}
|
||||
|
||||
// Dashboard types
|
||||
export interface DashboardData {
|
||||
activeRecordingCount: number;
|
||||
liveRoomCount: number;
|
||||
offlineRoomCount: number;
|
||||
totalRoomCount: number;
|
||||
todayRecordingSeconds: number;
|
||||
todayDataBytes: number;
|
||||
todayDanmakuCount: number;
|
||||
activeSessionCount: number;
|
||||
recentErrorCount: number;
|
||||
storageStatus: DashboardStorageStatus;
|
||||
recentSessions: DashboardRecentSession[];
|
||||
topRooms: DashboardTopRoom[];
|
||||
pendingTranscodeCount: number;
|
||||
pendingUploadCount: number;
|
||||
queuedDataBytes: number;
|
||||
}
|
||||
|
||||
export interface DashboardStorageStatus {
|
||||
hasEnoughSpace: boolean;
|
||||
message: string;
|
||||
availableBytes: number;
|
||||
tier: string;
|
||||
usagePercent: number;
|
||||
}
|
||||
|
||||
export interface DashboardRecentSession {
|
||||
id: string;
|
||||
liveRoomId: string;
|
||||
liveRoomTitle: string;
|
||||
platformName: string;
|
||||
segmentCount: number;
|
||||
status: number;
|
||||
startedAt?: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface DashboardTopRoom {
|
||||
liveRoomId: string;
|
||||
title?: string;
|
||||
anchorName?: string;
|
||||
platformName: string;
|
||||
roomId: string;
|
||||
sessionCount: number;
|
||||
totalDurationSeconds: number;
|
||||
}
|
||||
|
||||
// Video metadata types
|
||||
export interface VideoMetadata {
|
||||
durationSeconds?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
frameRate?: number;
|
||||
bitRate?: number;
|
||||
}
|
||||
|
||||
// Session playlist types
|
||||
export interface SessionPlaylistSegment {
|
||||
recordTaskId: string;
|
||||
segmentIndex: number;
|
||||
previewTicketUrl: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface SessionPlaylist {
|
||||
recordSessionId: string;
|
||||
liveRoomTitle: string;
|
||||
segments: SessionPlaylistSegment[];
|
||||
}
|
||||
|
||||
// Bandwidth types
|
||||
export interface BandwidthSummary {
|
||||
totalTrafficMB: number;
|
||||
averageBitrateKbps: number;
|
||||
peakBitrateKbps: number;
|
||||
}
|
||||
|
||||
export interface BandwidthTimeline {
|
||||
points: BandwidthPoint[];
|
||||
}
|
||||
|
||||
export interface BandwidthPoint {
|
||||
timestamp: string;
|
||||
bytesDownloaded: number;
|
||||
bitrateKbps?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Bell, Clock, DataAnalysis, House, VideoCamera, Warning } from "@element-plus/icons-vue";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import type { DashboardData } from "@/types";
|
||||
import { sessionStatusLabelMap } from "@/types";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const data = ref<DashboardData | null>(null);
|
||||
|
||||
function formatDuration(seconds?: number) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "-";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatDataSize(bytes?: number) {
|
||||
if (typeof bytes !== "number" || bytes <= 0) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function sessionStatusTagType(status: number) {
|
||||
if (status === 2) return "success";
|
||||
if (status === 5) return "danger";
|
||||
if (status === 4 || status === 6) return "info";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function storageTierTagType(): "success" | "warning" | "danger" {
|
||||
const tier = data.value?.storageStatus.tier;
|
||||
if (tier === "Green") return "success";
|
||||
if (tier === "Yellow") return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function storageTierLabel(): string {
|
||||
const tier = data.value?.storageStatus.tier;
|
||||
if (tier === "Green") return "正常";
|
||||
if (tier === "Yellow") return "警告";
|
||||
return "紧急";
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const { data: result } = await apiClient.get<DashboardData>("/dashboard");
|
||||
data.value = result;
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "仪表盘数据加载失败。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
<p class="page-subtitle">系统运行状态一览,包含直播间、录制会话、弹幕和存储概况。</p>
|
||||
</div>
|
||||
<el-space class="header-actions">
|
||||
<el-button @click="loadData" :loading="loading">刷新</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<el-skeleton v-if="loading && !data" animated :rows="6" />
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- KPI Cards -->
|
||||
<div class="stats-grid">
|
||||
<MetricCard label="正在录制" :value="data.activeRecordingCount">
|
||||
<template #icon><el-icon :size="18"><VideoCamera /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="直播间" :value="`${data.liveRoomCount} / ${data.offlineRoomCount}`" :description="`共 ${data.totalRoomCount} 个直播间`">
|
||||
<template #icon><el-icon :size="18"><House /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="今日录制时长" :value="formatDuration(data.todayRecordingSeconds)">
|
||||
<template #icon><el-icon :size="18"><Clock /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="今日数据量" :value="formatDataSize(data.todayDataBytes)">
|
||||
<template #icon><el-icon :size="18"><DataAnalysis /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="今日弹幕" :value="data.todayDanmakuCount.toLocaleString()">
|
||||
<template #icon><el-icon :size="18"><Bell /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard
|
||||
label="24h 异常"
|
||||
:value="data.recentErrorCount"
|
||||
:description="data.recentErrorCount > 0 ? '请前往系统日志页面排查' : '系统运行正常'"
|
||||
>
|
||||
<template #icon><el-icon :size="18"><Warning /></el-icon></template>
|
||||
</MetricCard>
|
||||
</div>
|
||||
|
||||
<!-- Storage Status -->
|
||||
<el-card class="surface-card" shadow="never">
|
||||
<h3 class="section-title">存储状态</h3>
|
||||
<p class="section-subtitle">录制输出路径的磁盘剩余空间和当前录制保护阈值。</p>
|
||||
<div class="storage-bar-row">
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">水位线</span>
|
||||
<el-tag :type="storageTierTagType()" size="large">
|
||||
{{ storageTierLabel() }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">使用率</span>
|
||||
<span class="storage-bar-card__value">{{ data.storageStatus.usagePercent.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">可用空间</span>
|
||||
<span class="storage-bar-card__value">{{ formatDataSize(data.storageStatus.availableBytes) }}</span>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">说明</span>
|
||||
<span class="storage-bar-card__value storage-bar-card__value--message">{{ data.storageStatus.message || "-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Queue Status -->
|
||||
<el-row :gutter="18">
|
||||
<el-col :lg="12" :sm="24">
|
||||
<el-card class="surface-card" shadow="never">
|
||||
<h3 class="section-title">处理队列</h3>
|
||||
<p class="section-subtitle">待转码和待上传的文件积压情况。</p>
|
||||
<div class="storage-bar-row">
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">待转码</span>
|
||||
<span class="storage-bar-card__value" :style="{ color: data.pendingTranscodeCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
|
||||
{{ data.pendingTranscodeCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">待上传</span>
|
||||
<span class="storage-bar-card__value" :style="{ color: data.pendingUploadCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
|
||||
{{ data.pendingUploadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">积压数据量</span>
|
||||
<span class="storage-bar-card__value" :style="{ color: data.queuedDataBytes > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
|
||||
{{ formatDataSize(data.queuedDataBytes) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Recent Sessions + Top Rooms -->
|
||||
<el-row :gutter="18">
|
||||
<el-col :lg="12" :sm="24">
|
||||
<el-card class="surface-card" shadow="never">
|
||||
<h3 class="section-title">最近会话</h3>
|
||||
<p class="section-subtitle">最近创建的录制会话,点击可跳转至详情。</p>
|
||||
<div class="table-scroll-shell">
|
||||
<el-table :data="data.recentSessions" class="premium-table" size="small">
|
||||
<el-table-column label="直播间" min-width="140" prop="liveRoomTitle" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="sessionStatusTagType(row.status)" size="small">
|
||||
{{ sessionStatusLabelMap[row.status] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分片" width="60" prop="segmentCount" />
|
||||
<el-table-column label="开始时间" width="160">
|
||||
<template #default="{ row }">{{ formatDate(row.startedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="router.push({ name: 'record-session-detail', params: { id: row.id } })">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :lg="12" :sm="24">
|
||||
<el-card class="surface-card" shadow="never">
|
||||
<h3 class="section-title">今日热门直播间</h3>
|
||||
<p class="section-subtitle">今日录制时长最长的直播间(Top 5)。</p>
|
||||
<div class="table-scroll-shell">
|
||||
<el-table :data="data.topRooms" class="premium-table" size="small">
|
||||
<el-table-column label="直播间" min-width="130">
|
||||
<template #default="{ row }">{{ row.title || row.anchorName || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平台" width="90" prop="platformName" />
|
||||
<el-table-column label="会话数" width="70" prop="sessionCount" />
|
||||
<el-table-column label="录制时长" width="100">
|
||||
<template #default="{ row }">{{ formatDuration(row.totalDurationSeconds) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="router.push({ name: 'live-rooms' })">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
.header-actions {
|
||||
align-self: center;
|
||||
}
|
||||
.page-error-alert {
|
||||
border-radius: 14px;
|
||||
}
|
||||
.storage-bar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px 40px;
|
||||
}
|
||||
.storage-bar-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.storage-bar-card__label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.storage-bar-card__value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.storage-bar-card__value--message {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.header-actions :deep(.el-button) {
|
||||
flex: 1 1 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,9 +3,12 @@ import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
|
||||
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
|
||||
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
|
||||
import type {
|
||||
RecordArtifactUploadBatchResult,
|
||||
RecordPreviewTicket,
|
||||
RecordSessionDetail,
|
||||
RecordSessionTimelineEvent,
|
||||
RecordSessionHeatBucket,
|
||||
@@ -28,6 +31,47 @@ const props = defineProps<{
|
||||
const router = useRouter();
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// Danmaku replay dialog
|
||||
const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
|
||||
const danmakuDialogVisible = ref(false);
|
||||
const danmakuDialogTitle = ref("");
|
||||
const danmakuPreviewUrl = ref("");
|
||||
const danmakuPreviewLoading = ref(false);
|
||||
const danmakuPreviewMessage = ref("");
|
||||
|
||||
async function openDanmakuReplay(recordTaskId: string, segmentIndex: number) {
|
||||
danmakuDialogVisible.value = true;
|
||||
danmakuDialogTitle.value = `弹幕回放 — 分片 #${segmentIndex}`;
|
||||
danmakuPreviewUrl.value = "";
|
||||
danmakuPreviewMessage.value = "";
|
||||
clearDanmaku();
|
||||
|
||||
// Load preview ticket and danmaku in parallel
|
||||
danmakuPreviewLoading.value = true;
|
||||
try {
|
||||
const [ticketResult] = await Promise.allSettled([
|
||||
apiClient.post<RecordPreviewTicket>(`/record-tasks/${recordTaskId}/preview-ticket`),
|
||||
loadTaskDanmaku(recordTaskId)
|
||||
]);
|
||||
|
||||
if (ticketResult.status === "fulfilled") {
|
||||
danmakuPreviewUrl.value = ticketResult.value.data.url;
|
||||
} else {
|
||||
danmakuPreviewMessage.value = "无法获取视频预览票据,请稍后重试。";
|
||||
}
|
||||
} catch {
|
||||
danmakuPreviewMessage.value = "加载预览资源失败。";
|
||||
} finally {
|
||||
danmakuPreviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDanmakuDialog() {
|
||||
danmakuDialogVisible.value = false;
|
||||
danmakuPreviewUrl.value = "";
|
||||
clearDanmaku();
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const uploadLoading = ref(false);
|
||||
const loadError = ref("");
|
||||
@@ -502,9 +546,18 @@ onMounted(loadDetail);
|
||||
{{ formatDuration(row.durationSeconds) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column label="操作" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="row.status !== 4 && row.status !== 6"
|
||||
@click="openDanmakuReplay(row.recordTaskId, row.segmentIndex)"
|
||||
>
|
||||
弹幕回放
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -544,6 +597,24 @@ onMounted(loadDetail);
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- Danmaku Replay Dialog -->
|
||||
<el-dialog
|
||||
v-model="danmakuDialogVisible"
|
||||
:title="danmakuDialogTitle"
|
||||
width="90%"
|
||||
:close-on-click-modal="false"
|
||||
@close="closeDanmakuDialog"
|
||||
>
|
||||
<div v-if="danmakuPreviewLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<div v-else-if="danmakuPreviewMessage" class="preview-empty">{{ danmakuPreviewMessage }}</div>
|
||||
<DanmakuPlayer
|
||||
v-else-if="danmakuPreviewUrl"
|
||||
:video-src="danmakuPreviewUrl"
|
||||
:danmaku-events="replayDanmakuEvents"
|
||||
/>
|
||||
<div v-else class="preview-empty">无法加载该分片的预览。</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -720,6 +791,17 @@ onMounted(loadDetail);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 260px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border-base);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
@@ -730,5 +812,10 @@ onMounted(loadDetail);
|
||||
flex: 1 1 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
min-height: 180px;
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
|
||||
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
|
||||
import type {
|
||||
ManualSegmentCompletedTriggerResult,
|
||||
RecordArtifactUploadItemResult,
|
||||
@@ -35,6 +37,29 @@ const previewUrl = ref("");
|
||||
const previewExpiresAt = ref("");
|
||||
const previewMessage = ref("");
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// Danmaku replay state
|
||||
const { danmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
|
||||
const showDanmaku = ref(false);
|
||||
const danmakuLoaded = ref(false);
|
||||
|
||||
async function toggleDanmaku() {
|
||||
if (showDanmaku.value) {
|
||||
showDanmaku.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!danmakuLoaded.value) {
|
||||
const hasEvents = await loadTaskDanmaku(props.id);
|
||||
if (!hasEvents && danmakuError.value) {
|
||||
ElMessage.warning(danmakuError.value);
|
||||
return;
|
||||
}
|
||||
danmakuLoaded.value = true;
|
||||
}
|
||||
|
||||
showDanmaku.value = true;
|
||||
}
|
||||
const rowGutter = computed(() => (isMobile.value ? 14 : 18));
|
||||
const logTableHeight = computed(() => (isMobile.value ? undefined : 420));
|
||||
const activeTaskStatuses = new Set([1, 2, 3, 7]);
|
||||
@@ -234,7 +259,16 @@ function formatProgress(value?: number) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(0)}%` : "-";
|
||||
}
|
||||
|
||||
watch(() => props.id, loadDetailAndPreview);
|
||||
function resetDanmakuState() {
|
||||
showDanmaku.value = false;
|
||||
danmakuLoaded.value = false;
|
||||
clearDanmaku();
|
||||
}
|
||||
|
||||
watch(() => props.id, () => {
|
||||
resetDanmakuState();
|
||||
loadDetailAndPreview();
|
||||
});
|
||||
onMounted(loadDetailAndPreview);
|
||||
</script>
|
||||
|
||||
@@ -388,6 +422,14 @@ onMounted(loadDetailAndPreview);
|
||||
<div class="preview-meta" v-if="previewExpiresAt">
|
||||
票据有效至 {{ formatDate(previewExpiresAt) }}
|
||||
</div>
|
||||
<el-button
|
||||
v-if="previewUrl && !previewLoading"
|
||||
:type="showDanmaku ? 'primary' : 'default'"
|
||||
:loading="danmakuLoading"
|
||||
@click="toggleDanmaku"
|
||||
>
|
||||
{{ showDanmaku ? "关闭弹幕" : "弹幕回放" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManualTranscode"
|
||||
type="primary"
|
||||
@@ -400,9 +442,14 @@ onMounted(loadDetailAndPreview);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<div v-if="previewLoading || danmakuLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<DanmakuPlayer
|
||||
v-else-if="previewUrl && showDanmaku"
|
||||
:video-src="previewUrl"
|
||||
:danmaku-events="danmakuEvents"
|
||||
/>
|
||||
<video
|
||||
v-else-if="previewUrl"
|
||||
v-else-if="previewUrl && !showDanmaku"
|
||||
:key="previewUrl"
|
||||
class="preview-player"
|
||||
:src="previewUrl"
|
||||
|
||||
@@ -42,7 +42,7 @@ 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" | "conditional-sessions" | "mixed" | "empty-sessions">("tasks");
|
||||
const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions" | "missing-file-tasks">("tasks");
|
||||
const deleteDialogTaskIds = ref<string[]>([]);
|
||||
const deleteDialogSessionIds = ref<string[]>([]);
|
||||
|
||||
@@ -174,6 +174,10 @@ const deleteDialogEyebrow = computed(() => {
|
||||
return "空闲会话清理";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return "无文件分片清理";
|
||||
}
|
||||
|
||||
return "删除确认";
|
||||
});
|
||||
const deleteDialogTitle = computed(() => {
|
||||
@@ -193,6 +197,10 @@ const deleteDialogTitle = computed(() => {
|
||||
return "清理无分片会话";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return "清理无文件分片";
|
||||
}
|
||||
|
||||
return "删除分片任务";
|
||||
});
|
||||
const deleteDialogLead = computed(() => {
|
||||
@@ -212,6 +220,10 @@ const deleteDialogLead = computed(() => {
|
||||
return `将自动找出所有没有任何分片任务的录制会话并批量删除。你也可以选择同时删除可能残留的本地文件。`;
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return `将自动找出所有视频文件已丢失的分片任务并批量删除(不限会话,只删命中的分片本身)。删除后若某个会话下不再有任何分片,会话也会一并清理;你也可以选择同时清理残留的弹幕 XML 文件。`;
|
||||
}
|
||||
|
||||
return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`;
|
||||
});
|
||||
const deleteDialogNote = computed(() => {
|
||||
@@ -227,6 +239,10 @@ const deleteDialogNote = computed(() => {
|
||||
return "仅清理没有任何关联分片的空会话,不影响有录制产物的会话。";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return "以分片为单位判定:仅当分片的视频文件在磁盘上不存在时才会删除。正在录制或处理中的分片会自动跳过,有视频文件的分片不受影响。";
|
||||
}
|
||||
|
||||
return "记录加文件会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
|
||||
});
|
||||
function isActiveStatus(status: number) {
|
||||
@@ -572,6 +588,13 @@ function openDeleteEmptySessionsDialog() {
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openDeleteMissingFileTasksDialog() {
|
||||
deleteDialogMode.value = "missing-file-tasks";
|
||||
deleteDialogSessionIds.value = [];
|
||||
deleteDialogTaskIds.value = [];
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmConditionalDelete() {
|
||||
conditionalDialogVisible.value = false;
|
||||
deleteDialogMode.value = "conditional-sessions";
|
||||
@@ -642,6 +665,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
const deletingSessions = currentMode === "sessions";
|
||||
const deletingConditionalSessions = currentMode === "conditional-sessions";
|
||||
const deletingEmptySessions = currentMode === "empty-sessions";
|
||||
const deletingMissingFileTasks = currentMode === "missing-file-tasks";
|
||||
const deletingMixed = currentMode === "mixed";
|
||||
const hasSelection = deletingMixed
|
||||
? deleteDialogSessionIds.value.length > 0 || deleteDialogTaskIds.value.length > 0
|
||||
@@ -649,7 +673,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
? deleteDialogSessionIds.value.length > 0
|
||||
: deleteDialogTaskIds.value.length > 0;
|
||||
|
||||
if (!deletingConditionalSessions && !deletingEmptySessions && !hasSelection) {
|
||||
if (!deletingConditionalSessions && !deletingEmptySessions && !deletingMissingFileTasks && !hasSelection) {
|
||||
closeDeleteDialog();
|
||||
return;
|
||||
}
|
||||
@@ -685,6 +709,11 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
deleteFiles
|
||||
});
|
||||
createdCleanupOperation = data;
|
||||
} else if (deletingMissingFileTasks) {
|
||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete-missing-files", {
|
||||
deleteFiles
|
||||
});
|
||||
deletedTaskResult = data;
|
||||
} else if (deletingMixed) {
|
||||
if (deleteDialogTaskIds.value.length > 0) {
|
||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||
@@ -723,7 +752,17 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
deleteDialogVisible.value = false;
|
||||
resetDeleteDialogState();
|
||||
|
||||
if (deleteDialogMode.value === "tasks") {
|
||||
if (currentMode === "missing-file-tasks") {
|
||||
ElMessage.success(
|
||||
deletedTaskResult
|
||||
? `已清理 ${deletedTaskResult.deletedTaskIds.length} 个无文件分片。`
|
||||
: "未发现可清理的无文件分片。"
|
||||
);
|
||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMode === "tasks") {
|
||||
ElMessage.success(
|
||||
deletedTaskResult ? `已删除 ${deletedTaskResult.deletedTaskIds.length} 个分片任务。` : "已删除分片任务。"
|
||||
);
|
||||
@@ -737,11 +776,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessage.success(
|
||||
currentMode === "tasks"
|
||||
? "已删除分片任务。"
|
||||
: "后台清理任务已创建,页面会自动轮询进度。"
|
||||
);
|
||||
ElMessage.success("后台清理任务已创建,页面会自动轮询进度。");
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
@@ -931,6 +966,9 @@ onBeforeUnmount(() => {
|
||||
<el-button plain :loading="deleting" @click="openDeleteEmptySessionsDialog">
|
||||
清理无分片会话
|
||||
</el-button>
|
||||
<el-button plain :loading="deleting" @click="openDeleteMissingFileTasksDialog">
|
||||
清理无文件分片
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ const form = reactive<SettingsFormModel>({
|
||||
enableStorageGuard: true,
|
||||
pauseRecordingWhenFreeSpaceBelowMegabytes: 1024,
|
||||
resumeRecordingWhenFreeSpaceAboveMegabytes: 4096,
|
||||
storageGreenThresholdPercent: 30,
|
||||
storageRedThresholdPercent: 10,
|
||||
enableRetentionCleanup: false,
|
||||
retentionDays: 30,
|
||||
retentionDeleteFiles: false,
|
||||
@@ -168,6 +170,8 @@ const form = reactive<SettingsFormModel>({
|
||||
segmentCompletedScriptPath: "",
|
||||
segmentCompletedScriptContent: "",
|
||||
eventScriptTimeoutSeconds: 60,
|
||||
eventScriptRetryAttempts: 3,
|
||||
eventScriptRetryDelaySeconds: 10,
|
||||
enableEmailNotification: false,
|
||||
emailSmtpHost: "",
|
||||
emailSmtpPort: 587,
|
||||
@@ -375,7 +379,7 @@ const eventScriptEnvironmentExamples = [
|
||||
];
|
||||
|
||||
const eventScriptModeOptions = [
|
||||
{ label: "璺緞", value: "path" },
|
||||
{ label: "路径", value: "path" },
|
||||
{ label: "脚本文本", value: "inline" }
|
||||
];
|
||||
|
||||
@@ -459,12 +463,12 @@ const canSendTestWebhook = computed(() => Boolean(form.webhookUrl.trim()));
|
||||
|
||||
const segmentedExamplePath = computed(() => {
|
||||
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
|
||||
return `Douyin/2026/04/15/主播名?221530_origin_主播名峗直播标题_123456789_00001.${extension}`;
|
||||
return `Douyin/2026/04/15/主播名_221530_origin_主播名_直播标题_123456789_00001.${extension}`;
|
||||
});
|
||||
|
||||
const nestedSegmentedExamplePath = computed(() => {
|
||||
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
|
||||
return `Douyin/origin/2026/04/15/主播名?221530_origin_主播名峗直播标题_123456789/221530_origin_主播名峗直播标题_123456789_00001.${extension}`;
|
||||
return `Douyin/origin/2026/04/15/主播名_221530_origin_主播名_直播标题_123456789/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
@@ -569,7 +573,7 @@ async function sendTestEmail() {
|
||||
emailExceptionBodyTemplateHtml: form.emailExceptionBodyTemplateHtml
|
||||
});
|
||||
|
||||
ElMessage.success("娴嬭瘯閭欢宸插彂閫侊紝璇锋鏌ユ敹浠剁");
|
||||
ElMessage.success("测试邮件已发送,请检查收件箱");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "Failed to send test email."));
|
||||
} finally {
|
||||
@@ -628,7 +632,7 @@ async function runRetentionCleanup() {
|
||||
await startRetentionCleanupTracking(data);
|
||||
ElMessage.success("Retention cleanup background task created.");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "淇濈暀娓呯悊鎵ц澶辫触"));
|
||||
ElMessage.error(getApiErrorMessage(error, "保留清理执行失败"));
|
||||
} finally {
|
||||
runningRetentionCleanup.value = false;
|
||||
}
|
||||
@@ -960,15 +964,15 @@ watch(
|
||||
|
||||
<div class="settings-grid" v-loading="loading">
|
||||
<el-tabs v-model="activeSettingTab" type="border-card" class="settings-tabs">
|
||||
<el-tab-pane label="褰曞埗" name="recording">
|
||||
<el-tab-pane label="录制" name="recording">
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">褰曞埗鍩虹</h3>
|
||||
<h3 class="section-title">录制基础</h3>
|
||||
<p class="section-subtitle">默认画质、输出格式、分段策略、ffmpeg 模板和网络容错等录制基础参数在此集中管理。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="ffmpeg 璺緞">
|
||||
<el-form-item label="ffmpeg 路径">
|
||||
<el-input v-model="form.ffmpegPath" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1095,10 +1099,37 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="绿色水位线:剩余空间高于 (%)">
|
||||
<el-input-number
|
||||
v-model="form.storageGreenThresholdPercent"
|
||||
:min="5"
|
||||
:max="90"
|
||||
:step="5"
|
||||
:disabled="!form.enableStorageGuard"
|
||||
/>
|
||||
<div class="field-hint">高于此比例时正常录制,低于时拒绝新录制</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="红色水位线:剩余空间低于 (%)">
|
||||
<el-input-number
|
||||
v-model="form.storageRedThresholdPercent"
|
||||
:min="1"
|
||||
:max="85"
|
||||
:step="5"
|
||||
:disabled="!form.enableStorageGuard"
|
||||
/>
|
||||
<div class="field-hint">低于此比例时暂停所有录制和转码,仅保留上传</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div class="helper-panel">
|
||||
恢复阈值建议高于暂停阈值,避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。
|
||||
恢复阈值建议高于暂停阈值,避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。<br/>
|
||||
绿色/红色水位线控制三级存储保护:<b>绿色</b>(正常录制) → <b>黄色</b>(拒绝新录制,现有继续转码上传) → <b>红色</b>(暂停所有录制转码,仅上传清盘)。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -1181,13 +1212,13 @@ watch(
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">寮瑰箷褰曞埗</h3>
|
||||
<h3 class="section-title">弹幕录制</h3>
|
||||
<p class="section-subtitle">Control parallel danmaku XML recording, non-chat event capture, and retry / polling pacing.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍚敤寮瑰箷褰曞埗">
|
||||
<el-form-item label="启用弹幕录制">
|
||||
<el-switch v-model="form.enableDanmakuRecording" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1221,11 +1252,11 @@ watch(
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">璺緞妯℃澘</h3>
|
||||
<h3 class="section-title">路径模板</h3>
|
||||
<p class="section-subtitle">Both directory and filename templates support variables. Segmented layouts are fully controlled by the templates themselves.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="鐩綍妯℃澘">
|
||||
<el-form-item label="目录模板">
|
||||
<el-input
|
||||
v-model="form.outputDirectoryTemplate"
|
||||
type="textarea"
|
||||
@@ -1246,8 +1277,8 @@ watch(
|
||||
|
||||
<div class="example-box">
|
||||
<div class="example-box__label">示例输出</div>
|
||||
<div class="monospace example-box__value">榛樿鍒嗘锛歿{ segmentedExamplePath }}</div>
|
||||
<div class="monospace example-box__value">鐩綍妯℃澘鍚?{fileStem}锛歿{ nestedSegmentedExamplePath }}</div>
|
||||
<div class="monospace example-box__value">默认分段:{{ segmentedExamplePath }}</div>
|
||||
<div class="monospace example-box__value">目录模板含 {fileStem}:{{ nestedSegmentedExamplePath }}</div>
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
@@ -1302,7 +1333,7 @@ watch(
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鍚敤涓婁紶">
|
||||
<el-form-item label="启用上传">
|
||||
<el-switch v-model="form.enableFileUpload" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1317,12 +1348,12 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鍒悕鐢ㄤ簬鐩綍">
|
||||
<el-form-item label="别名用于目录">
|
||||
<el-switch v-model="form.useAliasForStorage" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="涓婁紶鐩爣">
|
||||
<el-form-item label="上传目标">
|
||||
<el-select v-model="form.uploadTarget" :disabled="!form.enableFileUpload">
|
||||
<el-option
|
||||
v-for="option in uploadTargetOptions"
|
||||
@@ -1339,7 +1370,7 @@ watch(
|
||||
<div v-if="form.enableFileUpload && form.uploadTarget === 1" class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">WebDAV 鐩爣</h4>
|
||||
<h4 class="template-section__title">WebDAV 目标</h4>
|
||||
<p class="template-section__subtitle">Create remote directories from recording-relative paths and upload the video plus danmaku files.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1352,7 +1383,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍩虹璺緞">
|
||||
<el-form-item label="基础路径">
|
||||
<el-input v-model="form.webDavUpload.basePath" placeholder="/live-recorder" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1373,7 +1404,7 @@ watch(
|
||||
<div v-if="form.enableFileUpload && form.uploadTarget === 2" class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">S3 鐩爣</h4>
|
||||
<h4 class="template-section__title">S3 目标</h4>
|
||||
<p class="template-section__subtitle">Supports custom endpoint, bucket, region, and prefix settings for object-storage compatible services.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1420,7 +1451,8 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
鑷姩涓婁紶鍥哄畾澶勭悊鈥滆棰戞枃浠?+ 瀵瑰簲寮瑰箷 XML鈥濄€傚彧鏈変袱鑰呴兘涓婁紶鎴愬姛骞朵笖浣犳墦寮€鈥滀笂浼犲悗鍒犳湰鍦扳€濇椂锛岀郴缁熸墠浼氭竻鐞嗘湰鍦版枃浠躲€? </div>
|
||||
自动上传固定处理“视频文件 + 对应弹幕 XML”。只有两者都上传成功并且你打开“上传后删本地”时,系统才会清理本地文件。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
@@ -1480,16 +1512,26 @@ watch(
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="启用事件脚本">
|
||||
<el-switch v-model="form.enableEventScripts" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="脚本超时(秒)">
|
||||
<el-input-number v-model="form.eventScriptTimeoutSeconds" :min="1" :max="3600" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-form-item label="重试次数">
|
||||
<el-input-number v-model="form.eventScriptRetryAttempts" :min="0" :max="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="重试间隔(秒)">
|
||||
<el-input-number v-model="form.eventScriptRetryDelaySeconds" :min="0" :max="3600" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
@@ -1522,7 +1564,7 @@ watch(
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item v-if="form.liveStartedScriptMode === 'path'" label="鑴氭湰璺緞">
|
||||
<el-form-item v-if="form.liveStartedScriptMode === 'path'" label="脚本路径">
|
||||
<el-input v-model="form.liveStartedScriptPath" placeholder="/app/scripts/live-started.sh" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="脚本文本">
|
||||
@@ -1576,7 +1618,7 @@ watch(
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item v-if="form.liveEndedScriptMode === 'path'" label="鑴氭湰璺緞">
|
||||
<el-form-item v-if="form.liveEndedScriptMode === 'path'" label="脚本路径">
|
||||
<el-input v-model="form.liveEndedScriptPath" placeholder="/app/scripts/live-ended.sh" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="脚本文本">
|
||||
@@ -1597,7 +1639,7 @@ watch(
|
||||
{{ scriptTestResults.live_ended?.detail }}
|
||||
</div>
|
||||
<div v-if="scriptTestResults.live_ended?.customLogOutput" class="test-result__detail">
|
||||
鑷畾涔夋棩蹇楄緭鍑猴細{{ scriptTestResults.live_ended?.customLogOutput }}
|
||||
自定义日志输出:{{ scriptTestResults.live_ended?.customLogOutput }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1651,7 +1693,7 @@ watch(
|
||||
{{ scriptTestResults.segment_completed?.detail }}
|
||||
</div>
|
||||
<div v-if="scriptTestResults.segment_completed?.customLogOutput" class="test-result__detail">
|
||||
鑷畾涔夋棩蹇楄緭鍑猴細{{ scriptTestResults.segment_completed?.customLogOutput }}
|
||||
自定义日志输出:{{ scriptTestResults.segment_completed?.customLogOutput }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1676,6 +1718,10 @@ watch(
|
||||
The official Docker image includes <code>curl</code> and <code>jq</code> by default. If you run on a host machine or a custom image, rely on the commands available in that environment.
|
||||
</div>
|
||||
|
||||
<div class="event-script-help__intro">
|
||||
Set <code>Retry attempts</code> to <code>0</code> to disable automatic retries. Retry exhaustion failures are sent through the existing exception notification channel.
|
||||
</div>
|
||||
|
||||
<div class="event-script-example">
|
||||
<div class="event-script-example__label">Environment variable examples</div>
|
||||
<div class="event-script-example__grid">
|
||||
@@ -1695,21 +1741,21 @@ watch(
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="閫氱煡" name="notifications">
|
||||
<el-tab-pane label="通知" name="notifications">
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Webhook 閫氱煡</h3>
|
||||
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Currently only live-started and exception events are sent.</p>
|
||||
<h3 class="section-title">Webhook 通知</h3>
|
||||
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鍚敤 Webhook">
|
||||
<el-form-item label="启用 Webhook">
|
||||
<el-switch v-model="form.enableWebhookNotification" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="寮€鎾€氱煡">
|
||||
<el-form-item label="开播通知">
|
||||
<el-switch v-model="form.notifyWebhookOnLiveStarted" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1731,7 +1777,7 @@ watch(
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24">
|
||||
<el-form-item label="鑷畾涔夎姹傚ご">
|
||||
<el-form-item label="自定义请求头">
|
||||
<el-input
|
||||
v-model="form.webhookHeaders"
|
||||
type="textarea"
|
||||
@@ -1741,7 +1787,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="鑷畾涔?JSON Body 妯℃澘">
|
||||
<el-form-item label="自定义 JSON Body 模板">
|
||||
<el-input
|
||||
v-model="form.webhookBodyTemplate"
|
||||
type="textarea"
|
||||
@@ -1771,18 +1817,18 @@ watch(
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">閭欢閫氱煡</h3>
|
||||
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Tests use the current unsaved form values directly.</p>
|
||||
<h3 class="section-title">邮件通知</h3>
|
||||
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鍚敤閭欢閫氱煡">
|
||||
<el-form-item label="启用邮件通知">
|
||||
<el-switch v-model="form.enableEmailNotification" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鍚敤 SSL">
|
||||
<el-form-item label="启用 SSL">
|
||||
<el-switch v-model="form.emailUseSsl" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1835,7 +1881,7 @@ watch(
|
||||
v-model="form.emailToAddresses"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="澶氫釜鍦板潃鍙敤閫楀彿銆佸垎鍙锋垨鎹㈣鍒嗛殧"
|
||||
placeholder="多个地址可用逗号、分号或换行分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1849,11 +1895,11 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="涓婚妯℃澘">
|
||||
<el-form-item label="主题模板">
|
||||
<el-input v-model="form.emailLiveStartedSubjectTemplate" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="HTML 姝f枃妯℃澘">
|
||||
<el-form-item label="HTML 正文模板">
|
||||
<el-input v-model="form.emailLiveStartedBodyTemplateHtml" type="textarea" :rows="10" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1863,16 +1909,16 @@ watch(
|
||||
<div class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">异常提醒妯℃澘</h4>
|
||||
<h4 class="template-section__title">异常提醒模板</h4>
|
||||
<p class="template-section__subtitle">Exception emails inject source, summary, detail, task context values, and optional event script output into the HTML body.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="涓婚妯℃澘">
|
||||
<el-form-item label="主题模板">
|
||||
<el-input v-model="form.emailExceptionSubjectTemplate" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="HTML 姝f枃妯℃澘">
|
||||
<el-form-item label="HTML 正文模板">
|
||||
<el-input v-model="form.emailExceptionBodyTemplateHtml" type="textarea" :rows="12" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1888,11 +1934,12 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
閭欢妯℃澘閲岀殑 <code v-pre>{{detectedAtUtc}}</code> 鍜?<code v-pre>{{occurredAtUtc}}</code> 瀛楁鍚嶄繚鎸佷笉鍙橈紝浣嗗疄闄呮覆鏌撳€煎凡缁忕粺涓€鏀规垚鍖椾含鏃堕棿锛圲TC+8锛夈€? <code v-pre>{{eventScriptOutput}}</code> 鍒欏搴旇剼鏈€氳繃鑷畾涔夋棩蹇楁枃浠惰緭鍑虹殑鏂囨湰鍐呭銆? </div>
|
||||
邮件模板里的 <code v-pre>{{detectedAtUtc}}</code> 和 <code v-pre>{{occurredAtUtc}}</code> 字段名保持不变,但实际渲染值已经统一改成北京时间(UTC+8)。<code v-pre>{{eventScriptOutput}}</code> 则对应脚本通过自定义日志文件输出的文本内容。
|
||||
</div>
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="helper-text">Sending a test email does not save settings. The email renders both the live-started and exception template examples.</div>
|
||||
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">娴嬭瘯閭欢</el-button>
|
||||
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试邮件</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -1994,7 +2041,7 @@ watch(
|
||||
v-model="form.douyinCookie"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="鍙矘璐?ttwid銆乵sToken 绛?Cookie"
|
||||
placeholder="可粘贴 ttwid、msToken 等 Cookie"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -14,6 +14,7 @@ export default defineConfig(({ command }) => ({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: "es2015",
|
||||
chunkSizeWarningLimit: 900,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
@@ -25,6 +25,10 @@ public interface ILiveRoomRepository
|
||||
|
||||
Task<IReadOnlyList<LiveRoom>> ListAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByAvailabilityAsync(LiveRoomAvailabilityStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(LiveRoom liveRoom);
|
||||
@@ -42,6 +46,10 @@ public interface IRecordTaskRepository
|
||||
|
||||
Task<RecordTask?> GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<double> SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByStatusAsync(RecordTaskStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(RecordTask recordTask);
|
||||
@@ -59,6 +67,14 @@ public interface IRecordSessionRepository
|
||||
|
||||
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByStatusAsync(RecordSessionStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountActiveAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListRecentAsync(int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListInDateRangeAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(RecordSession recordSession);
|
||||
@@ -68,6 +84,12 @@ public interface IRecordResultRepository
|
||||
{
|
||||
Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(long TotalBytes, int TotalDanmaku)> GetTodayAggregateAsync(DateTimeOffset createdFrom, DateTimeOffset createdTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountPendingUploadAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
|
||||
|
||||
void Update(RecordResult recordResult);
|
||||
@@ -100,6 +122,8 @@ public interface ISystemLogRepository
|
||||
int take = 200,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountRecentErrorsAsync(DateTimeOffset since, CancellationToken cancellationToken = default);
|
||||
|
||||
void RemoveRange(IEnumerable<SystemLogEntry> entries);
|
||||
|
||||
Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Service for reading and parsing danmaku XML files produced by the recording system.
|
||||
/// </summary>
|
||||
public interface IDanmakuService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns parsed danmaku events for a single recording task (one segment).
|
||||
/// Returns null if the task does not exist or has no danmaku file.
|
||||
/// </summary>
|
||||
Task<DanmakuResponseDto?> GetTaskDanmakuAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregated danmaku events for all tasks in a recording session.
|
||||
/// Returns null if the session does not exist or has no tasks with danmaku.
|
||||
/// Offsets for segments beyond the first are adjusted so they are relative to the session start.
|
||||
/// </summary>
|
||||
Task<SessionDanmakuResponseDto?> GetSessionDanmakuAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Service for extracting video metadata and generating thumbnails using ffmpeg/ffprobe.
|
||||
/// </summary>
|
||||
public interface IVideoMetadataService
|
||||
{
|
||||
Task<VideoMetadata?> ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<string?> GenerateThumbnailAsync(string filePath, string outputDir, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record VideoMetadata(
|
||||
double? DurationSeconds,
|
||||
int? Width,
|
||||
int? Height,
|
||||
string? VideoCodec,
|
||||
string? AudioCodec,
|
||||
double? FrameRate,
|
||||
long? BitRate);
|
||||
|
||||
public sealed record VideoMetadataDto(
|
||||
double? DurationSeconds,
|
||||
int? Width,
|
||||
int? Height,
|
||||
string? VideoCodec,
|
||||
string? AudioCodec,
|
||||
double? FrameRate,
|
||||
long? BitRate);
|
||||
@@ -2,6 +2,18 @@ using LiveRecorder.Application.Models.Settings;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Storage;
|
||||
|
||||
public enum StorageTier
|
||||
{
|
||||
/// <summary>Disk has sufficient free space for normal operation.</summary>
|
||||
Green = 0,
|
||||
|
||||
/// <summary>Disk space is low. Deny new recordings but allow existing to finish.</summary>
|
||||
Yellow = 1,
|
||||
|
||||
/// <summary>Disk space is critically low. Deny new recordings and pause active ones.</summary>
|
||||
Red = 2
|
||||
}
|
||||
|
||||
public interface IStorageGuardService
|
||||
{
|
||||
StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0);
|
||||
@@ -15,5 +27,27 @@ public sealed record StorageGuardResult(
|
||||
string CheckedPath,
|
||||
long AvailableBytes,
|
||||
long RequiredBytes,
|
||||
string Message);
|
||||
string Message)
|
||||
{
|
||||
/// <summary>
|
||||
/// Current storage tier (Green/Yellow/Red).
|
||||
/// </summary>
|
||||
public StorageTier Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disk usage percentage (0-100). Only populated when IsEnabled is true.
|
||||
/// </summary>
|
||||
public double UsagePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if new recordings can be started. Only true in Green tier.
|
||||
/// This replaces the old binary HasEnoughSpace check — the tier system is the single source of truth.
|
||||
/// </summary>
|
||||
public bool CanStartNewRecording => Tier == StorageTier.Green;
|
||||
|
||||
/// <summary>
|
||||
/// True if active recordings should be paused. Only true in Red tier.
|
||||
/// This replaces the old MB-based CheckShouldPause — consolidated into the tier system.
|
||||
/// </summary>
|
||||
public bool ShouldPauseActive => Tier == StorageTier.Red;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ public sealed class MediaBrowserItemDto
|
||||
public bool CanTranscode { get; init; }
|
||||
|
||||
public bool CanPreview { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Video metadata (only populated when includeMetadata is requested and item is a video file).
|
||||
/// </summary>
|
||||
public Abstractions.Recording.VideoMetadataDto? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Thumbnail URL relative path (only populated when includeMetadata is requested and item is a video file).
|
||||
/// </summary>
|
||||
public string? ThumbnailUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MediaBrowserResponseDto
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Bandwidth summary statistics.
|
||||
/// </summary>
|
||||
public sealed class BandwidthSummaryDto
|
||||
{
|
||||
public double TotalTrafficMB { get; init; }
|
||||
public double AverageBitrateKbps { get; init; }
|
||||
public double PeakBitrateKbps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bandwidth timeline for a recording session.
|
||||
/// </summary>
|
||||
public sealed class BandwidthTimelineDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
public required IReadOnlyList<BandwidthPointDto> Points { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single bandwidth sample point in time.
|
||||
/// </summary>
|
||||
public sealed class BandwidthPointDto
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public long BytesDownloaded { get; init; }
|
||||
public double? BitrateKbps { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
/// <summary>
|
||||
/// A single parsed danmaku event (chat message or non-chat event like gift/like/member/enter).
|
||||
/// </summary>
|
||||
public sealed class DanmakuEventDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Offset in seconds from the segment's video start time.
|
||||
/// </summary>
|
||||
public double OffsetSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type: "chat", "gift", "like", "member", "enter", "superchat", "live", "preparing", or platform-specific types.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For chat: the message text. For non-chat events: a descriptive label (e.g., "gift: rose x1").
|
||||
/// </summary>
|
||||
public required string Content { get; init; }
|
||||
|
||||
public string? User { get; init; }
|
||||
|
||||
public string? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color string for chat messages (e.g., "FFFFFF"). Only meaningful for chat events.
|
||||
/// </summary>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Font size for chat messages (e.g., 25). Only meaningful for chat events.
|
||||
/// </summary>
|
||||
public int? FontSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display mode for chat messages (1 = scroll right-to-left). Only meaningful for chat events.
|
||||
/// </summary>
|
||||
public int? Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unix timestamp in milliseconds when the event occurred (from the platform or recorded time).
|
||||
/// </summary>
|
||||
public long? TimestampMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For gift events: the gift name (e.g., "rose").
|
||||
/// </summary>
|
||||
public string? GiftName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For gift/like events: the repeat count.
|
||||
/// </summary>
|
||||
public int? Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Truncated raw payload from the platform (for debugging).
|
||||
/// </summary>
|
||||
public string? Raw { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Danmaku response for a single recording task (one segment).
|
||||
/// </summary>
|
||||
public sealed class DanmakuResponseDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
|
||||
public int SegmentIndex { get; init; }
|
||||
|
||||
public string? Platform { get; init; }
|
||||
|
||||
public string? RoomId { get; init; }
|
||||
|
||||
public string? LiveRoomId { get; init; }
|
||||
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The UTC time when this segment's recording started (anchor for offset calculation).
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
public required IReadOnlyList<DanmakuEventDto> Events { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated danmaku response for an entire recording session (all segments).
|
||||
/// </summary>
|
||||
public sealed class SessionDanmakuResponseDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
public required IReadOnlyList<DanmakuResponseDto> Tasks { get; init; }
|
||||
}
|
||||
@@ -166,3 +166,23 @@ public sealed class RecordSessionDeletionBatchResult
|
||||
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionPlaylistDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
public string LiveRoomTitle { get; init; } = string.Empty;
|
||||
|
||||
public required IReadOnlyList<SessionPlaylistSegmentDto> Segments { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionPlaylistSegmentDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
|
||||
public int SegmentIndex { get; init; }
|
||||
|
||||
public string PreviewTicketUrl { get; init; } = string.Empty;
|
||||
|
||||
public double? DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ public sealed class DeleteCompletedRecordTasksRequest
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeleteMissingFileRecordTasksRequest
|
||||
{
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeleteCompletedRecordTasksResultDto
|
||||
{
|
||||
public required IReadOnlyList<Guid> DeletedTaskIds { get; init; }
|
||||
|
||||
@@ -24,6 +24,12 @@ public sealed class StorageGuardStatusDto
|
||||
public long RequiredBytes { get; init; }
|
||||
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Storage tier: Green, Yellow, or Red.</summary>
|
||||
public string Tier { get; init; } = "Green";
|
||||
|
||||
/// <summary>Disk usage percentage (0-100).</summary>
|
||||
public double UsagePercent { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecoverableLiveRoomDto
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace LiveRecorder.Application.Models.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time system dashboard overview DTO.
|
||||
/// </summary>
|
||||
public sealed class DashboardDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of sessions currently recording (Running status).
|
||||
/// </summary>
|
||||
public int ActiveRecordingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of live rooms currently live.
|
||||
/// </summary>
|
||||
public int LiveRoomCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of live rooms currently offline.
|
||||
/// </summary>
|
||||
public int OfflineRoomCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of live rooms in the system.
|
||||
/// </summary>
|
||||
public int TotalRoomCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total recording duration in seconds for sessions started today (Beijing time).
|
||||
/// </summary>
|
||||
public double TodayRecordingSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total data recorded today in bytes (sum of FileSizeBytes).
|
||||
/// </summary>
|
||||
public long TodayDataBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total danmaku events recorded today.
|
||||
/// </summary>
|
||||
public int TodayDanmakuCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of sessions with Starting or Running status.
|
||||
/// </summary>
|
||||
public int ActiveSessionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of Error-level system logs in the last 24 hours.
|
||||
/// </summary>
|
||||
public int RecentErrorCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current storage guard status.
|
||||
/// </summary>
|
||||
public StorageStatusDto StorageStatus { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Most recent active/completed sessions (up to 5).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RecentSessionItemDto> RecentSessions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top live rooms by recording duration today (up to 5).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<TopRoomItemDto> TopRooms { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of recording tasks currently in Processing (transcoding) status.
|
||||
/// </summary>
|
||||
public int PendingTranscodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of recording results with NotUploaded status where local file still exists.
|
||||
/// </summary>
|
||||
public int PendingUploadCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total file size in bytes of files awaiting upload.
|
||||
/// </summary>
|
||||
public long QueuedDataBytes { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StorageStatusDto
|
||||
{
|
||||
public bool HasEnoughSpace { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public long AvailableBytes { get; init; }
|
||||
public string Tier { get; init; } = "Green";
|
||||
public double UsagePercent { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecentSessionItemDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid LiveRoomId { get; init; }
|
||||
public string LiveRoomTitle { get; init; } = string.Empty;
|
||||
public string PlatformName { get; init; } = string.Empty;
|
||||
public int SegmentCount { get; init; }
|
||||
public int Status { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public double? DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TopRoomItemDto
|
||||
{
|
||||
public Guid LiveRoomId { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? AnchorName { get; init; }
|
||||
public string PlatformName { get; init; } = string.Empty;
|
||||
public string RoomId { get; init; } = string.Empty;
|
||||
public int SessionCount { get; init; }
|
||||
public double TotalDurationSeconds { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace LiveRecorder.Application.Models.Reports;
|
||||
|
||||
public sealed class HealthReadyResponse
|
||||
{
|
||||
public string Status { get; set; } = "ready";
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public DatabaseHealthStatus Database { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class DatabaseHealthStatus
|
||||
{
|
||||
public bool Reachable { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
}
|
||||
@@ -112,6 +112,12 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public int ResumeRecordingWhenFreeSpaceAboveMegabytes { get; set; } = 4096;
|
||||
|
||||
/// <summary>Disk free percentage above which storage is considered healthy (Green tier). Default 30%.</summary>
|
||||
public double StorageGreenThresholdPercent { get; set; } = 30;
|
||||
|
||||
/// <summary>Disk free percentage below which storage is critical (Red tier). Default 10%.</summary>
|
||||
public double StorageRedThresholdPercent { get; set; } = 10;
|
||||
|
||||
public bool EnableAutoReconnect { get; set; } = true;
|
||||
|
||||
public int ReconnectDelayMaxSeconds { get; set; } = 5;
|
||||
@@ -177,6 +183,10 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
||||
|
||||
public int EventScriptRetryAttempts { get; set; } = 3;
|
||||
|
||||
public int EventScriptRetryDelaySeconds { get; set; } = 10;
|
||||
|
||||
public bool EnableRetentionCleanup { get; set; } = false;
|
||||
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
@@ -381,6 +391,12 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public int ResumeRecordingWhenFreeSpaceAboveMegabytes { get; set; } = 4096;
|
||||
|
||||
/// <summary>Disk free percentage above which storage is considered healthy (Green tier). Default 30%.</summary>
|
||||
public double StorageGreenThresholdPercent { get; set; } = 30;
|
||||
|
||||
/// <summary>Disk free percentage below which storage is critical (Red tier). Default 10%.</summary>
|
||||
public double StorageRedThresholdPercent { get; set; } = 10;
|
||||
|
||||
public bool EnableAutoReconnect { get; set; } = true;
|
||||
|
||||
public int ReconnectDelayMaxSeconds { get; set; } = 5;
|
||||
@@ -446,6 +462,10 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
||||
|
||||
public int EventScriptRetryAttempts { get; set; } = 3;
|
||||
|
||||
public int EventScriptRetryDelaySeconds { get; set; } = 10;
|
||||
|
||||
public bool EnableRetentionCleanup { get; set; } = false;
|
||||
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Application.Services;
|
||||
|
||||
public sealed class BandwidthStatisticsService
|
||||
{
|
||||
private const string BandwidthCategory = "Bandwidth";
|
||||
private const string SampleMessage = "bandwidth_sample";
|
||||
|
||||
private readonly ISystemLogRepository _systemLogRepository;
|
||||
|
||||
public BandwidthStatisticsService(ISystemLogRepository systemLogRepository)
|
||||
{
|
||||
_systemLogRepository = systemLogRepository;
|
||||
}
|
||||
|
||||
public async Task<BandwidthTimelineDto?> GetSessionTimelineAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Query all bandwidth sample log entries for this session
|
||||
// Since there's no dedicated method, we use ListAsync with category filter
|
||||
var allLogs = await _systemLogRepository.ListAllAsync(cancellationToken);
|
||||
var bandwidthLogs = allLogs
|
||||
.Where(item => item.Category == BandwidthCategory
|
||||
&& item.Message == SampleMessage
|
||||
&& item.RecordSessionId == recordSessionId)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
if (bandwidthLogs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var points = new List<BandwidthPointDto>(bandwidthLogs.Count);
|
||||
foreach (var entry in bandwidthLogs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Detail))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(entry.Detail);
|
||||
long bytesDownloaded = 0;
|
||||
double? bitrateKbps = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("bytesDownloaded", out var bd) && bd.TryGetInt64(out var bv))
|
||||
bytesDownloaded = bv;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("bitrateKbps", out var br) && br.TryGetDouble(out var bkv))
|
||||
bitrateKbps = bkv;
|
||||
|
||||
points.Add(new BandwidthPointDto
|
||||
{
|
||||
Timestamp = entry.CreatedAt,
|
||||
BytesDownloaded = bytesDownloaded,
|
||||
BitrateKbps = bitrateKbps
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip malformed entries
|
||||
}
|
||||
}
|
||||
|
||||
return new BandwidthTimelineDto
|
||||
{
|
||||
RecordSessionId = recordSessionId,
|
||||
Points = points
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BandwidthSummaryDto?> GetDailySummaryAsync(DateOnly date, int utcOffsetMinutes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var windowStartUtc = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.FromMinutes(utcOffsetMinutes));
|
||||
var windowEndUtc = new DateTimeOffset(date.ToDateTime(TimeOnly.MaxValue), TimeSpan.FromMinutes(utcOffsetMinutes));
|
||||
|
||||
var allLogs = await _systemLogRepository.ListAllAsync(cancellationToken);
|
||||
var bandwidthLogs = allLogs
|
||||
.Where(item => item.Category == BandwidthCategory
|
||||
&& item.Message == SampleMessage
|
||||
&& item.CreatedAt >= windowStartUtc
|
||||
&& item.CreatedAt <= windowEndUtc)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
if (bandwidthLogs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bitrates = new List<double>();
|
||||
long maxBytes = 0;
|
||||
long finalBytes = 0;
|
||||
|
||||
foreach (var entry in bandwidthLogs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Detail))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(entry.Detail);
|
||||
if (doc.RootElement.TryGetProperty("bytesDownloaded", out var bd) && bd.TryGetInt64(out var bv))
|
||||
{
|
||||
if (bv > maxBytes) maxBytes = bv;
|
||||
finalBytes = bv;
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("bitrateKbps", out var br) && br.TryGetDouble(out var bkv))
|
||||
{
|
||||
bitrates.Add(bkv);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
var avgBitrate = bitrates.Count > 0 ? bitrates.Average() : 0;
|
||||
var peakBitrate = bitrates.Count > 0 ? bitrates.Max() : 0;
|
||||
|
||||
return new BandwidthSummaryDto
|
||||
{
|
||||
TotalTrafficMB = finalBytes / (1024.0 * 1024.0),
|
||||
AverageBitrateKbps = avgBitrate,
|
||||
PeakBitrateKbps = peakBitrate
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Abstractions.Storage;
|
||||
using LiveRecorder.Application.Models.Reports;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Application.Services;
|
||||
|
||||
public sealed class DashboardService
|
||||
{
|
||||
private readonly ILiveRoomRepository _liveRoomRepository;
|
||||
private readonly IRecordSessionRepository _recordSessionRepository;
|
||||
private readonly IRecordTaskRepository _recordTaskRepository;
|
||||
private readonly IRecordResultRepository _recordResultRepository;
|
||||
private readonly ISystemLogRepository _systemLogRepository;
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly IStorageGuardService _storageGuardService;
|
||||
|
||||
public DashboardService(
|
||||
ILiveRoomRepository liveRoomRepository,
|
||||
IRecordSessionRepository recordSessionRepository,
|
||||
IRecordTaskRepository recordTaskRepository,
|
||||
IRecordResultRepository recordResultRepository,
|
||||
ISystemLogRepository systemLogRepository,
|
||||
ISystemSettingsService systemSettingsService,
|
||||
IStorageGuardService storageGuardService)
|
||||
{
|
||||
_liveRoomRepository = liveRoomRepository;
|
||||
_recordSessionRepository = recordSessionRepository;
|
||||
_recordTaskRepository = recordTaskRepository;
|
||||
_recordResultRepository = recordResultRepository;
|
||||
_systemLogRepository = systemLogRepository;
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_storageGuardService = storageGuardService;
|
||||
}
|
||||
|
||||
public async Task<DashboardDto> GetDashboardAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var beijingNow = ChinaTime.ToBeijingTime(now);
|
||||
var todayBeijingDate = DateOnly.FromDateTime(beijingNow.DateTime);
|
||||
var todayUtcStart = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MinValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime));
|
||||
var todayUtcEnd = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MaxValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime));
|
||||
var recentErrorSince = now.AddHours(-24);
|
||||
|
||||
// Run queries sequentially — DbContext is not thread-safe
|
||||
var activeRecordingCount = await _recordSessionRepository.CountByStatusAsync(RecordSessionStatus.Running, cancellationToken);
|
||||
var liveRoomCount = await _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Live, cancellationToken);
|
||||
var offlineRoomCount = await _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Offline, cancellationToken);
|
||||
var totalRoomCount = await _liveRoomRepository.CountAsync(cancellationToken);
|
||||
var activeSessionCount = await _recordSessionRepository.CountActiveAsync(cancellationToken);
|
||||
var recentErrorCount = await _systemLogRepository.CountRecentErrorsAsync(recentErrorSince, cancellationToken);
|
||||
var todayRecordingSeconds = await _recordTaskRepository.SumDurationSecondsAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||
var (todayTotalBytes, todayTotalDanmaku) = await _recordResultRepository.GetTodayAggregateAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||
var recentSessions = await _recordSessionRepository.ListRecentAsync(5, cancellationToken);
|
||||
var todaySessions = await _recordSessionRepository.ListInDateRangeAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
var pendingTranscodeCount = await _recordTaskRepository.CountByStatusAsync(RecordTaskStatus.Processing, cancellationToken);
|
||||
var pendingUploadCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken);
|
||||
var queuedDataBytes = await _recordResultRepository.SumPendingUploadBytesAsync(cancellationToken);
|
||||
|
||||
return new DashboardDto
|
||||
{
|
||||
ActiveRecordingCount = activeRecordingCount,
|
||||
LiveRoomCount = liveRoomCount,
|
||||
OfflineRoomCount = offlineRoomCount,
|
||||
TotalRoomCount = totalRoomCount,
|
||||
TodayRecordingSeconds = todayRecordingSeconds,
|
||||
TodayDataBytes = todayTotalBytes,
|
||||
TodayDanmakuCount = todayTotalDanmaku,
|
||||
ActiveSessionCount = activeSessionCount,
|
||||
RecentErrorCount = recentErrorCount,
|
||||
StorageStatus = new StorageStatusDto
|
||||
{
|
||||
HasEnoughSpace = storageCheck.HasEnoughSpace,
|
||||
Message = storageCheck.Message ?? string.Empty,
|
||||
AvailableBytes = storageCheck.AvailableBytes,
|
||||
Tier = storageCheck.Tier.ToString(),
|
||||
UsagePercent = storageCheck.UsagePercent
|
||||
},
|
||||
PendingTranscodeCount = pendingTranscodeCount,
|
||||
PendingUploadCount = pendingUploadCount,
|
||||
QueuedDataBytes = queuedDataBytes,
|
||||
RecentSessions = recentSessions
|
||||
.Select(MapRecentSession)
|
||||
.ToList(),
|
||||
TopRooms = ComputeTopRooms(todaySessions)
|
||||
};
|
||||
}
|
||||
|
||||
private static RecentSessionItemDto MapRecentSession(Domain.Entities.RecordSession session)
|
||||
{
|
||||
var duration = session.RecordTasks
|
||||
.Where(item => item.DurationSeconds.HasValue)
|
||||
.Sum(item => item.DurationSeconds ?? 0);
|
||||
|
||||
return new RecentSessionItemDto
|
||||
{
|
||||
Id = session.Id,
|
||||
LiveRoomId = session.LiveRoomId,
|
||||
LiveRoomTitle = session.LiveRoom?.Title ?? session.LiveRoom?.Alias ?? session.LiveRoom?.AnchorName ?? "-",
|
||||
PlatformName = session.LiveRoom?.Platform.ToString() ?? "-",
|
||||
SegmentCount = session.SegmentCount,
|
||||
Status = (int)session.Status,
|
||||
StartedAt = session.StartedAt ?? session.CreatedAt,
|
||||
DurationSeconds = duration > 0 ? duration : null
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TopRoomItemDto> ComputeTopRooms(IReadOnlyCollection<Domain.Entities.RecordSession> sessions)
|
||||
{
|
||||
return sessions
|
||||
.GroupBy(item => item.LiveRoomId)
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group.First();
|
||||
var totalDuration = group
|
||||
.SelectMany(item => item.RecordTasks)
|
||||
.Sum(item => item.DurationSeconds ?? 0);
|
||||
|
||||
return new TopRoomItemDto
|
||||
{
|
||||
LiveRoomId = group.Key,
|
||||
Title = first.LiveRoom?.Title ?? first.LiveRoom?.Alias ?? first.LiveRoom?.AnchorName,
|
||||
AnchorName = first.LiveRoom?.AnchorName,
|
||||
PlatformName = first.LiveRoom?.Platform.ToString() ?? "-",
|
||||
RoomId = first.LiveRoom?.RoomId ?? "-",
|
||||
SessionCount = group.Count(),
|
||||
TotalDurationSeconds = totalDuration
|
||||
};
|
||||
})
|
||||
.OrderByDescending(item => item.TotalDurationSeconds)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,19 @@ public sealed class MediaBrowserService
|
||||
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly IFfmpegService _ffmpegService;
|
||||
private readonly IVideoMetadataService _videoMetadataService;
|
||||
|
||||
public MediaBrowserService(
|
||||
ISystemSettingsService systemSettingsService,
|
||||
IFfmpegService ffmpegService)
|
||||
IFfmpegService ffmpegService,
|
||||
IVideoMetadataService videoMetadataService)
|
||||
{
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_ffmpegService = ffmpegService;
|
||||
_videoMetadataService = videoMetadataService;
|
||||
}
|
||||
|
||||
public async Task<MediaBrowserResponseDto> BrowseAsync(string? relativePath, CancellationToken cancellationToken = default)
|
||||
public async Task<MediaBrowserResponseDto> BrowseAsync(string? relativePath, bool includeMetadata = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var rootPath = ResolveOutputRoot(settings.OutputRoot);
|
||||
@@ -50,28 +53,56 @@ public sealed class MediaBrowserService
|
||||
};
|
||||
});
|
||||
|
||||
var files = Directory
|
||||
.EnumerateFiles(targetPath)
|
||||
.Select(filePath =>
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
var extension = info.Extension.ToLowerInvariant();
|
||||
return new MediaBrowserItemDto
|
||||
{
|
||||
Name = info.Name,
|
||||
RelativePath = Path.GetRelativePath(rootPath, info.FullName).Replace('\\', '/'),
|
||||
Type = ResolveItemType(extension),
|
||||
SizeBytes = info.Length,
|
||||
ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue
|
||||
? null
|
||||
: new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero),
|
||||
CanPreview = PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
|
||||
CanTranscode = TranscodableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
});
|
||||
var items = new List<MediaBrowserItemDto>();
|
||||
items.AddRange(directories);
|
||||
|
||||
var items = directories
|
||||
.Concat(files)
|
||||
foreach (var filePath in Directory.EnumerateFiles(targetPath))
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
var extension = info.Extension.ToLowerInvariant();
|
||||
var absolutePath = info.FullName;
|
||||
VideoMetadataDto? metadata = null;
|
||||
string? thumbnailUrl = null;
|
||||
|
||||
if (includeMetadata && PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var extracted = await _videoMetadataService.ExtractMetadataAsync(absolutePath, cancellationToken);
|
||||
if (extracted is not null)
|
||||
{
|
||||
metadata = new VideoMetadataDto(
|
||||
extracted.DurationSeconds,
|
||||
extracted.Width,
|
||||
extracted.Height,
|
||||
extracted.VideoCodec,
|
||||
extracted.AudioCodec,
|
||||
extracted.FrameRate,
|
||||
extracted.BitRate);
|
||||
}
|
||||
|
||||
var thumb = await _videoMetadataService.GenerateThumbnailAsync(absolutePath, rootPath, cancellationToken);
|
||||
if (thumb is not null)
|
||||
{
|
||||
thumbnailUrl = Path.GetRelativePath(rootPath, thumb).Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(new MediaBrowserItemDto
|
||||
{
|
||||
Name = info.Name,
|
||||
RelativePath = Path.GetRelativePath(rootPath, absolutePath).Replace('\\', '/'),
|
||||
Type = ResolveItemType(extension),
|
||||
SizeBytes = info.Length,
|
||||
ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue
|
||||
? null
|
||||
: new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero),
|
||||
CanPreview = PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
|
||||
CanTranscode = TranscodableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
|
||||
Metadata = metadata,
|
||||
ThumbnailUrl = thumbnailUrl
|
||||
});
|
||||
}
|
||||
|
||||
var sortedItems = items
|
||||
.OrderBy(static item => item.Type != "directory")
|
||||
.ThenBy(static item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
@@ -207,8 +207,17 @@ public sealed class RecordService
|
||||
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (!storageCheck.HasEnoughSpace)
|
||||
if (!storageCheck.CanStartNewRecording)
|
||||
{
|
||||
var storageMessage = storageCheck.Tier switch
|
||||
{
|
||||
Application.Abstractions.Storage.StorageTier.Yellow =>
|
||||
"Storage is in warning state. New recordings are paused but existing recordings continue. Transcoding and uploading will free up space.",
|
||||
Application.Abstractions.Storage.StorageTier.Red =>
|
||||
"Storage is critically low. All recordings are paused until enough disk space is freed.",
|
||||
_ => storageCheck.Message
|
||||
};
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Storage",
|
||||
@@ -222,12 +231,12 @@ public sealed class RecordService
|
||||
await UpdateAutoStartDecisionAsync(
|
||||
liveRoom,
|
||||
AutoStartDecisionCodes.SkippedStorage,
|
||||
"Auto-start skipped because storage is below threshold.",
|
||||
$"Auto-start skipped because storage tier is {storageCheck.Tier}.",
|
||||
storageCheck.Message,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(storageCheck.Message);
|
||||
throw new InvalidOperationException(storageMessage);
|
||||
}
|
||||
|
||||
var effectiveSettings = _liveRoomRecordingSettingsResolver.Resolve(liveRoom, settings);
|
||||
@@ -495,6 +504,32 @@ public sealed class RecordService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DeleteCompletedRecordTasksResultDto> DeleteMissingFileTasksAsync(
|
||||
DeleteMissingFileRecordTasksRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var tasks = await _recordTaskRepository.ListAsync(null, cancellationToken);
|
||||
var missingFileTaskIds = tasks
|
||||
.Where(static task => !IsActiveTaskStatus(task.Status) && !HasExistingVideoFile(task))
|
||||
.Select(static task => task.Id)
|
||||
.ToArray();
|
||||
|
||||
if (missingFileTaskIds.Length == 0)
|
||||
{
|
||||
return CreateEmptyDeleteResult();
|
||||
}
|
||||
|
||||
return await DeleteTasksAsync(
|
||||
new DeleteCompletedRecordTasksRequest
|
||||
{
|
||||
TaskIds = missingFileTaskIds,
|
||||
DeleteFiles = request.DeleteFiles
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordPreviewTicketDto> CreatePreviewTicketAsync(Guid id, string mediaBaseUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken);
|
||||
@@ -661,14 +696,14 @@ public sealed class RecordService
|
||||
}
|
||||
|
||||
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (!storageCheck.HasEnoughSpace)
|
||||
if (!storageCheck.CanStartNewRecording)
|
||||
{
|
||||
foreach (var liveRoomId in liveRoomIds)
|
||||
{
|
||||
await TryUpdateAutoStartDecisionAsync(
|
||||
liveRoomId,
|
||||
AutoStartDecisionCodes.SkippedStorage,
|
||||
"Auto-start skipped because storage is below threshold.",
|
||||
$"Auto-start skipped because storage tier is {storageCheck.Tier}.",
|
||||
storageCheck.Message,
|
||||
cancellationToken);
|
||||
await _systemLogService.WriteAsync(
|
||||
@@ -814,6 +849,24 @@ public sealed class RecordService
|
||||
or RecordTaskStatus.Stopping
|
||||
or RecordTaskStatus.Processing;
|
||||
|
||||
private static bool HasExistingVideoFile(RecordTask task)
|
||||
{
|
||||
var candidatePath = !string.IsNullOrWhiteSpace(task.Result?.FilePath)
|
||||
? task.Result!.FilePath
|
||||
: task.OutputFilePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidatePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var resolvedPath = Path.IsPathRooted(candidatePath)
|
||||
? candidatePath
|
||||
: Path.GetFullPath(candidatePath, AppContext.BaseDirectory);
|
||||
|
||||
return File.Exists(resolvedPath);
|
||||
}
|
||||
|
||||
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
|
||||
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LiveRecorder.Application.Services;
|
||||
@@ -25,6 +26,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
private const string EnableStorageGuardKey = "storage.guard.enabled";
|
||||
private const string PauseRecordingWhenFreeSpaceBelowMegabytesKey = "storage.guard.pause_recording_below_mb";
|
||||
private const string ResumeRecordingWhenFreeSpaceAboveMegabytesKey = "storage.guard.resume_recording_above_mb";
|
||||
private const string StorageGreenThresholdPercentKey = "storage.guard.green_threshold_percent";
|
||||
private const string StorageRedThresholdPercentKey = "storage.guard.red_threshold_percent";
|
||||
private const string EnableReconnectKey = "recording.enable_auto_reconnect";
|
||||
private const string ReconnectDelayMaxSecondsKey = "recording.reconnect_delay_max_seconds";
|
||||
private const string ReadWriteTimeoutMillisecondsKey = "recording.read_write_timeout_milliseconds";
|
||||
@@ -71,6 +74,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
private const string SegmentCompletedScriptPathKey = "event_scripts.segment_completed.path";
|
||||
private const string SegmentCompletedScriptContentKey = "event_scripts.segment_completed.content";
|
||||
private const string EventScriptTimeoutSecondsKey = "event_scripts.timeout_seconds";
|
||||
private const string EventScriptRetryAttemptsKey = "event_scripts.retry_attempts";
|
||||
private const string EventScriptRetryDelaySecondsKey = "event_scripts.retry_delay_seconds";
|
||||
private const string EnableRetentionCleanupKey = "retention.cleanup.enabled";
|
||||
private const string RetentionDaysKey = "retention.cleanup.days";
|
||||
private const string RetentionDeleteFilesKey = "retention.cleanup.delete_files";
|
||||
@@ -138,6 +143,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
EnableStorageGuard = bool.TryParse(GetValue(lookup, EnableStorageGuardKey, "true"), out var enableStorageGuard) && enableStorageGuard,
|
||||
PauseRecordingWhenFreeSpaceBelowMegabytes = GetIntValue(lookup, PauseRecordingWhenFreeSpaceBelowMegabytesKey, 1024, 0, 1048576),
|
||||
ResumeRecordingWhenFreeSpaceAboveMegabytes = GetIntValue(lookup, ResumeRecordingWhenFreeSpaceAboveMegabytesKey, 4096, 0, 1048576),
|
||||
StorageGreenThresholdPercent = GetDoubleValue(lookup, StorageGreenThresholdPercentKey, 30, 5, 90),
|
||||
StorageRedThresholdPercent = GetDoubleValue(lookup, StorageRedThresholdPercentKey, 10, 1, 85),
|
||||
EnableAutoReconnect = bool.TryParse(GetValue(lookup, EnableReconnectKey, "true"), out var enableReconnect) && enableReconnect,
|
||||
ReconnectDelayMaxSeconds = GetIntValue(lookup, ReconnectDelayMaxSecondsKey, 5, 1, 300),
|
||||
ReadWriteTimeoutMilliseconds = GetIntValue(lookup, ReadWriteTimeoutMillisecondsKey, 15000000, 1000, 60000000),
|
||||
@@ -202,6 +209,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
SegmentCompletedScriptPath = GetValue(lookup, SegmentCompletedScriptPathKey, string.Empty),
|
||||
SegmentCompletedScriptContent = GetValue(lookup, SegmentCompletedScriptContentKey, string.Empty),
|
||||
EventScriptTimeoutSeconds = GetIntValue(lookup, EventScriptTimeoutSecondsKey, 60, 1, 3600),
|
||||
EventScriptRetryAttempts = GetIntValue(lookup, EventScriptRetryAttemptsKey, 3, 0, 20),
|
||||
EventScriptRetryDelaySeconds = GetIntValue(lookup, EventScriptRetryDelaySecondsKey, 10, 0, 3600),
|
||||
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,
|
||||
@@ -303,6 +312,16 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
Math.Clamp(request.ResumeRecordingWhenFreeSpaceAboveMegabytes, 0, 1048576).ToString(),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(
|
||||
StorageGreenThresholdPercentKey,
|
||||
Math.Clamp(request.StorageGreenThresholdPercent, 5, 90).ToString("F1", CultureInfo.InvariantCulture),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(
|
||||
StorageRedThresholdPercentKey,
|
||||
Math.Clamp(request.StorageRedThresholdPercent, 1, 85).ToString("F1", CultureInfo.InvariantCulture),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(EnableReconnectKey, request.EnableAutoReconnect.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(ReconnectDelayMaxSecondsKey, request.ReconnectDelayMaxSeconds.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(ReadWriteTimeoutMillisecondsKey, request.ReadWriteTimeoutMilliseconds.ToString(), now, cancellationToken);
|
||||
@@ -352,6 +371,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(SegmentCompletedScriptPathKey, request.SegmentCompletedScriptPath.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(SegmentCompletedScriptContentKey, request.SegmentCompletedScriptContent, now, cancellationToken);
|
||||
await UpsertAsync(EventScriptTimeoutSecondsKey, Math.Clamp(request.EventScriptTimeoutSeconds, 1, 3600).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EventScriptRetryAttemptsKey, Math.Clamp(request.EventScriptRetryAttempts, 0, 20).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EventScriptRetryDelaySecondsKey, Math.Clamp(request.EventScriptRetryDelaySeconds, 0, 3600).ToString(), now, cancellationToken);
|
||||
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);
|
||||
@@ -460,6 +481,22 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
return Math.Clamp(parsedValue, minimum, maximum);
|
||||
}
|
||||
|
||||
private static double GetDoubleValue(
|
||||
IReadOnlyDictionary<string, string> lookup,
|
||||
string key,
|
||||
double fallback,
|
||||
double minimum,
|
||||
double maximum)
|
||||
{
|
||||
var raw = GetValue(lookup, key, fallback.ToString(CultureInfo.InvariantCulture));
|
||||
if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Custom EF Core execution strategy that integrates with <see cref="DatabaseCircuitBreaker"/>.
|
||||
///
|
||||
/// When the circuit is open, operations fail immediately without retrying.
|
||||
/// When a non-transient error occurs (e.g. disk_full), the operation does not retry.
|
||||
/// After each failure, the circuit is recorded, and after each success, the circuit is reset.
|
||||
/// </summary>
|
||||
public sealed class CircuitAwareExecutionStrategy : NpgsqlRetryingExecutionStrategy
|
||||
{
|
||||
private static readonly TimeSpan DefaultMaxRetryDelay = TimeSpan.FromSeconds(15);
|
||||
|
||||
public CircuitAwareExecutionStrategy(
|
||||
ExecutionStrategyDependencies dependencies,
|
||||
int maxRetryCount,
|
||||
TimeSpan maxRetryDelay)
|
||||
: base(dependencies, maxRetryCount, maxRetryDelay, errorCodesToAdd: null)
|
||||
{
|
||||
}
|
||||
|
||||
public CircuitAwareExecutionStrategy(
|
||||
ExecutionStrategyDependencies dependencies,
|
||||
int maxRetryCount,
|
||||
TimeSpan maxRetryDelay,
|
||||
ICollection<string>? errorCodesToAdd)
|
||||
: base(dependencies, maxRetryCount, maxRetryDelay, errorCodesToAdd)
|
||||
{
|
||||
}
|
||||
|
||||
protected override bool ShouldRetryOn(Exception? exception)
|
||||
{
|
||||
// Fast-fail: circuit is open
|
||||
if (DatabaseCircuitBreaker.IsOpen)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fast-fail: non-transient errors (disk_full, out_of_memory, etc.)
|
||||
if (DatabaseCircuitBreaker.IsNonTransient(exception))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delegate to default Npgsql retry logic for transient errors
|
||||
return base.ShouldRetryOn(exception);
|
||||
}
|
||||
|
||||
protected override void OnFirstExecution()
|
||||
{
|
||||
// If circuit is open, throw immediately before even attempting
|
||||
if (DatabaseCircuitBreaker.IsOpen)
|
||||
{
|
||||
throw new DatabaseCircuitOpenException(
|
||||
$"Database circuit breaker is open. Consecutive failures: {DatabaseCircuitBreaker.ConsecutiveFailures}. " +
|
||||
$"Circuit opened at: {DatabaseCircuitBreaker.OpenedAt:O}.");
|
||||
}
|
||||
|
||||
base.OnFirstExecution();
|
||||
}
|
||||
|
||||
public override TResult Execute<TState, TResult>(
|
||||
TState state,
|
||||
Func<DbContext, TState, TResult> operation,
|
||||
Func<DbContext, TState, ExecutionResult<TResult>>? verifySucceeded)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = base.Execute(state, operation, verifySucceeded);
|
||||
DatabaseCircuitBreaker.RecordSuccess();
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<TResult> ExecuteAsync<TState, TResult>(
|
||||
TState state,
|
||||
Func<DbContext, TState, CancellationToken, Task<TResult>> operation,
|
||||
Func<DbContext, TState, CancellationToken, Task<ExecutionResult<TResult>>>? verifySucceeded,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await base.ExecuteAsync(state, operation, verifySucceeded, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
DatabaseCircuitBreaker.RecordSuccess();
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe static circuit breaker for database operations.
|
||||
/// When the database becomes unavailable (e.g. disk full), this prevents
|
||||
/// every API request from wasting 45 seconds on doomed retries.
|
||||
/// </summary>
|
||||
public static class DatabaseCircuitBreaker
|
||||
{
|
||||
private static readonly object Lock = new();
|
||||
|
||||
/// <summary>Consecutive failures before the circuit opens.</summary>
|
||||
private const int FailureThreshold = 5;
|
||||
|
||||
/// <summary>How long the circuit stays open before allowing a probe.</summary>
|
||||
private static readonly TimeSpan BreakDuration = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Npgsql error codes that are NOT transient — retrying is futile.</summary>
|
||||
private static readonly HashSet<string> NonTransientCodes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"53100", // disk_full
|
||||
"53200", // out_of_memory
|
||||
"53300", // too_many_connections
|
||||
"08006", // connection_failure (persistent)
|
||||
"57P03", // cannot_connect_now
|
||||
"42601", // syntax_error (bug, not transient)
|
||||
"42501", // insufficient_privilege
|
||||
"3D000", // invalid_catalog_name
|
||||
"28P01", // invalid_password
|
||||
};
|
||||
|
||||
private static int _consecutiveFailures;
|
||||
private static DateTimeOffset _openedAt = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>Whether the circuit is currently open (failing fast).</summary>
|
||||
public static bool IsOpen
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_consecutiveFailures < FailureThreshold)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.UtcNow - _openedAt > BreakDuration)
|
||||
{
|
||||
// Transition to half-open: allow one probe
|
||||
lock (Lock)
|
||||
{
|
||||
if (_consecutiveFailures >= FailureThreshold &&
|
||||
DateTimeOffset.UtcNow - _openedAt > BreakDuration)
|
||||
{
|
||||
// Reset to just below threshold so the next call probes
|
||||
_consecutiveFailures = FailureThreshold - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static int ConsecutiveFailures => Volatile.Read(ref _consecutiveFailures);
|
||||
|
||||
public static DateTimeOffset OpenedAt => _openedAt;
|
||||
|
||||
/// <summary>Record a successful database operation.</summary>
|
||||
public static void RecordSuccess()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_consecutiveFailures = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Record a failed database operation.</summary>
|
||||
public static void RecordFailure()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_consecutiveFailures++;
|
||||
if (_consecutiveFailures >= FailureThreshold)
|
||||
{
|
||||
_openedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether a given exception is a non-transient database error
|
||||
/// that should NOT be retried. Returns true if retrying would be futile.
|
||||
/// </summary>
|
||||
public static bool IsNonTransient(Exception? ex)
|
||||
{
|
||||
while (ex is not null)
|
||||
{
|
||||
if (ex is Npgsql.NpgsqlException npgEx && npgEx.SqlState is { Length: 5 } state)
|
||||
{
|
||||
return NonTransientCodes.Contains(state);
|
||||
}
|
||||
|
||||
ex = ex.InnerException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Log the current circuit state.</summary>
|
||||
public static void LogState(ILogger logger)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"DatabaseCircuitBreaker state: Open={IsOpen}, ConsecutiveFailures={Failures}, OpenedAt={OpenedAt}",
|
||||
IsOpen,
|
||||
ConsecutiveFailures,
|
||||
OpenedAt == DateTimeOffset.MinValue ? "never" : OpenedAt.ToString("O"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace LiveRecorder.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a database operation is rejected because the circuit breaker is open.
|
||||
/// This is a fast-fail — the request will not be retried.
|
||||
/// </summary>
|
||||
public sealed class DatabaseCircuitOpenException : InvalidOperationException
|
||||
{
|
||||
public DatabaseCircuitOpenException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DatabaseCircuitOpenException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ public sealed class LiveRoomRepository : ILiveRoomRepository
|
||||
.OrderByDescending(static item => item.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
public Task<int> CountAsync(CancellationToken cancellationToken = default) =>
|
||||
_dbContext.LiveRooms.CountAsync(cancellationToken);
|
||||
|
||||
public Task<int> CountByAvailabilityAsync(LiveRoomAvailabilityStatus status, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.LiveRooms.CountAsync(item => item.AvailabilityStatus == status, cancellationToken);
|
||||
|
||||
public Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.LiveRooms.AddAsync(liveRoom, cancellationToken).AsTask();
|
||||
|
||||
@@ -130,6 +136,15 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
|
||||
(item.Status == RecordTaskStatus.Starting || item.Status == RecordTaskStatus.Running),
|
||||
cancellationToken);
|
||||
|
||||
public Task<double> SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordTasks
|
||||
.Include(item => item.RecordSession)
|
||||
.Where(item => item.RecordSession != null && item.RecordSession.StartedAt >= startedFrom && item.RecordSession.StartedAt <= startedTo)
|
||||
.SumAsync(item => item.DurationSeconds ?? 0, cancellationToken);
|
||||
|
||||
public Task<int> CountByStatusAsync(RecordTaskStatus status, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordTasks.CountAsync(item => item.Status == status, cancellationToken);
|
||||
|
||||
public Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask();
|
||||
|
||||
@@ -195,6 +210,32 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
|
||||
item.Status == RecordSessionStatus.Stopping),
|
||||
cancellationToken);
|
||||
|
||||
public Task<int> CountByStatusAsync(RecordSessionStatus status, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordSessions.CountAsync(item => item.Status == status, cancellationToken);
|
||||
|
||||
public Task<int> CountActiveAsync(CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordSessions.CountAsync(item =>
|
||||
item.Status == RecordSessionStatus.Starting || item.Status == RecordSessionStatus.Running, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<RecordSession>> ListRecentAsync(int take, CancellationToken cancellationToken = default) =>
|
||||
await _dbContext.RecordSessions
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
|
||||
.ThenInclude(item => item.Result)
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(take, 1, 50))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<RecordSession>> ListInDateRangeAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default) =>
|
||||
await _dbContext.RecordSessions
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
|
||||
.ThenInclude(item => item.Result)
|
||||
.AsNoTracking()
|
||||
.Where(item => item.StartedAt >= startedFrom && item.StartedAt <= startedTo)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordSessions.AddAsync(recordSession, cancellationToken).AsTask();
|
||||
|
||||
@@ -213,6 +254,29 @@ public sealed class RecordResultRepository : IRecordResultRepository
|
||||
public Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults.FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken);
|
||||
|
||||
public async Task<(long TotalBytes, int TotalDanmaku)> GetTodayAggregateAsync(DateTimeOffset createdFrom, DateTimeOffset createdTo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _dbContext.RecordResults
|
||||
.Where(item => item.CreatedAt >= createdFrom && item.CreatedAt <= createdTo)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
TotalBytes = g.Sum(item => item.FileSizeBytes ?? 0L),
|
||||
TotalDanmaku = g.Sum(item => item.DanmakuMessageCount)
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return (result?.TotalBytes ?? 0L, result?.TotalDanmaku ?? 0);
|
||||
}
|
||||
|
||||
public Task<int> CountPendingUploadAsync(CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults.CountAsync(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded, cancellationToken);
|
||||
|
||||
public Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults
|
||||
.Where(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded)
|
||||
.SumAsync(item => item.FileSizeBytes ?? 0L, cancellationToken);
|
||||
|
||||
public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask();
|
||||
|
||||
@@ -310,6 +374,9 @@ public sealed class SystemLogRepository : ISystemLogRepository
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> CountRecentErrorsAsync(DateTimeOffset since, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.SystemLogEntries.CountAsync(item => item.Level == SystemLogLevel.Error && item.CreatedAt >= since, cancellationToken);
|
||||
|
||||
public void RemoveRange(IEnumerable<SystemLogEntry> entries) => _dbContext.SystemLogEntries.RemoveRange(entries);
|
||||
|
||||
public async Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -7,6 +8,8 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
public sealed class CleanupOperationBackgroundService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan ErrorBaseDelay = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan ErrorMaxDelay = TimeSpan.FromMinutes(5);
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<CleanupOperationBackgroundService> _logger;
|
||||
|
||||
@@ -20,6 +23,7 @@ public sealed class CleanupOperationBackgroundService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Startup: requeue interrupted operations
|
||||
try
|
||||
{
|
||||
using var startupScope = _serviceScopeFactory.CreateScope();
|
||||
@@ -31,17 +35,36 @@ public sealed class CleanupOperationBackgroundService : BackgroundService
|
||||
_logger.LogWarning(ex, "Failed to requeue interrupted cleanup operations at startup");
|
||||
}
|
||||
|
||||
var consecutiveErrors = 0;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Skip processing if the circuit is open — don't waste resources
|
||||
if (DatabaseCircuitBreaker.IsOpen)
|
||||
{
|
||||
consecutiveErrors = await DelayWithBackoff(ErrorBaseDelay, ErrorMaxDelay, consecutiveErrors, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var coordinator = scope.ServiceProvider.GetRequiredService<CleanupOperationCoordinator>();
|
||||
var processed = await coordinator.ProcessNextQueuedOperationAsync(stoppingToken);
|
||||
|
||||
if (processed)
|
||||
{
|
||||
consecutiveErrors = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// No queued operations — idle delay
|
||||
consecutiveErrors = 0;
|
||||
await Task.Delay(IdleDelay, stoppingToken);
|
||||
}
|
||||
catch (DatabaseCircuitOpenException)
|
||||
{
|
||||
consecutiveErrors = await DelayWithBackoff(ErrorBaseDelay, ErrorMaxDelay, consecutiveErrors, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -49,17 +72,47 @@ public sealed class CleanupOperationBackgroundService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Cleanup operation background worker failed");
|
||||
}
|
||||
var isDatabaseError = DatabaseCircuitBreaker.IsNonTransient(ex) ||
|
||||
ex is Npgsql.NpgsqlException ||
|
||||
ex is Microsoft.EntityFrameworkCore.DbUpdateException;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(IdleDelay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
if (isDatabaseError)
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
_logger.LogWarning(ex,
|
||||
"Cleanup background worker: database error (#{ErrorCount}). Circuit state: Open={IsOpen}, Failures={Failures}",
|
||||
consecutiveErrors + 1,
|
||||
DatabaseCircuitBreaker.IsOpen,
|
||||
DatabaseCircuitBreaker.ConsecutiveFailures);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(ex, "Cleanup operation background worker failed");
|
||||
}
|
||||
|
||||
consecutiveErrors = await DelayWithBackoff(ErrorBaseDelay, ErrorMaxDelay, consecutiveErrors, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> DelayWithBackoff(
|
||||
TimeSpan baseDelay, TimeSpan maxDelay, int errorCount, CancellationToken cancellationToken)
|
||||
{
|
||||
errorCount++;
|
||||
// Exponential backoff: 5s, 10s, 20s, 40s, 80s, 160s, capping at 5min
|
||||
var factor = Math.Pow(2, Math.Min(errorCount - 1, 6));
|
||||
var delay = TimeSpan.FromMilliseconds(
|
||||
Math.Min(baseDelay.TotalMilliseconds * factor, maxDelay.TotalMilliseconds));
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Swallow — loop will exit on next iteration
|
||||
}
|
||||
|
||||
return errorCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class DanmakuService : IDanmakuService
|
||||
{
|
||||
private readonly IRecordTaskRepository _recordTaskRepository;
|
||||
private readonly IRecordSessionRepository _recordSessionRepository;
|
||||
|
||||
public DanmakuService(
|
||||
IRecordTaskRepository recordTaskRepository,
|
||||
IRecordSessionRepository recordSessionRepository)
|
||||
{
|
||||
_recordTaskRepository = recordTaskRepository;
|
||||
_recordSessionRepository = recordSessionRepository;
|
||||
}
|
||||
|
||||
public async Task<DanmakuResponseDto?> GetTaskDanmakuAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var task = await _recordTaskRepository.GetByIdAsync(recordTaskId, cancellationToken);
|
||||
if (task is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var danmakuPath = ResolveDanmakuPath(task);
|
||||
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var taskStartedAt = task.StartedAt ?? task.CreatedAt;
|
||||
var events = ParseDanmakuXml(danmakuPath);
|
||||
|
||||
return new DanmakuResponseDto
|
||||
{
|
||||
RecordTaskId = task.Id,
|
||||
SegmentIndex = task.SegmentIndex,
|
||||
Platform = task.LiveRoom?.Platform.ToString(),
|
||||
RoomId = task.LiveRoom?.RoomId,
|
||||
LiveRoomId = task.LiveRoomId.ToString(),
|
||||
RecordSessionId = task.RecordSessionId,
|
||||
StartedAt = taskStartedAt,
|
||||
Events = events
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SessionDanmakuResponseDto?> GetSessionDanmakuAsync(
|
||||
Guid recordSessionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await _recordSessionRepository.GetByIdAsync(recordSessionId, cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tasks = await _recordTaskRepository.ListBySessionIdAsync(recordSessionId, cancellationToken);
|
||||
if (tasks.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the session anchor: the earliest task StartedAt (or CreatedAt)
|
||||
var sessionAnchor = tasks
|
||||
.Select(static task => task.StartedAt ?? task.CreatedAt)
|
||||
.Min();
|
||||
|
||||
var taskResponses = new List<DanmakuResponseDto>(tasks.Count);
|
||||
foreach (var task in tasks.OrderBy(static item => item.SegmentIndex).ThenBy(static item => item.CreatedAt))
|
||||
{
|
||||
var danmakuPath = ResolveDanmakuPath(task);
|
||||
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var taskStartedAt = task.StartedAt ?? task.CreatedAt;
|
||||
var events = ParseDanmakuXml(danmakuPath);
|
||||
|
||||
// Adjust offsets so they are relative to the session anchor (not the individual segment)
|
||||
var segmentOffsetFromSessionAnchor = (taskStartedAt - sessionAnchor).TotalSeconds;
|
||||
if (Math.Abs(segmentOffsetFromSessionAnchor) > 0.01)
|
||||
{
|
||||
events = events
|
||||
.Select(item => new DanmakuEventDto
|
||||
{
|
||||
OffsetSeconds = item.OffsetSeconds + segmentOffsetFromSessionAnchor,
|
||||
Type = item.Type,
|
||||
Content = item.Content,
|
||||
User = item.User,
|
||||
UserId = item.UserId,
|
||||
Color = item.Color,
|
||||
FontSize = item.FontSize,
|
||||
Mode = item.Mode,
|
||||
TimestampMs = item.TimestampMs,
|
||||
GiftName = item.GiftName,
|
||||
Count = item.Count,
|
||||
Raw = item.Raw
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
taskResponses.Add(new DanmakuResponseDto
|
||||
{
|
||||
RecordTaskId = task.Id,
|
||||
SegmentIndex = task.SegmentIndex,
|
||||
Platform = task.LiveRoom?.Platform.ToString(),
|
||||
RoomId = task.LiveRoom?.RoomId,
|
||||
LiveRoomId = task.LiveRoomId.ToString(),
|
||||
RecordSessionId = task.RecordSessionId,
|
||||
StartedAt = taskStartedAt,
|
||||
Events = events
|
||||
});
|
||||
}
|
||||
|
||||
if (taskResponses.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SessionDanmakuResponseDto
|
||||
{
|
||||
RecordSessionId = recordSessionId,
|
||||
Tasks = taskResponses
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveDanmakuPath(RecordTask task)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(task.Result?.DanmakuFilePath))
|
||||
{
|
||||
return task.Result.DanmakuFilePath;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(task.OutputFilePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.ChangeExtension(task.OutputFilePath, ".xml");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DanmakuEventDto> ParseDanmakuXml(string danmakuPath)
|
||||
{
|
||||
var events = new List<DanmakuEventDto>();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings
|
||||
{
|
||||
IgnoreComments = true,
|
||||
IgnoreWhitespace = true,
|
||||
DtdProcessing = DtdProcessing.Ignore
|
||||
};
|
||||
|
||||
using var reader = XmlReader.Create(danmakuPath, settings);
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.NodeType != XmlNodeType.Element)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(reader.Name, "d", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var chatEvent = ParseChatElement(reader);
|
||||
if (chatEvent is not null)
|
||||
{
|
||||
events.Add(chatEvent);
|
||||
}
|
||||
}
|
||||
else if (string.Equals(reader.Name, "event", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var nonChatEvent = ParseEventElement(reader);
|
||||
if (nonChatEvent is not null)
|
||||
{
|
||||
events.Add(nonChatEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static DanmakuEventDto? ParseChatElement(XmlReader reader)
|
||||
{
|
||||
// <d p="offsetSeconds,mode,fontSize,color,timestampMs,?,userId,?" user="..." type="chat" raw="...">content</d>
|
||||
var payload = reader.GetAttribute("p");
|
||||
var user = reader.GetAttribute("user");
|
||||
var raw = reader.GetAttribute("raw");
|
||||
|
||||
double? offsetSeconds = null;
|
||||
int? fontSize = null;
|
||||
int? mode = null;
|
||||
string? color = null;
|
||||
long? timestampMs = null;
|
||||
string? userId = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
var parts = payload.Split(',');
|
||||
if (parts.Length >= 1 && double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedOffset))
|
||||
{
|
||||
offsetSeconds = parsedOffset;
|
||||
}
|
||||
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMode))
|
||||
{
|
||||
mode = parsedMode;
|
||||
}
|
||||
|
||||
if (parts.Length >= 3 && int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedFontSize))
|
||||
{
|
||||
fontSize = parsedFontSize;
|
||||
}
|
||||
|
||||
if (parts.Length >= 4)
|
||||
{
|
||||
color = string.IsNullOrWhiteSpace(parts[3]) ? null : parts[3].Trim();
|
||||
}
|
||||
|
||||
if (parts.Length >= 5 && long.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTs))
|
||||
{
|
||||
timestampMs = parsedTs;
|
||||
}
|
||||
|
||||
if (parts.Length >= 7)
|
||||
{
|
||||
userId = string.IsNullOrWhiteSpace(parts[6]) ? null : parts[6].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!offsetSeconds.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = reader.ReadInnerXml().Trim();
|
||||
|
||||
return new DanmakuEventDto
|
||||
{
|
||||
OffsetSeconds = Math.Max(0, offsetSeconds.Value),
|
||||
Type = "chat",
|
||||
Content = string.IsNullOrWhiteSpace(content) ? string.Empty : content,
|
||||
User = NormalizeNullable(user),
|
||||
UserId = NormalizeNullable(userId),
|
||||
Color = NormalizeNullable(color) ?? "FFFFFF",
|
||||
FontSize = fontSize ?? 25,
|
||||
Mode = mode ?? 1,
|
||||
TimestampMs = timestampMs,
|
||||
Raw = NormalizeNullable(raw)
|
||||
};
|
||||
}
|
||||
|
||||
private static DanmakuEventDto? ParseEventElement(XmlReader reader)
|
||||
{
|
||||
// <event type="gift" ts="123" offset="12.3" user="name" userId="uid" content="desc" raw="..." [extraKey="extraValue"] ... />
|
||||
var type = reader.GetAttribute("type");
|
||||
var offset = reader.GetAttribute("offset");
|
||||
var ts = reader.GetAttribute("ts");
|
||||
var user = reader.GetAttribute("user");
|
||||
var userId = reader.GetAttribute("userId");
|
||||
var content = reader.GetAttribute("content");
|
||||
var raw = reader.GetAttribute("raw");
|
||||
|
||||
if (!double.TryParse(offset, NumberStyles.Float, CultureInfo.InvariantCulture, out var offsetSeconds))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long? timestampMs = null;
|
||||
if (long.TryParse(ts, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTs))
|
||||
{
|
||||
timestampMs = parsedTs;
|
||||
}
|
||||
|
||||
var giftName = reader.GetAttribute("giftName");
|
||||
var count = reader.GetAttribute("count");
|
||||
|
||||
int? countValue = null;
|
||||
if (int.TryParse(count, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedCount))
|
||||
{
|
||||
countValue = parsedCount;
|
||||
}
|
||||
|
||||
return new DanmakuEventDto
|
||||
{
|
||||
OffsetSeconds = Math.Max(0, offsetSeconds),
|
||||
Type = string.IsNullOrWhiteSpace(type) ? "other" : type.Trim(),
|
||||
Content = NormalizeNullable(content) ?? string.Empty,
|
||||
User = NormalizeNullable(user),
|
||||
UserId = NormalizeNullable(userId),
|
||||
Color = null,
|
||||
FontSize = null,
|
||||
Mode = null,
|
||||
TimestampMs = timestampMs,
|
||||
GiftName = NormalizeNullable(giftName),
|
||||
Count = countValue,
|
||||
Raw = NormalizeNullable(raw)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeNullable(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Abstractions.Scripting;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Common;
|
||||
@@ -18,15 +20,21 @@ public sealed class EventScriptService : IEventScriptService
|
||||
|
||||
private readonly ISystemSettingsService _settingsService;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
private readonly IEmailNotificationService _emailNotificationService;
|
||||
private readonly IWebhookNotificationService _webhookNotificationService;
|
||||
private readonly ILogger<EventScriptService> _logger;
|
||||
|
||||
public EventScriptService(
|
||||
ISystemSettingsService settingsService,
|
||||
ISystemLogService systemLogService,
|
||||
IEmailNotificationService emailNotificationService,
|
||||
IWebhookNotificationService webhookNotificationService,
|
||||
ILogger<EventScriptService> logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_systemLogService = systemLogService;
|
||||
_emailNotificationService = emailNotificationService;
|
||||
_webhookNotificationService = webhookNotificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -44,8 +52,12 @@ public sealed class EventScriptService : IEventScriptService
|
||||
settings.LiveStartedScriptPath,
|
||||
settings.LiveStartedScriptContent,
|
||||
settings.EventScriptTimeoutSeconds,
|
||||
settings.EventScriptRetryAttempts,
|
||||
settings.EventScriptRetryDelaySeconds,
|
||||
"live_started",
|
||||
environment,
|
||||
liveRoom,
|
||||
recordTask: null,
|
||||
"Script",
|
||||
liveRoom.Id,
|
||||
recordSessionId: null,
|
||||
@@ -67,8 +79,12 @@ public sealed class EventScriptService : IEventScriptService
|
||||
settings.LiveEndedScriptPath,
|
||||
settings.LiveEndedScriptContent,
|
||||
settings.EventScriptTimeoutSeconds,
|
||||
settings.EventScriptRetryAttempts,
|
||||
settings.EventScriptRetryDelaySeconds,
|
||||
"live_ended",
|
||||
environment,
|
||||
liveRoom,
|
||||
recordTask: null,
|
||||
"Script",
|
||||
liveRoom.Id,
|
||||
recordSessionId: null,
|
||||
@@ -105,8 +121,12 @@ public sealed class EventScriptService : IEventScriptService
|
||||
settings.SegmentCompletedScriptPath,
|
||||
settings.SegmentCompletedScriptContent,
|
||||
settings.EventScriptTimeoutSeconds,
|
||||
settings.EventScriptRetryAttempts,
|
||||
settings.EventScriptRetryDelaySeconds,
|
||||
"segment_completed",
|
||||
environment,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
"Script",
|
||||
liveRoom?.Id ?? recordSession.LiveRoomId,
|
||||
recordSession.Id,
|
||||
@@ -176,8 +196,12 @@ public sealed class EventScriptService : IEventScriptService
|
||||
string scriptPath,
|
||||
string scriptContent,
|
||||
int timeoutSeconds,
|
||||
int retryAttempts,
|
||||
int retryDelaySeconds,
|
||||
string eventName,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
string logCategory,
|
||||
Guid? liveRoomId,
|
||||
Guid? recordSessionId,
|
||||
@@ -189,6 +213,43 @@ public sealed class EventScriptService : IEventScriptService
|
||||
return null;
|
||||
}
|
||||
|
||||
var outcome = await ExecuteWithRetryAsync(
|
||||
scriptMode,
|
||||
scriptPath,
|
||||
scriptContent,
|
||||
timeoutSeconds,
|
||||
retryAttempts,
|
||||
retryDelaySeconds,
|
||||
eventName,
|
||||
environment,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
logCategory,
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
|
||||
return MapOutcome(outcome);
|
||||
}
|
||||
|
||||
private async Task<ScriptExecutionOutcome> ExecuteWithRetryAsync(
|
||||
string scriptMode,
|
||||
string scriptPath,
|
||||
string scriptContent,
|
||||
int timeoutSeconds,
|
||||
int retryAttempts,
|
||||
int retryDelaySeconds,
|
||||
string eventName,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
string logCategory,
|
||||
Guid? liveRoomId,
|
||||
Guid? recordSessionId,
|
||||
Guid? recordTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await ExecuteAsync(
|
||||
scriptMode,
|
||||
scriptPath,
|
||||
@@ -202,7 +263,53 @@ public sealed class EventScriptService : IEventScriptService
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
|
||||
return MapOutcome(outcome);
|
||||
if (outcome.Success || !IsRetryableFailure(outcome))
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
|
||||
var maxRetryAttempts = Math.Clamp(retryAttempts, 0, 20);
|
||||
var totalAttempts = 1;
|
||||
|
||||
for (var retryIndex = 1; retryIndex <= maxRetryAttempts; retryIndex++)
|
||||
{
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
logCategory,
|
||||
$"Event script retry {retryIndex} of {maxRetryAttempts} scheduled for {eventName}.",
|
||||
BuildRetryAttemptDetail(eventName, retryIndex + 1, maxRetryAttempts + 1, retryDelaySeconds, outcome),
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
|
||||
if (retryDelaySeconds > 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(retryDelaySeconds, 0, 3600)), cancellationToken);
|
||||
}
|
||||
|
||||
outcome = await ExecuteAsync(
|
||||
scriptMode,
|
||||
scriptPath,
|
||||
scriptContent,
|
||||
timeoutSeconds,
|
||||
eventName,
|
||||
environment,
|
||||
logCategory,
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
totalAttempts++;
|
||||
|
||||
if (outcome.Success || !IsRetryableFailure(outcome))
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
await NotifyRetryExhaustedAsync(eventName, totalAttempts, outcome, liveRoom, recordTask, cancellationToken);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async Task<ScriptExecutionOutcome> ExecuteAsync(
|
||||
@@ -225,7 +332,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
false,
|
||||
$"Event script was not configured for {eventName}.",
|
||||
null,
|
||||
null);
|
||||
null,
|
||||
null,
|
||||
ScriptFailureKind.MissingConfiguration);
|
||||
|
||||
await WriteOutcomeLogAsync(
|
||||
missingConfiguration,
|
||||
@@ -244,7 +353,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
false,
|
||||
$"Event script was not found for {eventName}.",
|
||||
execution.Detail,
|
||||
null);
|
||||
null,
|
||||
execution.Detail,
|
||||
ScriptFailureKind.MissingScript);
|
||||
|
||||
await WriteOutcomeLogAsync(
|
||||
missingScript,
|
||||
@@ -295,7 +406,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
? $"Event script completed for {eventName}."
|
||||
: $"Event script exited with code {process.ExitCode} for {eventName}.",
|
||||
execution.Detail,
|
||||
null);
|
||||
null,
|
||||
execution.Detail,
|
||||
process.ExitCode == 0 ? ScriptFailureKind.None : ScriptFailureKind.ExitCode);
|
||||
outcomeLevel = process.ExitCode == 0 ? SystemLogLevel.Info : SystemLogLevel.Warning;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
@@ -305,7 +418,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
false,
|
||||
$"Event script timed out for {eventName}.",
|
||||
execution.Detail,
|
||||
null);
|
||||
null,
|
||||
execution.Detail,
|
||||
ScriptFailureKind.Timeout);
|
||||
outcomeLevel = SystemLogLevel.Warning;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -315,7 +430,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
false,
|
||||
$"Event script failed for {eventName}.",
|
||||
ex.ToString(),
|
||||
null);
|
||||
null,
|
||||
execution.Detail,
|
||||
ScriptFailureKind.Exception);
|
||||
outcomeLevel = SystemLogLevel.Warning;
|
||||
}
|
||||
finally
|
||||
@@ -344,6 +461,31 @@ public sealed class EventScriptService : IEventScriptService
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async Task NotifyRetryExhaustedAsync(
|
||||
string eventName,
|
||||
int totalAttempts,
|
||||
ScriptExecutionOutcome outcome,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = BuildRetryFailureNotificationDetail(eventName, totalAttempts, outcome);
|
||||
await _emailNotificationService.SendExceptionAsync(
|
||||
"EventScript",
|
||||
"Event script failed after retries.",
|
||||
detail,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
cancellationToken);
|
||||
await _webhookNotificationService.SendExceptionAsync(
|
||||
"EventScript",
|
||||
"Event script failed after retries.",
|
||||
detail,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static EventScriptExecution? CreateExecution(string scriptMode, string scriptPath, string scriptContent)
|
||||
{
|
||||
if (string.Equals(scriptMode, EventScriptSourceModes.Inline, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -582,6 +724,71 @@ public sealed class EventScriptService : IEventScriptService
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildRetryAttemptDetail(
|
||||
string eventName,
|
||||
int nextAttempt,
|
||||
int totalAttempts,
|
||||
int retryDelaySeconds,
|
||||
ScriptExecutionOutcome outcome)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"Event: {eventName}");
|
||||
builder.AppendLine($"Next attempt: {nextAttempt}/{totalAttempts}");
|
||||
builder.AppendLine($"Retry delay: {Math.Clamp(retryDelaySeconds, 0, 3600)} second(s)");
|
||||
builder.AppendLine($"Last result: {outcome.Message}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.ExecutionTarget))
|
||||
{
|
||||
builder.AppendLine($"Script: {outcome.ExecutionTarget}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.Detail) &&
|
||||
!string.Equals(outcome.Detail, outcome.ExecutionTarget, StringComparison.Ordinal))
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Detail:");
|
||||
builder.AppendLine(outcome.Detail);
|
||||
}
|
||||
|
||||
return TruncateSystemLogDetail(builder.ToString().Trim());
|
||||
}
|
||||
|
||||
private static string BuildRetryFailureNotificationDetail(
|
||||
string eventName,
|
||||
int totalAttempts,
|
||||
ScriptExecutionOutcome outcome)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"Event: {eventName}");
|
||||
builder.AppendLine($"Attempts: {totalAttempts}");
|
||||
builder.AppendLine($"Last result: {outcome.Message}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.ExecutionTarget))
|
||||
{
|
||||
builder.AppendLine($"Script: {outcome.ExecutionTarget}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.Detail) &&
|
||||
!string.Equals(outcome.Detail, outcome.ExecutionTarget, StringComparison.Ordinal))
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Detail:");
|
||||
builder.AppendLine(outcome.Detail);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.CustomLogOutput))
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Custom log output:");
|
||||
builder.AppendLine(outcome.CustomLogOutput);
|
||||
}
|
||||
|
||||
return TruncateSystemLogDetail(builder.ToString().Trim());
|
||||
}
|
||||
|
||||
private static bool IsRetryableFailure(ScriptExecutionOutcome outcome) =>
|
||||
outcome.FailureKind is ScriptFailureKind.ExitCode or ScriptFailureKind.Timeout or ScriptFailureKind.Exception;
|
||||
|
||||
private static string CreateScriptLogPath()
|
||||
{
|
||||
return Path.Combine(
|
||||
@@ -628,11 +835,23 @@ public sealed class EventScriptService : IEventScriptService
|
||||
}
|
||||
}
|
||||
|
||||
private enum ScriptFailureKind
|
||||
{
|
||||
None,
|
||||
MissingConfiguration,
|
||||
MissingScript,
|
||||
ExitCode,
|
||||
Timeout,
|
||||
Exception
|
||||
}
|
||||
|
||||
private sealed record EventScriptExecution(ProcessStartInfo? StartInfo, string Detail, bool IsMissing = false);
|
||||
|
||||
private sealed record ScriptExecutionOutcome(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Detail,
|
||||
string? CustomLogOutput);
|
||||
string? CustomLogOutput,
|
||||
string? ExecutionTarget,
|
||||
ScriptFailureKind FailureKind);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
@@ -56,6 +57,70 @@ public sealed partial class FfmpegService
|
||||
{
|
||||
_ = ValidateRuntimeSourceFailureAsync(runtime, line);
|
||||
}
|
||||
|
||||
TryUpdateBandwidthFromProgressLine(runtime, line);
|
||||
|
||||
// Flush bandwidth sample periodically
|
||||
_ = runtime.FlushBandwidthSampleIfNeededAsync(WriteBandwidthSampleAsync, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task WriteBandwidthSampleAsync(
|
||||
Guid liveRoomId,
|
||||
Guid recordSessionId,
|
||||
Guid recordTaskId,
|
||||
string detail,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var systemLogService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
await systemLogService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"Bandwidth",
|
||||
"bandwidth_sample",
|
||||
detail,
|
||||
liveRoomId: liveRoomId,
|
||||
recordSessionId: recordSessionId,
|
||||
recordTaskId: recordTaskId,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore bandwidth logging failures
|
||||
}
|
||||
}
|
||||
|
||||
private void TryUpdateBandwidthFromProgressLine(SessionProcessRuntime runtime, string line)
|
||||
{
|
||||
if (line.StartsWith("total_size=", StringComparison.Ordinal))
|
||||
{
|
||||
if (long.TryParse(line.AsSpan("total_size=".Length), out var totalSize))
|
||||
{
|
||||
runtime.UpdateBandwidthTotalSize(totalSize);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("bitrate=", StringComparison.Ordinal))
|
||||
{
|
||||
// bitrate format: "1234.5kbits/s"
|
||||
var bitrateStr = line.AsSpan("bitrate=".Length).Trim();
|
||||
if (bitrateStr.EndsWith("kbits/s", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
bitrateStr = bitrateStr.Slice(0, bitrateStr.Length - "kbits/s".Length).Trim();
|
||||
}
|
||||
if (double.TryParse(bitrateStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var bitrate))
|
||||
{
|
||||
runtime.UpdateBandwidthBitrate(bitrate);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("speed=", StringComparison.Ordinal))
|
||||
{
|
||||
var speedStr = line.AsSpan("speed=".Length).TrimEnd('x').Trim();
|
||||
if (double.TryParse(speedStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var speed))
|
||||
{
|
||||
runtime.UpdateBandwidthSpeed(speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryClassifyPersistedFfmpegLine(string line, bool isError, out SystemLogLevel level)
|
||||
@@ -1839,6 +1904,54 @@ public sealed partial class FfmpegService
|
||||
public ILiveDanmakuConnection? DanmakuConnection { get; set; }
|
||||
public Task? DanmakuPumpTask { get; set; }
|
||||
private List<string> CurrentRecorderSegmentPaths { get; } = [];
|
||||
// Bandwidth tracking fields
|
||||
private long _lastBandwidthTotalSize;
|
||||
private double? _lastBandwidthBitrate;
|
||||
private double _lastBandwidthSpeed;
|
||||
private DateTimeOffset _lastBandwidthFlushAt = DateTimeOffset.MinValue;
|
||||
private static readonly TimeSpan BandwidthFlushInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
public void UpdateBandwidthTotalSize(long totalSize)
|
||||
{
|
||||
_lastBandwidthTotalSize = Math.Max(0, totalSize);
|
||||
}
|
||||
|
||||
public void UpdateBandwidthBitrate(double bitrateKbps)
|
||||
{
|
||||
_lastBandwidthBitrate = Math.Max(0, bitrateKbps);
|
||||
}
|
||||
|
||||
public void UpdateBandwidthSpeed(double speed)
|
||||
{
|
||||
_lastBandwidthSpeed = speed;
|
||||
}
|
||||
|
||||
public async Task FlushBandwidthSampleIfNeededAsync(
|
||||
Func<Guid, Guid, Guid, string, System.Threading.CancellationToken, Task> writeLogAsync,
|
||||
System.Threading.CancellationToken cancellationToken)
|
||||
{
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
if (nowUtc - _lastBandwidthFlushAt < BandwidthFlushInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastBandwidthFlushAt = nowUtc;
|
||||
|
||||
if (_lastBandwidthTotalSize <= 0 && !_lastBandwidthBitrate.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var detail = $$"""{"bytesDownloaded":{{_lastBandwidthTotalSize}},"bitrateKbps":{{(_lastBandwidthBitrate?.ToString("F1", CultureInfo.InvariantCulture) ?? "null")}},"speed":{{_lastBandwidthSpeed.ToString("F2", CultureInfo.InvariantCulture)}}}""";
|
||||
|
||||
await writeLogAsync(
|
||||
LiveRoomId,
|
||||
RecordSessionId,
|
||||
CurrentTaskId,
|
||||
detail,
|
||||
cancellationToken);
|
||||
}
|
||||
private object RuntimeSourceFailureSync { get; } = new();
|
||||
private object RecentOutputSync { get; } = new();
|
||||
private Queue<string> RecentOutputLines { get; } = new();
|
||||
|
||||
@@ -656,7 +656,7 @@ public sealed partial class FfmpegService
|
||||
string? selectedVideoCodec,
|
||||
FfmpegInputOptionProfile inputOptionProfile)
|
||||
{
|
||||
var arguments = new List<string> { "-hide_banner", "-y" };
|
||||
var arguments = new List<string> { "-hide_banner", "-y", "-progress", "pipe:1" };
|
||||
var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode);
|
||||
|
||||
if (IsHttpInput(streamUrl))
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class FfmpegVideoMetadataService : IVideoMetadataService
|
||||
{
|
||||
private const string ThumbnailsSubDir = ".thumbnails";
|
||||
|
||||
public async Task<VideoMetadata?> ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetFfprobePath(),
|
||||
Arguments = $"-v quiet -print_format json -show_format -show_streams \"{filePath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseFfprobeOutput(output);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GenerateThumbnailAsync(string filePath, string outputDir, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relativePath = Path.GetRelativePath(Path.GetFullPath(outputDir, AppContext.BaseDirectory), filePath);
|
||||
// Sanitize: replace directory separators with safe characters
|
||||
var safeRelativePath = relativePath
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('/')
|
||||
.Replace('/', '_');
|
||||
var thumbDir = Path.Combine(outputDir, ThumbnailsSubDir);
|
||||
var thumbPath = Path.Combine(thumbDir, $"{safeRelativePath}.jpg");
|
||||
|
||||
// Return cached thumbnail if it exists
|
||||
if (File.Exists(thumbPath) && new FileInfo(thumbPath).Length > 0)
|
||||
{
|
||||
return thumbPath;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Calculate snapshot time: 10% of duration or 30 seconds default
|
||||
var metadata = await ExtractMetadataAsync(filePath, cancellationToken);
|
||||
var seekSeconds = metadata?.DurationSeconds.HasValue == true && metadata.DurationSeconds.Value > 60
|
||||
? (int)(metadata.DurationSeconds.Value * 0.1)
|
||||
: 30;
|
||||
|
||||
Directory.CreateDirectory(thumbDir);
|
||||
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetFfmpegPath(),
|
||||
Arguments = $"-ss {seekSeconds} -i \"{filePath}\" -vframes 1 -q:v 2 -y \"{thumbPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode == 0 && File.Exists(thumbPath) && new FileInfo(thumbPath).Length > 0)
|
||||
{
|
||||
return thumbPath;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Thumbnail generation failed silently
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VideoMetadata? ParseFfprobeOutput(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var format = doc.RootElement.TryGetProperty("format", out var fmt) ? fmt : (JsonElement?)null;
|
||||
var streams = doc.RootElement.TryGetProperty("streams", out var str) ? str : (JsonElement?)null;
|
||||
|
||||
double? duration = null;
|
||||
long? bitRate = null;
|
||||
if (format.HasValue)
|
||||
{
|
||||
if (format.Value.TryGetProperty("duration", out var dur) && dur.TryGetDouble(out var d))
|
||||
duration = d;
|
||||
if (format.Value.TryGetProperty("bit_rate", out var br) && br.TryGetInt64(out var b))
|
||||
bitRate = b;
|
||||
}
|
||||
|
||||
int? width = null;
|
||||
int? height = null;
|
||||
string? videoCodec = null;
|
||||
string? audioCodec = null;
|
||||
double? frameRate = null;
|
||||
|
||||
if (streams.HasValue && streams.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var stream in streams.Value.EnumerateArray())
|
||||
{
|
||||
var codecType = stream.TryGetProperty("codec_type", out var ct) ? ct.GetString() : null;
|
||||
var codecName = stream.TryGetProperty("codec_name", out var cn) ? cn.GetString() : null;
|
||||
|
||||
if (codecType == "video")
|
||||
{
|
||||
if (stream.TryGetProperty("width", out var w) && w.TryGetInt32(out var wv))
|
||||
width = wv;
|
||||
if (stream.TryGetProperty("height", out var h) && h.TryGetInt32(out var hv))
|
||||
height = hv;
|
||||
videoCodec = codecName;
|
||||
if (stream.TryGetProperty("r_frame_rate", out var fr) && fr.GetString() is { } frStr)
|
||||
frameRate = ParseFrameRate(frStr);
|
||||
}
|
||||
else if (codecType == "audio")
|
||||
{
|
||||
audioCodec = codecName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new VideoMetadata(duration, width, height, videoCodec, audioCodec, frameRate, bitRate);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static double? ParseFrameRate(string fraction)
|
||||
{
|
||||
var parts = fraction.Split('/');
|
||||
if (parts.Length == 2 &&
|
||||
double.TryParse(parts[0], out var num) &&
|
||||
double.TryParse(parts[1], out var den) &&
|
||||
den > 0)
|
||||
{
|
||||
return num / den;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetFfmpegPath() => "ffmpeg";
|
||||
private static string GetFfprobePath() => "ffprobe";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
@@ -74,7 +75,8 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
var settings = await settingsService.GetAsync(stoppingToken);
|
||||
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
|
||||
var storageGuardService = scope.ServiceProvider.GetRequiredService<IStorageGuardService>();
|
||||
if (storageGuardService.CheckCanStartOrResume(settings).HasEnoughSpace)
|
||||
var storageCheck = storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (storageCheck.Tier != StorageTier.Red)
|
||||
{
|
||||
await ffmpegService.ResumePausedFinalizationsAsync(stoppingToken);
|
||||
}
|
||||
@@ -144,8 +146,20 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (DatabaseCircuitOpenException)
|
||||
{
|
||||
// Circuit is open — skip this iteration and wait
|
||||
_logger.LogDebug("Polling loop skipped: database circuit breaker is open");
|
||||
delay = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Record database failures to the circuit breaker
|
||||
if (IsTransientDatabaseException(ex) || DatabaseCircuitBreaker.IsNonTransient(ex))
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
}
|
||||
|
||||
_logger.LogError(ex, "Background live room polling failed");
|
||||
|
||||
try
|
||||
@@ -353,15 +367,18 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
return;
|
||||
}
|
||||
|
||||
var pauseCheck = storageGuardService.CheckShouldPause(settings);
|
||||
if (!pauseCheck.HasEnoughSpace)
|
||||
var guardCheck = storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (guardCheck.ShouldPauseActive)
|
||||
{
|
||||
await PauseActiveSessionsForLowStorageAsync(
|
||||
dbContext,
|
||||
ffmpegService,
|
||||
logService,
|
||||
emailNotificationService,
|
||||
webhookNotificationService,
|
||||
liveRoom,
|
||||
liveRoom.Id,
|
||||
pauseCheck.Message,
|
||||
guardCheck.Message,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -377,25 +394,6 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
return;
|
||||
}
|
||||
|
||||
var startCheck = storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (!startCheck.HasEnoughSpace)
|
||||
{
|
||||
await UpdateAutoStartDecisionAsync(
|
||||
dbContext,
|
||||
liveRoom,
|
||||
AutoStartDecisionCodes.SkippedStorage,
|
||||
"Auto-start skipped because storage is below threshold.",
|
||||
startCheck.Message,
|
||||
cancellationToken);
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Storage",
|
||||
"Auto-start recording skipped because storage is below resume threshold.",
|
||||
startCheck.Message,
|
||||
liveRoomId: liveRoom.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var reconciledStaleSessionIds = await ReconcileStaleActiveSessionsAsync(
|
||||
dbContext,
|
||||
@@ -413,6 +411,25 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
if (!guardCheck.CanStartNewRecording)
|
||||
{
|
||||
await UpdateAutoStartDecisionAsync(
|
||||
dbContext,
|
||||
liveRoom,
|
||||
AutoStartDecisionCodes.SkippedStorage,
|
||||
$"Auto-start skipped because storage tier is {guardCheck.Tier}.",
|
||||
guardCheck.Message,
|
||||
cancellationToken);
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Storage",
|
||||
"Auto-start recording skipped because storage tier is not Green.",
|
||||
guardCheck.Message,
|
||||
liveRoomId: liveRoom.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var hasRunningSession = await dbContext.RecordSessions.AnyAsync(
|
||||
item => item.LiveRoomId == liveRoom.Id &&
|
||||
(item.Status == RecordSessionStatus.Starting ||
|
||||
@@ -589,6 +606,9 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
LiveRecorderDbContext dbContext,
|
||||
IFfmpegService ffmpegService,
|
||||
ISystemLogService logService,
|
||||
IEmailNotificationService emailNotificationService,
|
||||
IWebhookNotificationService webhookNotificationService,
|
||||
Domain.Entities.LiveRoom liveRoom,
|
||||
Guid liveRoomId,
|
||||
string detail,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -600,8 +620,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
item.Status == RecordSessionStatus.Stopping))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var affectedSessionCount = 0;
|
||||
var forcedStopCount = 0;
|
||||
|
||||
foreach (var activeSession in activeSessions.OrderBy(static item => item.CreatedAt))
|
||||
{
|
||||
affectedSessionCount++;
|
||||
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Storage",
|
||||
@@ -628,12 +653,32 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
liveRoomId,
|
||||
activeSession.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
forcedStopCount++;
|
||||
await ffmpegService.KillAndWaitAsync(activeSession.Id, OfflineForcedStopTimeout, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await ffmpegService.TryReconcileInactiveSessionAsync(activeSession.Id, cancellationToken);
|
||||
}
|
||||
|
||||
if (affectedSessionCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var notificationDetail = BuildLowStorageNotificationDetail(liveRoom, affectedSessionCount, forcedStopCount, detail);
|
||||
await emailNotificationService.SendExceptionAsync(
|
||||
"StorageGuard",
|
||||
"Low storage paused active recording sessions.",
|
||||
notificationDetail,
|
||||
liveRoom,
|
||||
cancellationToken: cancellationToken);
|
||||
await webhookNotificationService.SendExceptionAsync(
|
||||
"StorageGuard",
|
||||
"Low storage paused active recording sessions.",
|
||||
notificationDetail,
|
||||
liveRoom,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<Guid>> ReconcileStaleActiveSessionsAsync(
|
||||
@@ -699,6 +744,39 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
IsTransientPollingException(exception.InnerException, cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildLowStorageNotificationDetail(
|
||||
Domain.Entities.LiveRoom liveRoom,
|
||||
int affectedSessionCount,
|
||||
int forcedStopCount,
|
||||
string storageDetail)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"Platform: {liveRoom.Platform}");
|
||||
builder.AppendLine($"Room ID: {liveRoom.RoomId}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(liveRoom.AnchorName))
|
||||
{
|
||||
builder.AppendLine($"Anchor: {liveRoom.AnchorName}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(liveRoom.Title))
|
||||
{
|
||||
builder.AppendLine($"Title: {liveRoom.Title}");
|
||||
}
|
||||
|
||||
builder.AppendLine($"Affected sessions: {affectedSessionCount}");
|
||||
builder.AppendLine($"Forced stop attempts: {forcedStopCount}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(storageDetail))
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Storage detail:");
|
||||
builder.AppendLine(storageDetail);
|
||||
}
|
||||
|
||||
return builder.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string BuildPollingFailureDetail(Exception exception)
|
||||
{
|
||||
var root = exception.GetBaseException();
|
||||
|
||||
@@ -5,6 +5,7 @@ using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
@@ -13,11 +14,11 @@ public sealed class PlatformHttpClientFactory
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
|
||||
public PlatformHttpClientFactory(ISystemSettingsService systemSettingsService)
|
||||
public PlatformHttpClientFactory(IServiceScopeFactory serviceScopeFactory)
|
||||
{
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
}
|
||||
|
||||
public async Task<HttpClient> CreateAsync(
|
||||
@@ -25,7 +26,9 @@ public sealed class PlatformHttpClientFactory
|
||||
bool forceDirectConnection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var systemSettingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||
var settings = await systemSettingsService.GetAsync(cancellationToken);
|
||||
var proxy = forceDirectConnection ? null : BuildProxy(platform, settings);
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
|
||||
@@ -50,7 +50,9 @@ public sealed class RecoveryService
|
||||
CheckedPath = storage.CheckedPath,
|
||||
AvailableBytes = storage.AvailableBytes,
|
||||
RequiredBytes = storage.RequiredBytes,
|
||||
Message = storage.Message
|
||||
Message = storage.Message,
|
||||
Tier = storage.Tier.ToString(),
|
||||
UsagePercent = storage.UsagePercent
|
||||
},
|
||||
LiveRooms = liveRooms,
|
||||
Finalizations = finalizations
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -7,7 +8,8 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
public sealed class RetentionCleanupBackgroundService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(24);
|
||||
|
||||
private static readonly TimeSpan ErrorBaseDelay = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan ErrorMaxDelay = TimeSpan.FromMinutes(5);
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<RetentionCleanupBackgroundService> _logger;
|
||||
|
||||
@@ -21,13 +23,31 @@ public sealed class RetentionCleanupBackgroundService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var consecutiveErrors = 0;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Skip if circuit is already open
|
||||
if (DatabaseCircuitBreaker.IsOpen)
|
||||
{
|
||||
_logger.LogDebug("Retention cleanup skipped: database circuit breaker is open");
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var cleanupService = scope.ServiceProvider.GetRequiredService<RetentionCleanupService>();
|
||||
await cleanupService.TryEnqueueAsync(ignoreEnabledSetting: false, cancellationToken: stoppingToken);
|
||||
|
||||
consecutiveErrors = 0;
|
||||
}
|
||||
catch (DatabaseCircuitOpenException)
|
||||
{
|
||||
// Silent — circuit is already logged
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -35,7 +55,38 @@ public sealed class RetentionCleanupBackgroundService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Retention cleanup background task failed");
|
||||
var isDatabaseError = DatabaseCircuitBreaker.IsNonTransient(ex) ||
|
||||
ex is Npgsql.NpgsqlException ||
|
||||
ex is Microsoft.EntityFrameworkCore.DbUpdateException;
|
||||
|
||||
consecutiveErrors++;
|
||||
var delay = TimeSpan.FromMilliseconds(
|
||||
Math.Min(ErrorBaseDelay.TotalMilliseconds * Math.Pow(2, Math.Min(consecutiveErrors - 1, 6)),
|
||||
ErrorMaxDelay.TotalMilliseconds));
|
||||
|
||||
if (isDatabaseError)
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
_logger.LogWarning(ex,
|
||||
"Retention cleanup: database error (#{ErrorCount}). Circuit: Open={IsOpen}, Failures={Failures}",
|
||||
consecutiveErrors,
|
||||
DatabaseCircuitBreaker.IsOpen,
|
||||
DatabaseCircuitBreaker.ConsecutiveFailures);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(ex, "Retention cleanup background task failed");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -24,7 +24,11 @@ public sealed class StorageGuardService : IStorageGuardService
|
||||
{
|
||||
if (!settings.EnableStorageGuard)
|
||||
{
|
||||
return new StorageGuardResult(false, true, ResolveOutputRoot(settings.OutputRoot), long.MaxValue, 0, "Storage guard is disabled.");
|
||||
return new StorageGuardResult(false, true, ResolveOutputRoot(settings.OutputRoot), long.MaxValue, 0, "Storage guard is disabled.")
|
||||
{
|
||||
Tier = StorageTier.Green,
|
||||
UsagePercent = 0
|
||||
};
|
||||
}
|
||||
|
||||
var checkedPath = ResolveOutputRoot(settings.OutputRoot);
|
||||
@@ -35,17 +39,54 @@ public sealed class StorageGuardService : IStorageGuardService
|
||||
{
|
||||
var drive = new DriveInfo(Path.GetPathRoot(checkedPath) ?? checkedPath);
|
||||
var availableBytes = drive.AvailableFreeSpace;
|
||||
var hasEnoughSpace = availableBytes >= requiredBytes;
|
||||
var message = hasEnoughSpace
|
||||
? $"Storage is available. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}"
|
||||
: $"Storage is below threshold. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}";
|
||||
var totalBytes = drive.TotalSize;
|
||||
var usedBytes = Math.Max(0, totalBytes - availableBytes);
|
||||
var usagePercent = totalBytes > 0 ? (double)usedBytes / totalBytes * 100.0 : 0;
|
||||
var freePercent = 100.0 - usagePercent;
|
||||
|
||||
return new StorageGuardResult(true, hasEnoughSpace, checkedPath, availableBytes, requiredBytes, message);
|
||||
// Determine tier using configurable thresholds
|
||||
var greenThreshold = Math.Clamp(settings.StorageGreenThresholdPercent, 5, 90);
|
||||
var redThreshold = Math.Clamp(settings.StorageRedThresholdPercent, 1, greenThreshold - 1);
|
||||
|
||||
StorageTier tier;
|
||||
if (freePercent >= greenThreshold)
|
||||
{
|
||||
tier = StorageTier.Green;
|
||||
}
|
||||
else if (freePercent >= redThreshold)
|
||||
{
|
||||
tier = StorageTier.Yellow;
|
||||
}
|
||||
else
|
||||
{
|
||||
tier = StorageTier.Red;
|
||||
}
|
||||
|
||||
var hasEnoughSpace = availableBytes >= requiredBytes;
|
||||
var message = tier switch
|
||||
{
|
||||
StorageTier.Green => $"Storage is healthy. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}",
|
||||
StorageTier.Yellow => $"Storage is low. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}. New recordings paused, existing recordings continue.",
|
||||
StorageTier.Red => $"Storage is critically low. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}. All recordings paused, uploads continue.",
|
||||
_ => hasEnoughSpace
|
||||
? $"Storage is available. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}"
|
||||
: $"Storage is below threshold. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}"
|
||||
};
|
||||
|
||||
return new StorageGuardResult(true, hasEnoughSpace, checkedPath, availableBytes, requiredBytes, message)
|
||||
{
|
||||
Tier = tier,
|
||||
UsagePercent = Math.Round(usagePercent, 1)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Storage guard failed to inspect output root {OutputRoot}", checkedPath);
|
||||
return new StorageGuardResult(true, false, checkedPath, 0, requiredBytes, $"Unable to inspect storage path {checkedPath}: {ex.Message}");
|
||||
return new StorageGuardResult(true, false, checkedPath, 0, requiredBytes, $"Unable to inspect storage path {checkedPath}: {ex.Message}")
|
||||
{
|
||||
Tier = StorageTier.Red,
|
||||
UsagePercent = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,4 +118,3 @@ public sealed class StorageGuardService : IStorageGuardService
|
||||
return $"{display:0.##} {units[unitIndex]}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LiveRecorder.WebApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/bandwidth")]
|
||||
public sealed class BandwidthController : ControllerBase
|
||||
{
|
||||
private readonly BandwidthStatisticsService _bandwidthService;
|
||||
|
||||
public BandwidthController(BandwidthStatisticsService bandwidthService)
|
||||
{
|
||||
_bandwidthService = bandwidthService;
|
||||
}
|
||||
|
||||
[HttpGet("session/{id:guid}")]
|
||||
public async Task<ActionResult<BandwidthTimelineDto>> GetSessionTimeline(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _bandwidthService.GetSessionTimelineAsync(id, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("daily")]
|
||||
public async Task<ActionResult<BandwidthSummaryDto>> GetDaily(
|
||||
[FromQuery] string? date = null,
|
||||
[FromQuery] int utcOffsetMinutes = 480,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var targetDate = date is not null && DateOnly.TryParse(date, out var parsed)
|
||||
? parsed
|
||||
: DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromMinutes(utcOffsetMinutes)).DateTime);
|
||||
|
||||
var result = await _bandwidthService.GetDailySummaryAsync(targetDate, utcOffsetMinutes, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using LiveRecorder.Application.Models.Reports;
|
||||
using LiveRecorder.Application.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LiveRecorder.WebApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/dashboard")]
|
||||
public sealed class DashboardController : ControllerBase
|
||||
{
|
||||
private readonly DashboardService _dashboardService;
|
||||
|
||||
public DashboardController(DashboardService dashboardService)
|
||||
{
|
||||
_dashboardService = dashboardService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<DashboardDto>> Get(CancellationToken cancellationToken) =>
|
||||
Ok(await _dashboardService.GetDashboardAsync(cancellationToken));
|
||||
}
|
||||
@@ -18,9 +18,10 @@ public sealed class MediaBrowserController : ControllerBase
|
||||
[HttpGet("browser")]
|
||||
public async Task<ActionResult<MediaBrowserResponseDto>> Browse(
|
||||
[FromQuery] string? path,
|
||||
CancellationToken cancellationToken)
|
||||
[FromQuery] bool includeMetadata = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Ok(await _mediaBrowserService.BrowseAsync(path, cancellationToken));
|
||||
return Ok(await _mediaBrowserService.BrowseAsync(path, includeMetadata, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("file")]
|
||||
@@ -37,6 +38,55 @@ public sealed class MediaBrowserController : ControllerBase
|
||||
: PhysicalFile(filePath, contentType, enableRangeProcessing: contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[HttpGet("thumbnail")]
|
||||
public async Task<IActionResult> GetThumbnail(
|
||||
[FromQuery] string path,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filePath = await _mediaBrowserService.ResolveFilePathAsync(path, cancellationToken);
|
||||
// Build thumbnail path: the same way FfmpegVideoMetadataService does
|
||||
var settingsOutputRoot = filePath;
|
||||
// We need the output root. Use the service to resolve it.
|
||||
// Simpler approach: serve the thumbnail from the .thumbnails dir relative to the file
|
||||
var dirName = Path.GetDirectoryName(filePath);
|
||||
if (string.IsNullOrWhiteSpace(dirName))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Walk up to find output root by looking for .thumbnails directory
|
||||
var currentDir = dirName;
|
||||
string? thumbDir = null;
|
||||
while (currentDir is not null && Directory.Exists(currentDir))
|
||||
{
|
||||
var candidate = Path.Combine(currentDir, ".thumbnails");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
thumbDir = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
var parent = Directory.GetParent(currentDir);
|
||||
currentDir = parent?.FullName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(thumbDir))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Find the thumbnail file matching the relative path pattern
|
||||
var relativePath = path.Replace('\\', '/').TrimStart('/').Replace('/', '_');
|
||||
var thumbPath = Path.Combine(thumbDir, $"{relativePath}.jpg");
|
||||
|
||||
if (!System.IO.File.Exists(thumbPath))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return PhysicalFile(thumbPath, "image/jpeg");
|
||||
}
|
||||
|
||||
[HttpPost("transcode-file")]
|
||||
public async Task<ActionResult<TranscodeMediaFileResultDto>> TranscodeFile(
|
||||
[FromBody] TranscodeMediaFileRequest request,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
@@ -16,15 +19,18 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
private readonly RecordSessionService _recordSessionService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
|
||||
private readonly IDanmakuService _danmakuService;
|
||||
|
||||
public RecordSessionsController(
|
||||
RecordSessionService recordSessionService,
|
||||
RecordUploadService recordUploadService,
|
||||
CleanupOperationCoordinator cleanupOperationCoordinator)
|
||||
CleanupOperationCoordinator cleanupOperationCoordinator,
|
||||
IDanmakuService danmakuService)
|
||||
{
|
||||
_recordSessionService = recordSessionService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_cleanupOperationCoordinator = cleanupOperationCoordinator;
|
||||
_danmakuService = danmakuService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -100,4 +106,68 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
[HttpPost("{id:guid}/upload")]
|
||||
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
|
||||
|
||||
[HttpGet("{id:guid}/danmaku")]
|
||||
public async Task<ActionResult<SessionDanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _danmakuService.GetSessionDanmakuAsync(id, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/playlist")]
|
||||
public async Task<ActionResult<SessionPlaylistDto>> GetPlaylist(
|
||||
Guid id,
|
||||
[FromServices] IRecordMediaService recordMediaService,
|
||||
[FromServices] IRecordSessionRepository sessionRepository,
|
||||
[FromServices] LinkGenerator linkGenerator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await sessionRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var segments = new List<SessionPlaylistSegmentDto>();
|
||||
foreach (var task in session.RecordTasks
|
||||
.Where(item => item.Status is RecordTaskStatus.Completed or RecordTaskStatus.Stopped)
|
||||
.OrderBy(item => item.SegmentIndex)
|
||||
.ThenBy(item => item.CreatedAt))
|
||||
{
|
||||
try
|
||||
{
|
||||
var ticket = await recordMediaService.CreatePreviewTicketAsync(task.Id, cancellationToken);
|
||||
var ticketUrl = linkGenerator.GetUriByAction(
|
||||
HttpContext,
|
||||
action: nameof(MediaController.GetRecordTaskMedia),
|
||||
controller: "Media",
|
||||
values: new { ticket = ticket.Ticket })
|
||||
?? $"{Request.Scheme}://{Request.Host}/media/record-tasks/{ticket.Ticket}";
|
||||
|
||||
segments.Add(new SessionPlaylistSegmentDto
|
||||
{
|
||||
RecordTaskId = task.Id,
|
||||
SegmentIndex = task.SegmentIndex,
|
||||
PreviewTicketUrl = ticketUrl,
|
||||
DurationSeconds = task.DurationSeconds
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip segments that can't be previewed
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(new SessionPlaylistDto
|
||||
{
|
||||
RecordSessionId = session.Id,
|
||||
LiveRoomTitle = session.LiveRoom?.Title ?? "-",
|
||||
Segments = segments
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,20 @@ public sealed class RecordTasksController : ControllerBase
|
||||
private readonly RecordService _recordService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly IRecordMediaService _recordMediaService;
|
||||
private readonly IDanmakuService _danmakuService;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
|
||||
public RecordTasksController(
|
||||
RecordService recordService,
|
||||
RecordUploadService recordUploadService,
|
||||
IRecordMediaService recordMediaService,
|
||||
IDanmakuService danmakuService,
|
||||
LinkGenerator linkGenerator)
|
||||
{
|
||||
_recordService = recordService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_recordMediaService = recordMediaService;
|
||||
_danmakuService = danmakuService;
|
||||
_linkGenerator = linkGenerator;
|
||||
}
|
||||
|
||||
@@ -58,6 +61,12 @@ public sealed class RecordTasksController : ControllerBase
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _recordService.DeleteTasksAsync(request, cancellationToken));
|
||||
|
||||
[HttpPost("delete-missing-files")]
|
||||
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteMissingFiles(
|
||||
[FromBody] DeleteMissingFileRecordTasksRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _recordService.DeleteMissingFileTasksAsync(request, cancellationToken));
|
||||
|
||||
[HttpPost("{id:guid}/preview-ticket")]
|
||||
public async Task<ActionResult<RecordPreviewTicketDto>> CreatePreviewTicket(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -89,4 +98,11 @@ public sealed class RecordTasksController : ControllerBase
|
||||
[HttpPost("{id:guid}/upload")]
|
||||
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordUploadService.UploadTaskAsync(id, cancellationToken));
|
||||
|
||||
[HttpGet("{id:guid}/danmaku")]
|
||||
public async Task<ActionResult<DanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _danmakuService.GetTaskDanmakuAsync(id, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,15 @@ ARG DOTNET_RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim
|
||||
FROM ${DOTNET_SDK_IMAGE} AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY ["LiveRecorder.sln", "./"]
|
||||
COPY ["src/LiveRecorder.Domain/LiveRecorder.Domain.csproj", "src/LiveRecorder.Domain/"]
|
||||
COPY ["src/LiveRecorder.Application/LiveRecorder.Application.csproj", "src/LiveRecorder.Application/"]
|
||||
COPY ["src/LiveRecorder.Infrastructure/LiveRecorder.Infrastructure.csproj", "src/LiveRecorder.Infrastructure/"]
|
||||
COPY ["src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj", "src/LiveRecorder.WebApi/"]
|
||||
|
||||
RUN dotnet restore "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj"
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Workaround for QEMU ARM64 emulation
|
||||
ENV DOTNET_EnableWriteXorExecute=0
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
|
||||
ENV DOTNET_GCConserveMemory=9
|
||||
|
||||
RUN dotnet restore "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" -p:RestoreUseStaticGraphEvaluation=true
|
||||
RUN dotnet publish "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:DebugType=None /p:DebugSymbols=false /maxcpucount:1
|
||||
|
||||
FROM ${DOTNET_RUNTIME_IMAGE} AS runtime
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RuntimeIdentifier>linux-arm64</RuntimeIdentifier>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace LiveRecorder.WebApi.Middleware;
|
||||
|
||||
public sealed class ExceptionHandlingMiddleware
|
||||
@@ -17,22 +21,80 @@ public sealed class ExceptionHandlingMiddleware
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (DatabaseCircuitOpenException ex)
|
||||
{
|
||||
// Circuit is open — fast-fail with 503
|
||||
_logger.LogWarning(ex, "Database circuit breaker is open, returning 503");
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
message = "Database temporarily unavailable",
|
||||
detail = "The database is currently unreachable. Requests will be accepted again once connectivity is restored.",
|
||||
retryAfterSeconds = 30
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception");
|
||||
context.Response.StatusCode = ex switch
|
||||
|
||||
// Detect database-related exceptions and return 503 instead of 500
|
||||
var statusCode = ex switch
|
||||
{
|
||||
DatabaseCircuitOpenException => StatusCodes.Status503ServiceUnavailable,
|
||||
KeyNotFoundException => StatusCodes.Status404NotFound,
|
||||
InvalidOperationException => StatusCodes.Status400BadRequest,
|
||||
NotSupportedException => StatusCodes.Status400BadRequest,
|
||||
_ when IsDatabaseException(ex) => StatusCodes.Status503ServiceUnavailable,
|
||||
_ => StatusCodes.Status500InternalServerError
|
||||
};
|
||||
|
||||
context.Response.StatusCode = statusCode;
|
||||
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
message = ex.Message,
|
||||
detail = context.Response.StatusCode == StatusCodes.Status500InternalServerError ? "Internal Server Error" : null
|
||||
message = statusCode == StatusCodes.Status503ServiceUnavailable
|
||||
? "Database temporarily unavailable"
|
||||
: ex.Message,
|
||||
detail = statusCode switch
|
||||
{
|
||||
StatusCodes.Status503ServiceUnavailable => "The database is currently unreachable. Please retry later.",
|
||||
StatusCodes.Status500InternalServerError => "Internal Server Error",
|
||||
_ => null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if an exception (or any of its inner exceptions) is a database-related error
|
||||
/// that should be surfaced as 503 Service Unavailable.
|
||||
/// </summary>
|
||||
private static bool IsDatabaseException(Exception ex)
|
||||
{
|
||||
var current = ex;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is NpgsqlException or DbUpdateException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current is InvalidOperationException ioEx &&
|
||||
(ioEx.Message.Contains("database", StringComparison.OrdinalIgnoreCase) ||
|
||||
ioEx.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) ||
|
||||
ioEx.Message.Contains("Npgsql", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current is TimeoutException && current.Message.Contains("database", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Abstractions.Scripting;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Abstractions.Storage;
|
||||
using LiveRecorder.Application.Models.Reports;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using LiveRecorder.Infrastructure.Persistence.Repositories;
|
||||
@@ -31,6 +32,7 @@ using LiveRecorder.Infrastructure.Platforms.YouTube;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using LiveRecorder.WebApi.Middleware;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Npgsql;
|
||||
|
||||
@@ -132,7 +134,7 @@ if (connectionStringBuilder.Timeout <= 0)
|
||||
|
||||
if (connectionStringBuilder.CommandTimeout <= 0)
|
||||
{
|
||||
connectionStringBuilder.CommandTimeout = 60;
|
||||
connectionStringBuilder.CommandTimeout = 120;
|
||||
}
|
||||
|
||||
if (connectionStringBuilder.KeepAlive <= 0)
|
||||
@@ -146,9 +148,14 @@ builder.Services.AddDbContext<LiveRecorderDbContext>(options =>
|
||||
npgsql.MigrationsAssembly(typeof(LiveRecorderDbContext).Assembly.FullName);
|
||||
npgsql.CommandTimeout(connectionStringBuilder.CommandTimeout);
|
||||
npgsql.EnableRetryOnFailure(
|
||||
maxRetryCount: 5,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(10),
|
||||
maxRetryCount: 3,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(15),
|
||||
errorCodesToAdd: null);
|
||||
npgsql.ExecutionStrategy(dependencies =>
|
||||
new CircuitAwareExecutionStrategy(
|
||||
dependencies,
|
||||
maxRetryCount: 3,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(15)));
|
||||
}));
|
||||
|
||||
builder.Services.AddScoped<IUnitOfWork>(provider => provider.GetRequiredService<LiveRecorderDbContext>());
|
||||
@@ -175,6 +182,7 @@ builder.Services.AddScoped<RecordSessionService>();
|
||||
builder.Services.AddScoped<TranscodeTaskService>();
|
||||
builder.Services.AddScoped<MediaBrowserService>();
|
||||
builder.Services.AddScoped<SessionAnalyticsService>();
|
||||
builder.Services.AddScoped<DashboardService>();
|
||||
builder.Services.AddScoped<RecoveryService>();
|
||||
builder.Services.AddScoped<RecordSessionCleanupResolver>();
|
||||
builder.Services.AddScoped<CleanupOperationCoordinator>();
|
||||
@@ -183,6 +191,9 @@ builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
||||
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
||||
builder.Services.AddScoped<PlatformHttpRequestService>();
|
||||
builder.Services.AddScoped<RecordUploadService>();
|
||||
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
|
||||
builder.Services.AddScoped<IVideoMetadataService, FfmpegVideoMetadataService>();
|
||||
builder.Services.AddScoped<BandwidthStatisticsService>();
|
||||
builder.Services.AddScoped<DatabaseInitializer>();
|
||||
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
|
||||
|
||||
@@ -227,6 +238,61 @@ app.UseMiddleware<ApiTokenAuthenticationMiddleware>();
|
||||
app.MapGet("/", () => Results.Redirect("/swagger"));
|
||||
app.MapControllers();
|
||||
|
||||
// ── Health check endpoints ────────────────────────────────────────────
|
||||
// /health — liveness: is the process alive and responding?
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow }));
|
||||
|
||||
// /health/ready — readiness: can we reach the database?
|
||||
app.MapGet("/health/ready", async (CancellationToken cancellationToken) =>
|
||||
{
|
||||
var readyResult = new HealthReadyResponse
|
||||
{
|
||||
Status = "ready",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Database = new DatabaseHealthStatus()
|
||||
};
|
||||
|
||||
if (DatabaseCircuitBreaker.IsOpen)
|
||||
{
|
||||
readyResult.Status = "degraded";
|
||||
readyResult.Database = new DatabaseHealthStatus
|
||||
{
|
||||
Reachable = false,
|
||||
Reason = "Circuit breaker is open",
|
||||
ConsecutiveFailures = DatabaseCircuitBreaker.ConsecutiveFailures
|
||||
};
|
||||
return Results.Json(readyResult, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Lightweight probe: just test connectivity with a 2-second timeout
|
||||
await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString);
|
||||
var probeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
probeCts.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
|
||||
await connection.OpenAsync(probeCts.Token);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1";
|
||||
await cmd.ExecuteScalarAsync(probeCts.Token);
|
||||
|
||||
readyResult.Database = new DatabaseHealthStatus { Reachable = true };
|
||||
return Results.Ok(readyResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
readyResult.Status = "unhealthy";
|
||||
readyResult.Database = new DatabaseHealthStatus
|
||||
{
|
||||
Reachable = false,
|
||||
Reason = ex.Message,
|
||||
ConsecutiveFailures = DatabaseCircuitBreaker.ConsecutiveFailures
|
||||
};
|
||||
return Results.Json(readyResult, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
});
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Reflection;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Npgsql;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class DatabaseCircuitBreakerTests
|
||||
{
|
||||
// Mirrors the private FailureThreshold in DatabaseCircuitBreaker so the assertions
|
||||
// read intentionally. Keep in sync if the production threshold changes.
|
||||
private const int FailureThreshold = 5;
|
||||
|
||||
public DatabaseCircuitBreakerTests()
|
||||
{
|
||||
// The breaker is a process-wide static, so reset it to a known closed state
|
||||
// before each test to keep cases independent.
|
||||
DatabaseCircuitBreaker.RecordSuccess();
|
||||
SetOpenedAt(DateTimeOffset.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_below_threshold_keeps_circuit_closed()
|
||||
{
|
||||
for (var i = 0; i < FailureThreshold - 1; i++)
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
}
|
||||
|
||||
Assert.False(DatabaseCircuitBreaker.IsOpen);
|
||||
Assert.Equal(FailureThreshold - 1, DatabaseCircuitBreaker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_at_threshold_opens_circuit()
|
||||
{
|
||||
for (var i = 0; i < FailureThreshold; i++)
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
}
|
||||
|
||||
Assert.True(DatabaseCircuitBreaker.IsOpen);
|
||||
Assert.Equal(FailureThreshold, DatabaseCircuitBreaker.ConsecutiveFailures);
|
||||
Assert.NotEqual(DateTimeOffset.MinValue, DatabaseCircuitBreaker.OpenedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSuccess_closes_circuit_and_resets_failures()
|
||||
{
|
||||
for (var i = 0; i < FailureThreshold; i++)
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
}
|
||||
|
||||
Assert.True(DatabaseCircuitBreaker.IsOpen);
|
||||
|
||||
DatabaseCircuitBreaker.RecordSuccess();
|
||||
|
||||
Assert.False(DatabaseCircuitBreaker.IsOpen);
|
||||
Assert.Equal(0, DatabaseCircuitBreaker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOpen_after_break_duration_transitions_to_half_open()
|
||||
{
|
||||
for (var i = 0; i < FailureThreshold; i++)
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
}
|
||||
|
||||
Assert.True(DatabaseCircuitBreaker.IsOpen);
|
||||
|
||||
// The breaker reads DateTimeOffset.UtcNow directly (no injectable clock), so move
|
||||
// the recorded open time past the 30s break window to simulate it elapsing.
|
||||
SetOpenedAt(DateTimeOffset.UtcNow - TimeSpan.FromSeconds(31));
|
||||
|
||||
// The first read past the window half-opens: it permits one probe and drops the
|
||||
// failure count to one below the threshold.
|
||||
Assert.False(DatabaseCircuitBreaker.IsOpen);
|
||||
Assert.Equal(FailureThreshold - 1, DatabaseCircuitBreaker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("53100")] // disk_full
|
||||
[InlineData("53300")] // too_many_connections
|
||||
[InlineData("28P01")] // invalid_password
|
||||
public void IsNonTransient_returns_true_for_known_fatal_sql_states(string sqlState)
|
||||
{
|
||||
var exception = new PostgresException("fatal", "FATAL", "FATAL", sqlState);
|
||||
|
||||
Assert.True(DatabaseCircuitBreaker.IsNonTransient(exception));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNonTransient_returns_false_for_transient_sql_state()
|
||||
{
|
||||
// 40001 = serialization_failure, which is retryable and not in the fatal set.
|
||||
var exception = new PostgresException("retry me", "ERROR", "ERROR", "40001");
|
||||
|
||||
Assert.False(DatabaseCircuitBreaker.IsNonTransient(exception));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNonTransient_unwraps_inner_exceptions()
|
||||
{
|
||||
var inner = new PostgresException("disk full", "FATAL", "FATAL", "53100");
|
||||
var wrapper = new InvalidOperationException("save failed", inner);
|
||||
|
||||
Assert.True(DatabaseCircuitBreaker.IsNonTransient(wrapper));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNonTransient_returns_false_for_null_and_non_postgres_exceptions()
|
||||
{
|
||||
Assert.False(DatabaseCircuitBreaker.IsNonTransient(null));
|
||||
Assert.False(DatabaseCircuitBreaker.IsNonTransient(new InvalidOperationException("boom")));
|
||||
}
|
||||
|
||||
private static void SetOpenedAt(DateTimeOffset value) =>
|
||||
typeof(DatabaseCircuitBreaker)
|
||||
.GetField("_openedAt", BindingFlags.NonPublic | BindingFlags.Static)!
|
||||
.SetValue(null, value);
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\LiveRecorder.Application\LiveRecorder.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\LiveRecorder.Domain\LiveRecorder.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user