Author SHA1 Message Date
nanxunandClaude Opus 4.8 a062bf84bf fix: avoid danmaku event name collision in session detail replay
Alias the replay player's danmakuEvents to replayDanmakuEvents so it no longer shadows another binding in the session detail view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:52:18 +08:00
nanxunandClaude Opus 4.8 df70b64956 build: target linux-arm64 and streamline the Docker image build
Pin the WebApi to RuntimeIdentifier=linux-arm64 (framework-dependent) and rework the Dockerfile for ARM64: copy the full context up front, add QEMU emulation workarounds, and drop debug symbols / cap parallelism during publish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:52:18 +08:00
nanxunandClaude Opus 4.8 5196ffa0f0 feat: add database circuit breaker and health-readiness endpoint
Introduce a process-wide DatabaseCircuitBreaker that fails fast when Postgres is unavailable (e.g. disk full) instead of letting every request burn doomed EF Core retries. CircuitAwareExecutionStrategy derives from NpgsqlRetryingExecutionStrategy and records success/failure around the public Execute/ExecuteAsync seams; background workers skip work and back off while the circuit is open; the exception middleware maps an open circuit (and other DB outages) to 503. Adds /health (liveness) and /health/ready (readiness, reporting circuit state), plus unit tests for the open/half-open transitions and non-transient SQLSTATE detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:52:18 +08:00
nanxunandClaude Opus 4.8 6da0690fd3 feat: batch-delete recording segments whose files are missing
Add a segment-level cleanup alongside the existing session-level one. DeleteMissingFileTasksAsync scans all non-active record tasks, keeps those whose video file no longer exists on disk, and deletes them individually via DeleteTasksAsync (which also drops any session left empty). Exposed as POST /record-tasks/delete-missing-files and a new "清理无文件分片" action in the record tasks view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:52:18 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com e640d5b5cc fix: consolidate storage tier with old MB thresholds into single tier system
- CanStartNewRecording now purely based on Tier==Green (was HasEnoughSpace||Green)
- PollingBackgroundService now uses ShouldPauseActive instead of MB-based CheckShouldPause
- Both pause and start checks consolidated into single guardCheck call
- Old MB pause/resume thresholds still work as secondary safety via hasEnoughSpace

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 11:57:20 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com 0831b33ea0 feat: make storage tier thresholds configurable in system settings
Add StorageGreenThresholdPercent (default 30%) and StorageRedThresholdPercent
(default 10%) to both SystemSettingsDto and the settings UI.

StorageGuardService now reads thresholds from settings instead of hardcoding.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 11:55:08 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com f5ad1dec00 fix: run dashboard queries sequentially to avoid DbContext concurrency
DbContext is not thread-safe. Task.WhenAll caused concurrent access
within the same scoped DbContext, throwing 'A second operation was
started on this context instance' errors on slower machines.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 11:49:48 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com a819322559 fix: hardcode apiBaseUrl to /api instead of relying on import.meta.env
VITE_API_BASE_URL may not be properly resolved during Docker build,
causing axios to construct malformed URLs.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 11:23:23 +08:00
nanxun 2b0345722e fix: add null check to debug log 2026-06-05 11:17:27 +08:00
nanxun 9e5bbbb948 debug: add API URL logging to axios request interceptor 2026-06-05 11:13:18 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com f9016931e8 fix: add build target es2015 for Raspberry Pi Chromium compatibility
Older Chromium on ARM64 doesn't support strict mode arguments.callee
in modern ES module bundles. Downgrading build target to es2015.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 10:47:13 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com 46da2d78f0 perf: increase DB timeout to 120s, reduce retries to 3 with longer delay
Raspberry Pi PostgreSQL is I/O constrained. 60s timeout was too short
for concurrent writes during heavy recording sessions, causing timeout +
retry causing duplicate key violations.

- CommandTimeout: 60s -> 120s
- maxRetryCount: 5 -> 3
- maxRetryDelay: 10s -> 15s

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 01:01:59 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com 524a053263 fix: PlatformHttpClientFactory uses IServiceScopeFactory to avoid disposed DbContext in danmaku retry
PlatformHttpClientFactory was holding a direct ISystemSettingsService reference
(Scoped). When the danmaku connection's request scope ended, retry attempts failed
with ObjectDisposedException on LiveRecorderDbContext.

