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
This commit is contained in:
co-authored by
Claude Opus 4.8 noreply@anthropic.com
parent
a5c2cc3202
commit
cbee29bef9
@@ -8,6 +8,7 @@ import { useViewport } from "@/composables/useViewport";
|
|||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Bell,
|
Bell,
|
||||||
|
DataAnalysis,
|
||||||
Document,
|
Document,
|
||||||
Fold,
|
Fold,
|
||||||
House,
|
House,
|
||||||
@@ -36,6 +37,7 @@ const navigationGroups = [
|
|||||||
key: "monitor",
|
key: "monitor",
|
||||||
title: "直播监控",
|
title: "直播监控",
|
||||||
items: [
|
items: [
|
||||||
|
{ index: "/", label: "仪表盘", icon: DataAnalysis },
|
||||||
{ index: "/live-rooms", label: "直播间", icon: House },
|
{ index: "/live-rooms", label: "直播间", icon: House },
|
||||||
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
|
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
|
||||||
{ index: "/recovery", label: "恢复中心", icon: RefreshRight }
|
{ index: "/recovery", label: "恢复中心", icon: RefreshRight }
|
||||||
@@ -72,6 +74,8 @@ const backendStatusDescription = computed(() =>
|
|||||||
const backendStatusTone = computed(() => (backendUnavailable.value ? "is-danger" : "is-healthy"));
|
const backendStatusTone = computed(() => (backendUnavailable.value ? "is-danger" : "is-healthy"));
|
||||||
const pageEyebrow = computed(() => {
|
const pageEyebrow = computed(() => {
|
||||||
switch (route.name) {
|
switch (route.name) {
|
||||||
|
case "dashboard":
|
||||||
|
return "系统概览";
|
||||||
case "live-rooms":
|
case "live-rooms":
|
||||||
return "直播间监控";
|
return "直播间监控";
|
||||||
case "record-tasks":
|
case "record-tasks":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAuthStore } from "@/stores/auth";
|
|||||||
|
|
||||||
const LoginView = () => import("@/views/LoginView.vue");
|
const LoginView = () => import("@/views/LoginView.vue");
|
||||||
const MainLayout = () => import("@/components/layout/MainLayout.vue");
|
const MainLayout = () => import("@/components/layout/MainLayout.vue");
|
||||||
|
const DashboardView = () => import("@/views/DashboardView.vue");
|
||||||
const LiveRoomsView = () => import("@/views/LiveRoomsView.vue");
|
const LiveRoomsView = () => import("@/views/LiveRoomsView.vue");
|
||||||
const RecordTasksView = () => import("@/views/RecordTasksView.vue");
|
const RecordTasksView = () => import("@/views/RecordTasksView.vue");
|
||||||
const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue");
|
const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue");
|
||||||
@@ -29,7 +30,8 @@ const router = createRouter({
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
redirect: "/live-rooms"
|
name: "dashboard",
|
||||||
|
component: DashboardView
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "live-rooms",
|
path: "live-rooms",
|
||||||
|
|||||||
@@ -808,3 +808,88 @@ export interface SessionDanmakuResponse {
|
|||||||
recordSessionId: string;
|
recordSessionId: string;
|
||||||
tasks: DanmakuResponse[];
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStorageStatus {
|
||||||
|
hasEnoughSpace: boolean;
|
||||||
|
message: string;
|
||||||
|
availableBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardRecentSession {
|
||||||
|
id: string;
|
||||||
|
liveRoomId: string;
|
||||||
|
liveRoomTitle: string;
|
||||||
|
platformName: string;
|
||||||
|
segmentCount: number;
|
||||||
|
status: number;
|
||||||
|
startedAt?: string;
|
||||||
|
durationSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTopRoom {
|
||||||
|
liveRoomId: string;
|
||||||
|
title?: string;
|
||||||
|
anchorName?: string;
|
||||||
|
platformName: string;
|
||||||
|
roomId: string;
|
||||||
|
sessionCount: number;
|
||||||
|
totalDurationSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video metadata types
|
||||||
|
export interface VideoMetadata {
|
||||||
|
durationSeconds?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
videoCodec?: string;
|
||||||
|
audioCodec?: string;
|
||||||
|
frameRate?: number;
|
||||||
|
bitRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session playlist types
|
||||||
|
export interface SessionPlaylistSegment {
|
||||||
|
recordTaskId: string;
|
||||||
|
segmentIndex: number;
|
||||||
|
previewTicketUrl: string;
|
||||||
|
durationSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionPlaylist {
|
||||||
|
recordSessionId: string;
|
||||||
|
liveRoomTitle: string;
|
||||||
|
segments: SessionPlaylistSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bandwidth types
|
||||||
|
export interface BandwidthSummary {
|
||||||
|
totalTrafficMB: number;
|
||||||
|
averageBitrateKbps: number;
|
||||||
|
peakBitrateKbps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BandwidthTimeline {
|
||||||
|
points: BandwidthPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BandwidthPoint {
|
||||||
|
timestamp: string;
|
||||||
|
bytesDownloaded: number;
|
||||||
|
bitrateKbps?: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<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";
|
||||||
|
}
|
||||||
|
|
||||||
|
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="data.storageStatus.hasEnoughSpace ? 'success' : 'danger'">
|
||||||
|
{{ data.storageStatus.hasEnoughSpace ? "充足" : "不足" }}
|
||||||
|
</el-tag>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
@@ -25,6 +25,10 @@ public interface ILiveRoomRepository
|
|||||||
|
|
||||||
Task<IReadOnlyList<LiveRoom>> ListAsync(CancellationToken cancellationToken = default);
|
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);
|
Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
void Remove(LiveRoom liveRoom);
|
void Remove(LiveRoom liveRoom);
|
||||||
@@ -42,6 +46,8 @@ public interface IRecordTaskRepository
|
|||||||
|
|
||||||
Task<RecordTask?> GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
Task<RecordTask?> GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<double> SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default);
|
Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
void Remove(RecordTask recordTask);
|
void Remove(RecordTask recordTask);
|
||||||
@@ -59,6 +65,14 @@ public interface IRecordSessionRepository
|
|||||||
|
|
||||||
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
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);
|
Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
void Remove(RecordSession recordSession);
|
void Remove(RecordSession recordSession);
|
||||||
@@ -68,6 +82,8 @@ public interface IRecordResultRepository
|
|||||||
{
|
{
|
||||||
Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(long TotalBytes, int TotalDanmaku)> GetTodayAggregateAsync(DateTimeOffset createdFrom, DateTimeOffset createdTo, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
|
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
void Update(RecordResult recordResult);
|
void Update(RecordResult recordResult);
|
||||||
@@ -100,6 +116,8 @@ public interface ISystemLogRepository
|
|||||||
int take = 200,
|
int take = 200,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<int> CountRecentErrorsAsync(DateTimeOffset since, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
void RemoveRange(IEnumerable<SystemLogEntry> entries);
|
void RemoveRange(IEnumerable<SystemLogEntry> entries);
|
||||||
|
|
||||||
Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(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);
|
||||||
@@ -22,6 +22,16 @@ public sealed class MediaBrowserItemDto
|
|||||||
public bool CanTranscode { get; init; }
|
public bool CanTranscode { get; init; }
|
||||||
|
|
||||||
public bool CanPreview { 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
|
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; }
|
||||||
|
}
|
||||||
@@ -166,3 +166,23 @@ public sealed class RecordSessionDeletionBatchResult
|
|||||||
|
|
||||||
public required IReadOnlyList<string> Warnings { get; init; }
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StorageStatusDto
|
||||||
|
{
|
||||||
|
public bool HasEnoughSpace { get; init; }
|
||||||
|
public string Message { get; init; } = string.Empty;
|
||||||
|
public long AvailableBytes { 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,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,147 @@
|
|||||||
|
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 independent queries in parallel for efficiency
|
||||||
|
var activeRecordingTask = _recordSessionRepository.CountByStatusAsync(RecordSessionStatus.Running, cancellationToken);
|
||||||
|
var liveRoomCountTask = _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Live, cancellationToken);
|
||||||
|
var offlineRoomCountTask = _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Offline, cancellationToken);
|
||||||
|
var totalRoomCountTask = _liveRoomRepository.CountAsync(cancellationToken);
|
||||||
|
var activeSessionCountTask = _recordSessionRepository.CountActiveAsync(cancellationToken);
|
||||||
|
var recentErrorCountTask = _systemLogRepository.CountRecentErrorsAsync(recentErrorSince, cancellationToken);
|
||||||
|
var todayDurationTask = _recordTaskRepository.SumDurationSecondsAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||||
|
var todayAggregateTask = _recordResultRepository.GetTodayAggregateAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||||
|
var recentSessionsTask = _recordSessionRepository.ListRecentAsync(5, cancellationToken);
|
||||||
|
var todaySessionsTask = _recordSessionRepository.ListInDateRangeAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||||
|
var settingsTask = _systemSettingsService.GetAsync(cancellationToken);
|
||||||
|
|
||||||
|
await Task.WhenAll(
|
||||||
|
activeRecordingTask,
|
||||||
|
liveRoomCountTask,
|
||||||
|
offlineRoomCountTask,
|
||||||
|
totalRoomCountTask,
|
||||||
|
activeSessionCountTask,
|
||||||
|
recentErrorCountTask,
|
||||||
|
todayDurationTask,
|
||||||
|
todayAggregateTask,
|
||||||
|
recentSessionsTask,
|
||||||
|
todaySessionsTask,
|
||||||
|
(Task)settingsTask
|
||||||
|
);
|
||||||
|
|
||||||
|
var (todayTotalBytes, todayTotalDanmaku) = todayAggregateTask.Result;
|
||||||
|
var settings = settingsTask.Result;
|
||||||
|
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
|
||||||
|
|
||||||
|
return new DashboardDto
|
||||||
|
{
|
||||||
|
ActiveRecordingCount = activeRecordingTask.Result,
|
||||||
|
LiveRoomCount = liveRoomCountTask.Result,
|
||||||
|
OfflineRoomCount = offlineRoomCountTask.Result,
|
||||||
|
TotalRoomCount = totalRoomCountTask.Result,
|
||||||
|
TodayRecordingSeconds = todayDurationTask.Result,
|
||||||
|
TodayDataBytes = todayTotalBytes,
|
||||||
|
TodayDanmakuCount = todayTotalDanmaku,
|
||||||
|
ActiveSessionCount = activeSessionCountTask.Result,
|
||||||
|
RecentErrorCount = recentErrorCountTask.Result,
|
||||||
|
StorageStatus = new StorageStatusDto
|
||||||
|
{
|
||||||
|
HasEnoughSpace = storageCheck.HasEnoughSpace,
|
||||||
|
Message = storageCheck.Message ?? string.Empty,
|
||||||
|
AvailableBytes = storageCheck.AvailableBytes
|
||||||
|
},
|
||||||
|
RecentSessions = recentSessionsTask.Result
|
||||||
|
.Select(MapRecentSession)
|
||||||
|
.ToList(),
|
||||||
|
TopRooms = ComputeTopRooms(todaySessionsTask.Result)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ISystemSettingsService _systemSettingsService;
|
||||||
private readonly IFfmpegService _ffmpegService;
|
private readonly IFfmpegService _ffmpegService;
|
||||||
|
private readonly IVideoMetadataService _videoMetadataService;
|
||||||
|
|
||||||
public MediaBrowserService(
|
public MediaBrowserService(
|
||||||
ISystemSettingsService systemSettingsService,
|
ISystemSettingsService systemSettingsService,
|
||||||
IFfmpegService ffmpegService)
|
IFfmpegService ffmpegService,
|
||||||
|
IVideoMetadataService videoMetadataService)
|
||||||
{
|
{
|
||||||
_systemSettingsService = systemSettingsService;
|
_systemSettingsService = systemSettingsService;
|
||||||
_ffmpegService = ffmpegService;
|
_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 settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
var rootPath = ResolveOutputRoot(settings.OutputRoot);
|
var rootPath = ResolveOutputRoot(settings.OutputRoot);
|
||||||
@@ -50,28 +53,56 @@ public sealed class MediaBrowserService
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
var files = Directory
|
var items = new List<MediaBrowserItemDto>();
|
||||||
.EnumerateFiles(targetPath)
|
items.AddRange(directories);
|
||||||
.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 = directories
|
foreach (var filePath in Directory.EnumerateFiles(targetPath))
|
||||||
.Concat(files)
|
{
|
||||||
|
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")
|
.OrderBy(static item => item.Type != "directory")
|
||||||
.ThenBy(static item => item.Name, StringComparer.OrdinalIgnoreCase)
|
.ThenBy(static item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ public sealed class LiveRoomRepository : ILiveRoomRepository
|
|||||||
.OrderByDescending(static item => item.UpdatedAt)
|
.OrderByDescending(static item => item.UpdatedAt)
|
||||||
.ToList();
|
.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) =>
|
public Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) =>
|
||||||
_dbContext.LiveRooms.AddAsync(liveRoom, cancellationToken).AsTask();
|
_dbContext.LiveRooms.AddAsync(liveRoom, cancellationToken).AsTask();
|
||||||
|
|
||||||
@@ -130,6 +136,12 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
|
|||||||
(item.Status == RecordTaskStatus.Starting || item.Status == RecordTaskStatus.Running),
|
(item.Status == RecordTaskStatus.Starting || item.Status == RecordTaskStatus.Running),
|
||||||
cancellationToken);
|
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 AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) =>
|
public Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) =>
|
||||||
_dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask();
|
_dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask();
|
||||||
|
|
||||||
@@ -195,6 +207,32 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
|
|||||||
item.Status == RecordSessionStatus.Stopping),
|
item.Status == RecordSessionStatus.Stopping),
|
||||||
cancellationToken);
|
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) =>
|
public Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default) =>
|
||||||
_dbContext.RecordSessions.AddAsync(recordSession, cancellationToken).AsTask();
|
_dbContext.RecordSessions.AddAsync(recordSession, cancellationToken).AsTask();
|
||||||
|
|
||||||
@@ -213,6 +251,21 @@ public sealed class RecordResultRepository : IRecordResultRepository
|
|||||||
public Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default) =>
|
public Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default) =>
|
||||||
_dbContext.RecordResults.FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken);
|
_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 AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) =>
|
public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) =>
|
||||||
_dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask();
|
_dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask();
|
||||||
|
|
||||||
@@ -310,6 +363,9 @@ public sealed class SystemLogRepository : ISystemLogRepository
|
|||||||
.ToListAsync(cancellationToken);
|
.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 void RemoveRange(IEnumerable<SystemLogEntry> entries) => _dbContext.SystemLogEntries.RemoveRange(entries);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using LiveRecorder.Application.Abstractions.Logging;
|
using LiveRecorder.Application.Abstractions.Logging;
|
||||||
using LiveRecorder.Application.Abstractions.Notifications;
|
using LiveRecorder.Application.Abstractions.Notifications;
|
||||||
using LiveRecorder.Application.Abstractions.Platforms;
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
@@ -56,6 +57,70 @@ public sealed partial class FfmpegService
|
|||||||
{
|
{
|
||||||
_ = ValidateRuntimeSourceFailureAsync(runtime, line);
|
_ = 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)
|
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 ILiveDanmakuConnection? DanmakuConnection { get; set; }
|
||||||
public Task? DanmakuPumpTask { get; set; }
|
public Task? DanmakuPumpTask { get; set; }
|
||||||
private List<string> CurrentRecorderSegmentPaths { get; } = [];
|
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 RuntimeSourceFailureSync { get; } = new();
|
||||||
private object RecentOutputSync { get; } = new();
|
private object RecentOutputSync { get; } = new();
|
||||||
private Queue<string> RecentOutputLines { get; } = new();
|
private Queue<string> RecentOutputLines { get; } = new();
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ public sealed partial class FfmpegService
|
|||||||
string? selectedVideoCodec,
|
string? selectedVideoCodec,
|
||||||
FfmpegInputOptionProfile inputOptionProfile)
|
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);
|
var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode);
|
||||||
|
|
||||||
if (IsHttpInput(streamUrl))
|
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";
|
||||||
|
}
|
||||||
@@ -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")]
|
[HttpGet("browser")]
|
||||||
public async Task<ActionResult<MediaBrowserResponseDto>> Browse(
|
public async Task<ActionResult<MediaBrowserResponseDto>> Browse(
|
||||||
[FromQuery] string? path,
|
[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")]
|
[HttpGet("file")]
|
||||||
@@ -37,6 +38,55 @@ public sealed class MediaBrowserController : ControllerBase
|
|||||||
: PhysicalFile(filePath, contentType, enableRangeProcessing: contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase));
|
: 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")]
|
[HttpPost("transcode-file")]
|
||||||
public async Task<ActionResult<TranscodeMediaFileResultDto>> TranscodeFile(
|
public async Task<ActionResult<TranscodeMediaFileResultDto>> TranscodeFile(
|
||||||
[FromBody] TranscodeMediaFileRequest request,
|
[FromBody] TranscodeMediaFileRequest request,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using LiveRecorder.Application.Abstractions.Persistence;
|
||||||
using LiveRecorder.Application.Models.Cleanup;
|
using LiveRecorder.Application.Models.Cleanup;
|
||||||
using LiveRecorder.Application.Models.RecordTasks;
|
using LiveRecorder.Application.Models.RecordTasks;
|
||||||
using LiveRecorder.Application.Services;
|
using LiveRecorder.Application.Services;
|
||||||
using LiveRecorder.Application.Abstractions.Recording;
|
using LiveRecorder.Application.Abstractions.Recording;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
using LiveRecorder.Infrastructure.Services;
|
using LiveRecorder.Infrastructure.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -111,4 +113,61 @@ public sealed class RecordSessionsController : ControllerBase
|
|||||||
var result = await _danmakuService.GetSessionDanmakuAsync(id, cancellationToken);
|
var result = await _danmakuService.GetSessionDanmakuAsync(id, cancellationToken);
|
||||||
return result is null ? NotFound() : Ok(result);
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ builder.Services.AddScoped<RecordSessionService>();
|
|||||||
builder.Services.AddScoped<TranscodeTaskService>();
|
builder.Services.AddScoped<TranscodeTaskService>();
|
||||||
builder.Services.AddScoped<MediaBrowserService>();
|
builder.Services.AddScoped<MediaBrowserService>();
|
||||||
builder.Services.AddScoped<SessionAnalyticsService>();
|
builder.Services.AddScoped<SessionAnalyticsService>();
|
||||||
|
builder.Services.AddScoped<DashboardService>();
|
||||||
builder.Services.AddScoped<RecoveryService>();
|
builder.Services.AddScoped<RecoveryService>();
|
||||||
builder.Services.AddScoped<RecordSessionCleanupResolver>();
|
builder.Services.AddScoped<RecordSessionCleanupResolver>();
|
||||||
builder.Services.AddScoped<CleanupOperationCoordinator>();
|
builder.Services.AddScoped<CleanupOperationCoordinator>();
|
||||||
@@ -184,6 +185,8 @@ builder.Services.AddScoped<PlatformHttpClientFactory>();
|
|||||||
builder.Services.AddScoped<PlatformHttpRequestService>();
|
builder.Services.AddScoped<PlatformHttpRequestService>();
|
||||||
builder.Services.AddScoped<RecordUploadService>();
|
builder.Services.AddScoped<RecordUploadService>();
|
||||||
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
|
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
|
||||||
|
builder.Services.AddScoped<IVideoMetadataService, FfmpegVideoMetadataService>();
|
||||||
|
builder.Services.AddScoped<BandwidthStatisticsService>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
|
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user