Changed to use IServiceScopeFactory to create a fresh scope on each CreateAsync call,
so the danmaku retry loop always gets a live DbContext.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 00:02:42 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com 24e5cf2a06 feat: add tiered storage guard (Green/Yellow/Red) and dashboard queue monitor
Storage Tier System:
- Add StorageTier enum (Green >30% / Yellow 10-30% / Red <10%)
- Extend StorageGuardResult with Tier, CanStartNewRecording, ShouldPauseActive, UsagePercent
- Yellow tier: deny new recordings but allow existing to finish and upload
- Red tier: deny new recordings and pause active sessions
- Auto-recovery: when disk frees up, polling automatically resumes new recordings
- Update LiveRoomPollingBackgroundService to use tier-based checks
- Expose tier + usage percent in Recovery API

Dashboard Queue Monitor:
- Add pending transcode count, pending upload count, queued data volume to dashboard
- Add storage tier badge (Green/Yellow/Red) with usage percentage
- Add queue monitoring card to dashboard view

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-04 23:13:26 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com cbee29bef9 feat: add dashboard, file preview metadata, and bandwidth statistics
Dashboard:
- Add DashboardDto, DashboardService with SQL-level aggregate queries
- Add GET /api/dashboard endpoint with real-time system status
- Add repository aggregate methods (CountByAvailability, SumDuration, etc.)
- Add DashboardView.vue as new landing page with KPI cards, storage status, recent sessions, top rooms
- Update router to make dashboard the new / route, add nav item in sidebar

File Preview:
- Add IVideoMetadataService + FfmpegVideoMetadataService for video metadata extraction
- Extend MediaBrowserItemDto with Metadata and ThumbnailUrl fields
- Add includeMetadata param to media browser API, add thumbnail endpoint
- Add SessionPlaylistDto and GET /api/record-sessions/{id}/playlist for continuous playback

Bandwidth Statistics:
- Add -progress pipe:1 to live recording ffmpeg args for bitrate output
- Parse total_size/bitrate/speed from ffmpeg progress lines during recording
- Write bandwidth samples as SystemLogEntry (Category=Bandwidth) every 30s
- Add BandwidthStatisticsService, BandwidthController with session timeline + daily summary
- Add bandwidth TypeScript types

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-04 00:30:02 +08:00
nanxunandClaude Opus 4.8 a5c2cc3202 feat: add danmaku replay player integration
- Add IDanmakuService interface and DanmakuService implementation to parse danmaku XML files
- Add GET /api/record-tasks/{id}/danmaku and GET /api/record-sessions/{id}/danmaku endpoints
- Add DanmakuPlayer Vue component with native video + CSS overlay danmaku rendering
- Add danmakuEngine.ts pure-TypeScript animation loop with binary search, track management, and event notifications
- Add useDanmakuPlayer composable for reusable danmaku data loading
- Integrate danmaku toggle button into RecordTaskDetailView
- Integrate danmaku replay modal dialog into RecordSessionDetailView segment table

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:09:43 +08:00
nanxun b2aaef093d fix: restore settings page chinese text 2026-05-31 20:07:18 +08:00
nanxun f7fa02d13c fix: avoid jenkinsfile label mojibake 2026-05-31 19:53:01 +08:00
nanxun b82110461a feat: add storage and script failure notifications 2026-05-31 19:35:06 +08:00
59 changed files with 4161 additions and 166 deletions
Vendored
+1 -1
View File
@@ -1,5 +1,5 @@
pipeline {
agent { label '閺嬪嫬缂撻張?' }
agent { label '\u6784\u5efa\u673a1' }
options {
timestamps()
+2 -1
View File
@@ -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 -1
View File
@@ -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",
+52
View File
@@ -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;
+125
View File
@@ -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;
}
+281
View File
@@ -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>
+89 -2
View File
@@ -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>
+50 -3
View File
@@ -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"
+46 -8
View File
@@ -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>
+95 -48
View File
@@ -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>
+1
View File
@@ -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);
}
}
+8 -9
View File
@@ -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;
}
}
+69 -3
View File
@@ -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>