feat: add recovery center and daily reviews
This commit is contained in:
@@ -263,8 +263,8 @@ async function handleLogout() {
|
|||||||
/>
|
/>
|
||||||
<div class="app-topbar__copy">
|
<div class="app-topbar__copy">
|
||||||
<div class="app-topbar__eyebrow">Control Room</div>
|
<div class="app-topbar__eyebrow">Control Room</div>
|
||||||
<div class="app-topbar__title">{{ pageMeta.title }}</div>
|
<div class="app-topbar__title">Live Recorder</div>
|
||||||
<div class="app-topbar__subtitle">{{ pageMeta.subtitle }}</div>
|
<div class="app-topbar__subtitle">专业直播录制运维控制台</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useViewport } from "@/composables/useViewport";
|
import { useViewport } from "@/composables/useViewport";
|
||||||
|
import axios from "axios";
|
||||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||||
import type { DailyReviewReport } from "@/types";
|
import type { DailyReviewReport } from "@/types";
|
||||||
|
|
||||||
@@ -28,7 +29,10 @@ async function loadReport() {
|
|||||||
});
|
});
|
||||||
report.value = data;
|
report.value = data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = getApiErrorMessage(error, "回顾日报加载失败,请稍后重试。");
|
loadError.value =
|
||||||
|
axios.isAxiosError(error) && error.response?.status === 404
|
||||||
|
? "当前后端还没有部署回顾日报接口。请先同步更新并重启后端,再打开这个页面。"
|
||||||
|
: getApiErrorMessage(error, "回顾日报加载失败,请稍后重试。");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
|
import axios from "axios";
|
||||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||||
import { useViewport } from "@/composables/useViewport";
|
import { useViewport } from "@/composables/useViewport";
|
||||||
import type {
|
import type {
|
||||||
@@ -31,7 +32,10 @@ async function loadOverview() {
|
|||||||
const { data } = await apiClient.get<RecoveryOverview>("/recovery");
|
const { data } = await apiClient.get<RecoveryOverview>("/recovery");
|
||||||
overview.value = data;
|
overview.value = data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = getApiErrorMessage(error, "恢复中心加载失败,请稍后重试。");
|
loadError.value =
|
||||||
|
axios.isAxiosError(error) && error.response?.status === 404
|
||||||
|
? "当前后端还没有部署恢复中心接口。请先同步更新并重启后端,再打开这个页面。"
|
||||||
|
: getApiErrorMessage(error, "恢复中心加载失败,请稍后重试。");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
using LiveRecorder.Application.Models.Settings;
|
||||||
|
using LiveRecorder.Domain.Entities;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Application.Abstractions.Notifications;
|
||||||
|
|
||||||
|
public interface IWebhookNotificationService
|
||||||
|
{
|
||||||
|
Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task SendExceptionAsync(
|
||||||
|
string source,
|
||||||
|
string summary,
|
||||||
|
string? detail = null,
|
||||||
|
LiveRoom? liveRoom = null,
|
||||||
|
RecordTask? recordTask = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<WebhookTestResultDto> SendTestAsync(
|
||||||
|
SendTestWebhookRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -79,6 +79,8 @@ public interface ISystemLogRepository
|
|||||||
{
|
{
|
||||||
Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default);
|
Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<SystemLogEntry>> ListAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<IReadOnlyList<SystemLogEntry>> ListByRecordTaskIdsAsync(
|
Task<IReadOnlyList<SystemLogEntry>> ListByRecordTaskIdsAsync(
|
||||||
IReadOnlyCollection<Guid> recordTaskIds,
|
IReadOnlyCollection<Guid> recordTaskIds,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using LiveRecorder.Domain.Entities;
|
using LiveRecorder.Domain.Entities;
|
||||||
|
using LiveRecorder.Application.Models.Settings;
|
||||||
|
|
||||||
namespace LiveRecorder.Application.Abstractions.Scripting;
|
namespace LiveRecorder.Application.Abstractions.Scripting;
|
||||||
|
|
||||||
@@ -16,5 +17,8 @@ public interface IEventScriptService
|
|||||||
string segmentFilePath,
|
string segmentFilePath,
|
||||||
DateTimeOffset occurredAt,
|
DateTimeOffset occurredAt,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
|
||||||
|
|
||||||
|
Task<EventScriptTestResultDto> TestAsync(
|
||||||
|
TestEventScriptRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace LiveRecorder.Application.Common;
|
||||||
|
|
||||||
|
public static class AutoStartDecisionCodes
|
||||||
|
{
|
||||||
|
public const string Started = "started";
|
||||||
|
|
||||||
|
public const string SkippedDisabled = "skipped_disabled";
|
||||||
|
|
||||||
|
public const string SkippedStorage = "skipped_storage";
|
||||||
|
|
||||||
|
public const string SkippedActiveSession = "skipped_active_session";
|
||||||
|
|
||||||
|
public const string SkippedOffline = "skipped_offline";
|
||||||
|
|
||||||
|
public const string FailedStartup = "failed_startup";
|
||||||
|
}
|
||||||
@@ -116,6 +116,14 @@ public sealed class LiveRoomDto
|
|||||||
|
|
||||||
public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; }
|
public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; }
|
||||||
|
|
||||||
|
public string? LastAutoStartDecisionCode { get; init; }
|
||||||
|
|
||||||
|
public string? LastAutoStartDecisionSummary { get; init; }
|
||||||
|
|
||||||
|
public string? LastAutoStartDecisionDetail { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastAutoStartDecisionAt { get; init; }
|
||||||
|
|
||||||
public DateTimeOffset? LastCheckedAt { get; init; }
|
public DateTimeOffset? LastCheckedAt { get; init; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|||||||
@@ -48,9 +48,81 @@ public sealed class RecordSessionDetailDto
|
|||||||
{
|
{
|
||||||
public required RecordSessionDto Session { get; init; }
|
public required RecordSessionDto Session { get; init; }
|
||||||
|
|
||||||
|
public required RecordSessionTimelineDto Timeline { get; init; }
|
||||||
|
|
||||||
public required IReadOnlyList<SystemLogDto> Logs { get; init; }
|
public required IReadOnlyList<SystemLogDto> Logs { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class RecordSessionTimelineDto
|
||||||
|
{
|
||||||
|
public DateTimeOffset AnchorAt { get; init; }
|
||||||
|
|
||||||
|
public double TotalDurationSeconds { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<RecordSessionTimelineSegmentDto> Segments { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<RecordSessionTimelineEventDto> Events { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<RecordSessionHeatBucketDto> HeatBuckets { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecordSessionTimelineSegmentDto
|
||||||
|
{
|
||||||
|
public Guid RecordTaskId { get; init; }
|
||||||
|
|
||||||
|
public int SegmentIndex { get; init; }
|
||||||
|
|
||||||
|
public required RecordTaskStatus Status { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset StartedAt { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset EndedAt { get; init; }
|
||||||
|
|
||||||
|
public double OffsetSeconds { get; init; }
|
||||||
|
|
||||||
|
public double DurationSeconds { get; init; }
|
||||||
|
|
||||||
|
public string? Label { get; init; }
|
||||||
|
|
||||||
|
public string? Detail { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecordSessionTimelineEventDto
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
|
||||||
|
public required string Layer { get; init; }
|
||||||
|
|
||||||
|
public required string Title { get; init; }
|
||||||
|
|
||||||
|
public string? Detail { get; init; }
|
||||||
|
|
||||||
|
public Guid? RecordTaskId { get; init; }
|
||||||
|
|
||||||
|
public int? SegmentIndex { get; init; }
|
||||||
|
|
||||||
|
public SystemLogLevel? Level { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset OccurredAt { get; init; }
|
||||||
|
|
||||||
|
public double OffsetSeconds { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecordSessionHeatBucketDto
|
||||||
|
{
|
||||||
|
public Guid RecordTaskId { get; init; }
|
||||||
|
|
||||||
|
public int SegmentIndex { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset BucketStartedAt { get; init; }
|
||||||
|
|
||||||
|
public double OffsetSeconds { get; init; }
|
||||||
|
|
||||||
|
public double DurationSeconds { get; init; }
|
||||||
|
|
||||||
|
public int MessageCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class DeleteRecordSessionsRequest
|
public sealed class DeleteRecordSessionsRequest
|
||||||
{
|
{
|
||||||
public IReadOnlyList<Guid> SessionIds { get; set; } = [];
|
public IReadOnlyList<Guid> SessionIds { get; set; } = [];
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Application.Models.Recovery;
|
||||||
|
|
||||||
|
public sealed class RecoveryOverviewDto
|
||||||
|
{
|
||||||
|
public required StorageGuardStatusDto Storage { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<RecoverableLiveRoomDto> LiveRooms { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<RecoverableFinalizationDto> Finalizations { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StorageGuardStatusDto
|
||||||
|
{
|
||||||
|
public bool IsEnabled { get; init; }
|
||||||
|
|
||||||
|
public bool HasEnoughSpace { get; init; }
|
||||||
|
|
||||||
|
public required string CheckedPath { get; init; }
|
||||||
|
|
||||||
|
public long AvailableBytes { get; init; }
|
||||||
|
|
||||||
|
public long RequiredBytes { get; init; }
|
||||||
|
|
||||||
|
public required string Message { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecoverableLiveRoomDto
|
||||||
|
{
|
||||||
|
public Guid LiveRoomId { get; init; }
|
||||||
|
|
||||||
|
public required string PlatformName { get; init; }
|
||||||
|
|
||||||
|
public required string RoomId { get; init; }
|
||||||
|
|
||||||
|
public string? Title { get; init; }
|
||||||
|
|
||||||
|
public string? AnchorName { get; init; }
|
||||||
|
|
||||||
|
public string? LastAutoStartDecisionCode { get; init; }
|
||||||
|
|
||||||
|
public string? LastAutoStartDecisionSummary { get; init; }
|
||||||
|
|
||||||
|
public string? LastAutoStartDecisionDetail { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastAutoStartDecisionAt { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastCheckedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecoverableFinalizationDto
|
||||||
|
{
|
||||||
|
public Guid RecordTaskId { get; init; }
|
||||||
|
|
||||||
|
public Guid RecordSessionId { get; init; }
|
||||||
|
|
||||||
|
public Guid LiveRoomId { get; init; }
|
||||||
|
|
||||||
|
public required string LiveRoomTitle { get; init; }
|
||||||
|
|
||||||
|
public required string RoomId { get; init; }
|
||||||
|
|
||||||
|
public required string PlatformName { get; init; }
|
||||||
|
|
||||||
|
public int SegmentIndex { get; init; }
|
||||||
|
|
||||||
|
public required RecordTaskStatus Status { get; init; }
|
||||||
|
|
||||||
|
public string? OutputFilePath { get; init; }
|
||||||
|
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? EndedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecoveryActionResultDto
|
||||||
|
{
|
||||||
|
public int RequestedCount { get; init; }
|
||||||
|
|
||||||
|
public int SuccessCount { get; init; }
|
||||||
|
|
||||||
|
public int FailedCount { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<string> Messages { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Application.Models.Reports;
|
||||||
|
|
||||||
|
public sealed class DailyReviewReportDto
|
||||||
|
{
|
||||||
|
public required string Date { get; init; }
|
||||||
|
|
||||||
|
public int UtcOffsetMinutes { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset WindowStartUtc { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset WindowEndUtc { get; init; }
|
||||||
|
|
||||||
|
public required DailyReviewSummaryDto Summary { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<DailyReviewRoomDto> Rooms { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<DailyReviewSessionHighlightDto> Highlights { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<DailyReviewMomentDto> Moments { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DailyReviewSummaryDto
|
||||||
|
{
|
||||||
|
public int ActiveLiveRoomCount { get; init; }
|
||||||
|
|
||||||
|
public int SessionCount { get; init; }
|
||||||
|
|
||||||
|
public int SegmentCount { get; init; }
|
||||||
|
|
||||||
|
public double TotalDurationSeconds { get; init; }
|
||||||
|
|
||||||
|
public int WarningCount { get; init; }
|
||||||
|
|
||||||
|
public int ErrorCount { get; init; }
|
||||||
|
|
||||||
|
public int TotalDanmakuCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DailyReviewRoomDto
|
||||||
|
{
|
||||||
|
public Guid LiveRoomId { get; init; }
|
||||||
|
|
||||||
|
public required string PlatformName { get; init; }
|
||||||
|
|
||||||
|
public required string RoomId { get; init; }
|
||||||
|
|
||||||
|
public string? Title { get; init; }
|
||||||
|
|
||||||
|
public string? AnchorName { get; init; }
|
||||||
|
|
||||||
|
public int SessionCount { get; init; }
|
||||||
|
|
||||||
|
public int SegmentCount { get; init; }
|
||||||
|
|
||||||
|
public double TotalDurationSeconds { get; init; }
|
||||||
|
|
||||||
|
public int WarningCount { get; init; }
|
||||||
|
|
||||||
|
public int ErrorCount { get; init; }
|
||||||
|
|
||||||
|
public int DanmakuCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DailyReviewSessionHighlightDto
|
||||||
|
{
|
||||||
|
public required string Key { get; init; }
|
||||||
|
|
||||||
|
public required string Label { get; init; }
|
||||||
|
|
||||||
|
public Guid RecordSessionId { get; init; }
|
||||||
|
|
||||||
|
public Guid LiveRoomId { get; init; }
|
||||||
|
|
||||||
|
public required string PlatformName { get; init; }
|
||||||
|
|
||||||
|
public required string RoomId { get; init; }
|
||||||
|
|
||||||
|
public required string LiveRoomTitle { get; init; }
|
||||||
|
|
||||||
|
public string? AnchorName { get; init; }
|
||||||
|
|
||||||
|
public required RecordSessionStatus Status { get; init; }
|
||||||
|
|
||||||
|
public int SegmentCount { get; init; }
|
||||||
|
|
||||||
|
public double DurationSeconds { get; init; }
|
||||||
|
|
||||||
|
public int DanmakuCount { get; init; }
|
||||||
|
|
||||||
|
public int WarningCount { get; init; }
|
||||||
|
|
||||||
|
public int ErrorCount { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? StartedAt { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? EndedAt { get; init; }
|
||||||
|
|
||||||
|
public string? Summary { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DailyReviewMomentDto
|
||||||
|
{
|
||||||
|
public Guid LiveRoomId { get; init; }
|
||||||
|
|
||||||
|
public Guid RecordSessionId { get; init; }
|
||||||
|
|
||||||
|
public Guid RecordTaskId { get; init; }
|
||||||
|
|
||||||
|
public int SegmentIndex { get; init; }
|
||||||
|
|
||||||
|
public required string PlatformName { get; init; }
|
||||||
|
|
||||||
|
public required string RoomId { get; init; }
|
||||||
|
|
||||||
|
public required string LiveRoomTitle { get; init; }
|
||||||
|
|
||||||
|
public string? AnchorName { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset BucketStartedAt { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset BucketEndedAt { get; init; }
|
||||||
|
|
||||||
|
public int DanmakuCount { get; init; }
|
||||||
|
}
|
||||||
@@ -81,6 +81,12 @@ public sealed class SystemSettingsDto
|
|||||||
|
|
||||||
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
public bool EnableRetentionCleanup { get; set; } = false;
|
||||||
|
|
||||||
|
public int RetentionDays { get; set; } = 30;
|
||||||
|
|
||||||
|
public bool RetentionDeleteFiles { get; set; } = false;
|
||||||
|
|
||||||
public bool EnableEmailNotification { get; set; } = false;
|
public bool EnableEmailNotification { get; set; } = false;
|
||||||
|
|
||||||
public string EmailSmtpHost { get; set; } = string.Empty;
|
public string EmailSmtpHost { get; set; } = string.Empty;
|
||||||
@@ -138,6 +144,18 @@ public sealed class SystemSettingsDto
|
|||||||
</div>
|
</div>
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
public bool EnableWebhookNotification { get; set; } = false;
|
||||||
|
|
||||||
|
public string WebhookUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string WebhookHeaders { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int WebhookTimeoutSeconds { get; set; } = 15;
|
||||||
|
|
||||||
|
public bool NotifyWebhookOnLiveStarted { get; set; } = true;
|
||||||
|
|
||||||
|
public bool NotifyWebhookOnException { get; set; } = true;
|
||||||
|
|
||||||
public string DouyinUserAgent { get; set; } =
|
public string DouyinUserAgent { get; set; } =
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
@@ -218,6 +236,12 @@ public sealed class UpdateSystemSettingsRequest
|
|||||||
|
|
||||||
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
public bool EnableRetentionCleanup { get; set; } = false;
|
||||||
|
|
||||||
|
public int RetentionDays { get; set; } = 30;
|
||||||
|
|
||||||
|
public bool RetentionDeleteFiles { get; set; } = false;
|
||||||
|
|
||||||
public bool EnableEmailNotification { get; set; } = false;
|
public bool EnableEmailNotification { get; set; } = false;
|
||||||
|
|
||||||
public string EmailSmtpHost { get; set; } = string.Empty;
|
public string EmailSmtpHost { get; set; } = string.Empty;
|
||||||
@@ -275,6 +299,18 @@ public sealed class UpdateSystemSettingsRequest
|
|||||||
</div>
|
</div>
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
public bool EnableWebhookNotification { get; set; } = false;
|
||||||
|
|
||||||
|
public string WebhookUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string WebhookHeaders { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int WebhookTimeoutSeconds { get; set; } = 15;
|
||||||
|
|
||||||
|
public bool NotifyWebhookOnLiveStarted { get; set; } = true;
|
||||||
|
|
||||||
|
public bool NotifyWebhookOnException { get; set; } = true;
|
||||||
|
|
||||||
public string DouyinUserAgent { get; set; } =
|
public string DouyinUserAgent { get; set; } =
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
@@ -336,3 +372,62 @@ public sealed class SendTestEmailRequest
|
|||||||
</div>
|
</div>
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class TestEventScriptRequest
|
||||||
|
{
|
||||||
|
public string EventType { get; set; } = "live_started";
|
||||||
|
|
||||||
|
public string ScriptMode { get; set; } = EventScriptSourceModes.Path;
|
||||||
|
|
||||||
|
public string ScriptPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ScriptContent { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int TimeoutSeconds { get; set; } = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class EventScriptTestResultDto
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public required string Message { get; init; }
|
||||||
|
|
||||||
|
public string? Detail { get; init; }
|
||||||
|
|
||||||
|
public string? CustomLogOutput { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SendTestWebhookRequest
|
||||||
|
{
|
||||||
|
public string WebhookUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string WebhookHeaders { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int WebhookTimeoutSeconds { get; set; } = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WebhookTestResultDto
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public required string Message { get; init; }
|
||||||
|
|
||||||
|
public string? Detail { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RetentionCleanupResultDto
|
||||||
|
{
|
||||||
|
public int DeletedSessionCount { get; init; }
|
||||||
|
|
||||||
|
public int DeletedTaskCount { get; init; }
|
||||||
|
|
||||||
|
public int DeletedResultCount { get; init; }
|
||||||
|
|
||||||
|
public int DeletedLogCount { get; init; }
|
||||||
|
|
||||||
|
public int DeletedFileCount { get; init; }
|
||||||
|
|
||||||
|
public int DeletedDanmakuFileCount { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<string> Warnings { get; init; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using LiveRecorder.Application.Abstractions.Persistence;
|
|||||||
using LiveRecorder.Application.Abstractions.Platforms;
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
using LiveRecorder.Application.Abstractions.Recording;
|
using LiveRecorder.Application.Abstractions.Recording;
|
||||||
using LiveRecorder.Application.Abstractions.Settings;
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Application.Common;
|
||||||
using LiveRecorder.Application.Models.LiveRooms;
|
using LiveRecorder.Application.Models.LiveRooms;
|
||||||
using LiveRecorder.Application.Models.RecordTasks;
|
using LiveRecorder.Application.Models.RecordTasks;
|
||||||
using LiveRecorder.Domain.Entities;
|
using LiveRecorder.Domain.Entities;
|
||||||
@@ -424,6 +425,13 @@ public sealed class LiveRoomService
|
|||||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
if (!settings.AutoStartRecordingOnLive)
|
if (!settings.AutoStartRecordingOnLive)
|
||||||
{
|
{
|
||||||
|
liveRoom.SetLastAutoStartDecision(
|
||||||
|
AutoStartDecisionCodes.SkippedDisabled,
|
||||||
|
"Auto-start skipped because automatic start is disabled.",
|
||||||
|
null,
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
await _systemLogService.WriteAsync(
|
await _systemLogService.WriteAsync(
|
||||||
SystemLogLevel.Info,
|
SystemLogLevel.Info,
|
||||||
"LiveRoom",
|
"LiveRoom",
|
||||||
@@ -447,6 +455,7 @@ public sealed class LiveRoomService
|
|||||||
{
|
{
|
||||||
LiveRoomId = liveRoom.Id
|
LiveRoomId = liveRoom.Id
|
||||||
},
|
},
|
||||||
|
trackAutoStartDecision: true,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -478,6 +487,10 @@ public sealed class LiveRoomService
|
|||||||
EffectiveSettings = _liveRoomRecordingSettingsResolver.BuildEffectiveDto(effectiveSettings),
|
EffectiveSettings = _liveRoomRecordingSettingsResolver.BuildEffectiveDto(effectiveSettings),
|
||||||
IsEnabled = room.IsEnabled,
|
IsEnabled = room.IsEnabled,
|
||||||
AvailabilityStatus = room.AvailabilityStatus,
|
AvailabilityStatus = room.AvailabilityStatus,
|
||||||
|
LastAutoStartDecisionCode = room.LastAutoStartDecisionCode,
|
||||||
|
LastAutoStartDecisionSummary = room.LastAutoStartDecisionSummary,
|
||||||
|
LastAutoStartDecisionDetail = room.LastAutoStartDecisionDetail,
|
||||||
|
LastAutoStartDecisionAt = room.LastAutoStartDecisionAt,
|
||||||
LastCheckedAt = room.LastCheckedAt,
|
LastCheckedAt = room.LastCheckedAt,
|
||||||
CreatedAt = room.CreatedAt,
|
CreatedAt = room.CreatedAt,
|
||||||
UpdatedAt = room.UpdatedAt
|
UpdatedAt = room.UpdatedAt
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ namespace LiveRecorder.Application.Services;
|
|||||||
public sealed class LiveRoomStatusService
|
public sealed class LiveRoomStatusService
|
||||||
{
|
{
|
||||||
private readonly IEmailNotificationService _emailNotificationService;
|
private readonly IEmailNotificationService _emailNotificationService;
|
||||||
|
private readonly IWebhookNotificationService _webhookNotificationService;
|
||||||
private readonly IEventScriptService _eventScriptService;
|
private readonly IEventScriptService _eventScriptService;
|
||||||
|
|
||||||
public LiveRoomStatusService(
|
public LiveRoomStatusService(
|
||||||
IEmailNotificationService emailNotificationService,
|
IEmailNotificationService emailNotificationService,
|
||||||
|
IWebhookNotificationService webhookNotificationService,
|
||||||
IEventScriptService eventScriptService)
|
IEventScriptService eventScriptService)
|
||||||
{
|
{
|
||||||
_emailNotificationService = emailNotificationService;
|
_emailNotificationService = emailNotificationService;
|
||||||
|
_webhookNotificationService = webhookNotificationService;
|
||||||
_eventScriptService = eventScriptService;
|
_eventScriptService = eventScriptService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +56,7 @@ public sealed class LiveRoomStatusService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
||||||
|
await _webhookNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
||||||
await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
|
await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
|
||||||
liveRoom.MarkLiveNotificationSent(observedAt);
|
liveRoom.MarkLiveNotificationSent(observedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using LiveRecorder.Application.Common;
|
||||||
using LiveRecorder.Application.Abstractions.Logging;
|
using LiveRecorder.Application.Abstractions.Logging;
|
||||||
using LiveRecorder.Application.Abstractions.Notifications;
|
using LiveRecorder.Application.Abstractions.Notifications;
|
||||||
using LiveRecorder.Application.Abstractions.Persistence;
|
using LiveRecorder.Application.Abstractions.Persistence;
|
||||||
@@ -25,6 +26,7 @@ public sealed class RecordService
|
|||||||
private readonly ISystemSettingsService _systemSettingsService;
|
private readonly ISystemSettingsService _systemSettingsService;
|
||||||
private readonly ISystemLogService _systemLogService;
|
private readonly ISystemLogService _systemLogService;
|
||||||
private readonly IEmailNotificationService _emailNotificationService;
|
private readonly IEmailNotificationService _emailNotificationService;
|
||||||
|
private readonly IWebhookNotificationService _webhookNotificationService;
|
||||||
private readonly LiveRoomStatusService _liveRoomStatusService;
|
private readonly LiveRoomStatusService _liveRoomStatusService;
|
||||||
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
|
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
|
||||||
private readonly IStorageGuardService _storageGuardService;
|
private readonly IStorageGuardService _storageGuardService;
|
||||||
@@ -42,6 +44,7 @@ public sealed class RecordService
|
|||||||
ISystemSettingsService systemSettingsService,
|
ISystemSettingsService systemSettingsService,
|
||||||
ISystemLogService systemLogService,
|
ISystemLogService systemLogService,
|
||||||
IEmailNotificationService emailNotificationService,
|
IEmailNotificationService emailNotificationService,
|
||||||
|
IWebhookNotificationService webhookNotificationService,
|
||||||
LiveRoomStatusService liveRoomStatusService,
|
LiveRoomStatusService liveRoomStatusService,
|
||||||
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
|
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
|
||||||
IStorageGuardService storageGuardService,
|
IStorageGuardService storageGuardService,
|
||||||
@@ -58,6 +61,7 @@ public sealed class RecordService
|
|||||||
_systemSettingsService = systemSettingsService;
|
_systemSettingsService = systemSettingsService;
|
||||||
_systemLogService = systemLogService;
|
_systemLogService = systemLogService;
|
||||||
_emailNotificationService = emailNotificationService;
|
_emailNotificationService = emailNotificationService;
|
||||||
|
_webhookNotificationService = webhookNotificationService;
|
||||||
_liveRoomStatusService = liveRoomStatusService;
|
_liveRoomStatusService = liveRoomStatusService;
|
||||||
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
|
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
|
||||||
_storageGuardService = storageGuardService;
|
_storageGuardService = storageGuardService;
|
||||||
@@ -114,7 +118,13 @@ public sealed class RecordService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RecordTaskDto> StartAsync(StartRecordTaskRequest request, CancellationToken cancellationToken = default)
|
public Task<RecordTaskDto> StartAsync(StartRecordTaskRequest request, CancellationToken cancellationToken = default) =>
|
||||||
|
StartAsync(request, trackAutoStartDecision: false, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<RecordTaskDto> StartAsync(
|
||||||
|
StartRecordTaskRequest request,
|
||||||
|
bool trackAutoStartDecision,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
@@ -123,6 +133,16 @@ public sealed class RecordService
|
|||||||
|
|
||||||
if (!liveRoom.IsEnabled)
|
if (!liveRoom.IsEnabled)
|
||||||
{
|
{
|
||||||
|
if (trackAutoStartDecision)
|
||||||
|
{
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.SkippedDisabled,
|
||||||
|
"Auto-start skipped because the live room is disabled.",
|
||||||
|
detail: null,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
throw new InvalidOperationException("The live room is disabled. Enable it before starting a recording.");
|
throw new InvalidOperationException("The live room is disabled. Enable it before starting a recording.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +157,17 @@ public sealed class RecordService
|
|||||||
liveRoomId: liveRoom.Id,
|
liveRoomId: liveRoom.Id,
|
||||||
recordSessionId: activeSession.Id,
|
recordSessionId: activeSession.Id,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (trackAutoStartDecision)
|
||||||
|
{
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.SkippedActiveSession,
|
||||||
|
"Auto-start skipped because an active recording session already exists.",
|
||||||
|
$"activeSessionId={activeSession.Id}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
throw new InvalidOperationException("An active recording session already exists for the live room.");
|
throw new InvalidOperationException("An active recording session already exists for the live room.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +182,17 @@ public sealed class RecordService
|
|||||||
storageCheck.Message,
|
storageCheck.Message,
|
||||||
liveRoomId: liveRoom.Id,
|
liveRoomId: liveRoom.Id,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (trackAutoStartDecision)
|
||||||
|
{
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.SkippedStorage,
|
||||||
|
"Auto-start skipped because storage is below threshold.",
|
||||||
|
storageCheck.Message,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
throw new InvalidOperationException(storageCheck.Message);
|
throw new InvalidOperationException(storageCheck.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +222,15 @@ public sealed class RecordService
|
|||||||
{
|
{
|
||||||
initialTask.MarkFailed("The live room is currently offline.", now);
|
initialTask.MarkFailed("The live room is currently offline.", now);
|
||||||
recordSession.MarkFailed("The live room is currently offline.", now);
|
recordSession.MarkFailed("The live room is currently offline.", now);
|
||||||
|
if (trackAutoStartDecision)
|
||||||
|
{
|
||||||
|
ApplyAutoStartDecision(
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.SkippedOffline,
|
||||||
|
"Auto-start skipped because the live room is offline.",
|
||||||
|
detail: null);
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
await _systemLogService.WriteAsync(
|
await _systemLogService.WriteAsync(
|
||||||
SystemLogLevel.Warning,
|
SystemLogLevel.Warning,
|
||||||
@@ -218,6 +269,16 @@ public sealed class RecordService
|
|||||||
initialTask.MarkRunning(DateTimeOffset.UtcNow);
|
initialTask.MarkRunning(DateTimeOffset.UtcNow);
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (trackAutoStartDecision)
|
||||||
|
{
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.Started,
|
||||||
|
"Auto-start created a recording session.",
|
||||||
|
$"sessionId={recordSession.Id}; taskId={initialTask.Id}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
await _systemLogService.WriteAsync(
|
await _systemLogService.WriteAsync(
|
||||||
SystemLogLevel.Info,
|
SystemLogLevel.Info,
|
||||||
"RecordSession",
|
"RecordSession",
|
||||||
@@ -232,6 +293,15 @@ public sealed class RecordService
|
|||||||
{
|
{
|
||||||
initialTask.MarkFailed(ex.Message, DateTimeOffset.UtcNow);
|
initialTask.MarkFailed(ex.Message, DateTimeOffset.UtcNow);
|
||||||
recordSession.MarkFailed(ex.Message, DateTimeOffset.UtcNow);
|
recordSession.MarkFailed(ex.Message, DateTimeOffset.UtcNow);
|
||||||
|
if (trackAutoStartDecision)
|
||||||
|
{
|
||||||
|
ApplyAutoStartDecision(
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.FailedStartup,
|
||||||
|
"Auto-start failed while creating a recording session.",
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
await _systemLogService.WriteAsync(
|
await _systemLogService.WriteAsync(
|
||||||
@@ -251,6 +321,13 @@ public sealed class RecordService
|
|||||||
liveRoom,
|
liveRoom,
|
||||||
initialTask,
|
initialTask,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await _webhookNotificationService.SendExceptionAsync(
|
||||||
|
"RecordSession",
|
||||||
|
"Recording session startup failed.",
|
||||||
|
ex.ToString(),
|
||||||
|
liveRoom,
|
||||||
|
initialTask,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return RecordModelMapper.MapTask(initialTask);
|
return RecordModelMapper.MapTask(initialTask);
|
||||||
@@ -484,6 +561,16 @@ public sealed class RecordService
|
|||||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
if (!settings.AutoStartRecordingOnLive)
|
if (!settings.AutoStartRecordingOnLive)
|
||||||
{
|
{
|
||||||
|
foreach (var liveRoomId in liveRoomIds)
|
||||||
|
{
|
||||||
|
await TryUpdateAutoStartDecisionAsync(
|
||||||
|
liveRoomId,
|
||||||
|
AutoStartDecisionCodes.SkippedDisabled,
|
||||||
|
"Auto-start skipped because automatic start is disabled.",
|
||||||
|
detail: null,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +579,12 @@ public sealed class RecordService
|
|||||||
{
|
{
|
||||||
foreach (var liveRoomId in liveRoomIds)
|
foreach (var liveRoomId in liveRoomIds)
|
||||||
{
|
{
|
||||||
|
await TryUpdateAutoStartDecisionAsync(
|
||||||
|
liveRoomId,
|
||||||
|
AutoStartDecisionCodes.SkippedStorage,
|
||||||
|
"Auto-start skipped because storage is below threshold.",
|
||||||
|
storageCheck.Message,
|
||||||
|
cancellationToken);
|
||||||
await _systemLogService.WriteAsync(
|
await _systemLogService.WriteAsync(
|
||||||
SystemLogLevel.Warning,
|
SystemLogLevel.Warning,
|
||||||
"Storage",
|
"Storage",
|
||||||
@@ -507,13 +600,35 @@ public sealed class RecordService
|
|||||||
foreach (var liveRoomId in liveRoomIds)
|
foreach (var liveRoomId in liveRoomIds)
|
||||||
{
|
{
|
||||||
var liveRoom = await _liveRoomRepository.GetByIdAsync(liveRoomId, cancellationToken);
|
var liveRoom = await _liveRoomRepository.GetByIdAsync(liveRoomId, cancellationToken);
|
||||||
if (liveRoom is null || !liveRoom.IsEnabled || liveRoom.AvailabilityStatus != LiveRoomAvailabilityStatus.Live)
|
if (liveRoom is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!liveRoom.IsEnabled)
|
||||||
|
{
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.SkippedDisabled,
|
||||||
|
"Auto-start skipped because the live room is disabled.",
|
||||||
|
detail: null,
|
||||||
|
cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveRoom.AvailabilityStatus != LiveRoomAvailabilityStatus.Live)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken) is not null)
|
if (await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken) is not null)
|
||||||
{
|
{
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.SkippedActiveSession,
|
||||||
|
"Auto-start skipped because an active recording session already exists.",
|
||||||
|
detail: null,
|
||||||
|
cancellationToken);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +646,7 @@ public sealed class RecordService
|
|||||||
{
|
{
|
||||||
LiveRoomId = liveRoom.Id
|
LiveRoomId = liveRoom.Id
|
||||||
},
|
},
|
||||||
|
trackAutoStartDecision: true,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -546,6 +662,57 @@ public sealed class RecordService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task TryUpdateAutoStartDecisionAsync(
|
||||||
|
Guid liveRoomId,
|
||||||
|
string code,
|
||||||
|
string summary,
|
||||||
|
string? detail,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var liveRoom = await _liveRoomRepository.GetByIdAsync(liveRoomId, cancellationToken);
|
||||||
|
if (liveRoom is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpdateAutoStartDecisionAsync(liveRoom, code, summary, detail, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateAutoStartDecisionAsync(
|
||||||
|
LiveRoom liveRoom,
|
||||||
|
string code,
|
||||||
|
string summary,
|
||||||
|
string? detail,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ApplyAutoStartDecision(liveRoom, code, summary, detail);
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyAutoStartDecision(
|
||||||
|
LiveRoom liveRoom,
|
||||||
|
string code,
|
||||||
|
string summary,
|
||||||
|
string? detail)
|
||||||
|
{
|
||||||
|
liveRoom.SetLastAutoStartDecision(
|
||||||
|
Truncate(code, 64),
|
||||||
|
Truncate(summary, 256),
|
||||||
|
Truncate(detail, 2048),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Truncate(string? value, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = value.Trim();
|
||||||
|
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||||
|
}
|
||||||
|
|
||||||
internal static DeleteCompletedRecordTasksResultDto CreateEmptyDeleteResult() => new()
|
internal static DeleteCompletedRecordTasksResultDto CreateEmptyDeleteResult() => new()
|
||||||
{
|
{
|
||||||
DeletedTaskIds = [],
|
DeletedTaskIds = [],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class RecordSessionService
|
|||||||
private readonly IFfmpegService _ffmpegService;
|
private readonly IFfmpegService _ffmpegService;
|
||||||
private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService;
|
private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService;
|
||||||
private readonly ISystemLogService _systemLogService;
|
private readonly ISystemLogService _systemLogService;
|
||||||
|
private readonly SessionAnalyticsService _sessionAnalyticsService;
|
||||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ public sealed class RecordSessionService
|
|||||||
IFfmpegService ffmpegService,
|
IFfmpegService ffmpegService,
|
||||||
StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService,
|
StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService,
|
||||||
ISystemLogService systemLogService,
|
ISystemLogService systemLogService,
|
||||||
|
SessionAnalyticsService sessionAnalyticsService,
|
||||||
IServiceScopeFactory serviceScopeFactory,
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
{
|
||||||
@@ -41,6 +43,7 @@ public sealed class RecordSessionService
|
|||||||
_ffmpegService = ffmpegService;
|
_ffmpegService = ffmpegService;
|
||||||
_stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService;
|
_stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService;
|
||||||
_systemLogService = systemLogService;
|
_systemLogService = systemLogService;
|
||||||
|
_sessionAnalyticsService = sessionAnalyticsService;
|
||||||
_serviceScopeFactory = serviceScopeFactory;
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
}
|
}
|
||||||
@@ -77,13 +80,14 @@ public sealed class RecordSessionService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var logs = await _systemLogService.ListAsync(recordSessionId: id, take: 500, cancellationToken: cancellationToken);
|
var relatedLogs = await _sessionAnalyticsService.ListRelatedLogsAsync(session, cancellationToken);
|
||||||
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
|
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
|
||||||
session.RecordTasks.Select(static item => item.Id).ToArray());
|
session.RecordTasks.Select(static item => item.Id).ToArray());
|
||||||
return new RecordSessionDetailDto
|
return new RecordSessionDetailDto
|
||||||
{
|
{
|
||||||
Session = RecordModelMapper.MapSession(session, runtimeStates),
|
Session = RecordModelMapper.MapSession(session, runtimeStates),
|
||||||
Logs = logs
|
Timeline = await _sessionAnalyticsService.BuildTimelineAsync(session, relatedLogs, cancellationToken),
|
||||||
|
Logs = relatedLogs.Select(MapLog).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,4 +292,17 @@ public sealed class RecordSessionService
|
|||||||
var repository = scope.ServiceProvider.GetRequiredService<IRecordSessionRepository>();
|
var repository = scope.ServiceProvider.GetRequiredService<IRecordSessionRepository>();
|
||||||
return await repository.GetByIdAsync(id, cancellationToken);
|
return await repository.GetByIdAsync(id, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Models.Logs.SystemLogDto MapLog(SystemLogEntry item) => new()
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Level = item.Level,
|
||||||
|
Category = item.Category,
|
||||||
|
Message = item.Message,
|
||||||
|
Detail = item.Detail,
|
||||||
|
LiveRoomId = item.LiveRoomId,
|
||||||
|
RecordSessionId = item.RecordSessionId,
|
||||||
|
RecordTaskId = item.RecordTaskId,
|
||||||
|
CreatedAt = item.CreatedAt
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,805 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Xml;
|
||||||
|
using LiveRecorder.Application.Abstractions.Persistence;
|
||||||
|
using LiveRecorder.Application.Models.Reports;
|
||||||
|
using LiveRecorder.Application.Models.RecordTasks;
|
||||||
|
using LiveRecorder.Domain.Entities;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Application.Services;
|
||||||
|
|
||||||
|
public sealed class SessionAnalyticsService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan TimelineLeadTime = TimeSpan.FromMinutes(10);
|
||||||
|
private static readonly TimeSpan TimelineTailTime = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
private readonly IRecordSessionRepository _recordSessionRepository;
|
||||||
|
private readonly ISystemLogRepository _systemLogRepository;
|
||||||
|
|
||||||
|
public SessionAnalyticsService(
|
||||||
|
IRecordSessionRepository recordSessionRepository,
|
||||||
|
ISystemLogRepository systemLogRepository)
|
||||||
|
{
|
||||||
|
_recordSessionRepository = recordSessionRepository;
|
||||||
|
_systemLogRepository = systemLogRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DailyReviewReportDto> GetDailyReviewAsync(
|
||||||
|
DateOnly localDate,
|
||||||
|
int utcOffsetMinutes,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedOffsetMinutes = Math.Clamp(utcOffsetMinutes, -840, 840);
|
||||||
|
var (windowStartUtc, windowEndUtc) = GetUtcWindow(localDate, normalizedOffsetMinutes);
|
||||||
|
var sessions = await _recordSessionRepository.ListAsync(cancellationToken: cancellationToken);
|
||||||
|
var allLogs = await _systemLogRepository.ListAllAsync(cancellationToken);
|
||||||
|
|
||||||
|
var taskToSessionId = new Dictionary<Guid, Guid>();
|
||||||
|
var sessionById = new Dictionary<Guid, RecordSession>();
|
||||||
|
var overlappingSessionIds = new HashSet<Guid>();
|
||||||
|
var sessionSnapshots = new List<DailySessionSnapshot>();
|
||||||
|
var moments = new List<DailyReviewMomentDto>();
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
sessionById[session.Id] = session;
|
||||||
|
foreach (var task in session.RecordTasks)
|
||||||
|
{
|
||||||
|
taskToSessionId[task.Id] = session.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionStart = GetSessionStart(session);
|
||||||
|
var sessionEnd = GetSessionEnd(session);
|
||||||
|
if (!OverlapsWindow(sessionStart, sessionEnd, windowStartUtc, windowEndUtc))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlappingSessionIds.Add(session.Id);
|
||||||
|
var overlappingTasks = session.RecordTasks
|
||||||
|
.Where(task => OverlapsWindow(
|
||||||
|
GetTaskStart(task),
|
||||||
|
GetTaskEnd(task, sessionEnd),
|
||||||
|
windowStartUtc,
|
||||||
|
windowEndUtc))
|
||||||
|
.OrderBy(task => task.SegmentIndex)
|
||||||
|
.ThenBy(task => task.CreatedAt)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var danmakuCount = 0;
|
||||||
|
foreach (var task in overlappingTasks)
|
||||||
|
{
|
||||||
|
var taskBuckets = ReadDanmakuBuckets(
|
||||||
|
task,
|
||||||
|
GetTaskStart(task),
|
||||||
|
windowStartUtc,
|
||||||
|
windowEndUtc);
|
||||||
|
if (taskBuckets.Count > 0)
|
||||||
|
{
|
||||||
|
danmakuCount += taskBuckets.Sum(static bucket => bucket.MessageCount);
|
||||||
|
moments.AddRange(taskBuckets.Select(bucket => new DailyReviewMomentDto
|
||||||
|
{
|
||||||
|
LiveRoomId = session.LiveRoomId,
|
||||||
|
RecordSessionId = session.Id,
|
||||||
|
RecordTaskId = task.Id,
|
||||||
|
SegmentIndex = task.SegmentIndex,
|
||||||
|
PlatformName = ResolvePlatformName(session),
|
||||||
|
RoomId = ResolveRoomId(session),
|
||||||
|
LiveRoomTitle = ResolveLiveRoomTitle(session),
|
||||||
|
AnchorName = session.LiveRoom?.AnchorName,
|
||||||
|
BucketStartedAt = bucket.BucketStartedAt,
|
||||||
|
BucketEndedAt = bucket.BucketStartedAt.AddMinutes(1),
|
||||||
|
DanmakuCount = bucket.MessageCount
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawDanmakuCount = task.Result?.DanmakuMessageCount ?? 0;
|
||||||
|
if (rawDanmakuCount > 0 &&
|
||||||
|
IsFullyInsideWindow(GetTaskStart(task), GetTaskEnd(task, sessionEnd), windowStartUtc, windowEndUtc))
|
||||||
|
{
|
||||||
|
danmakuCount += rawDanmakuCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionSnapshots.Add(new DailySessionSnapshot(
|
||||||
|
Session: session,
|
||||||
|
DurationSeconds: CalculateOverlapSeconds(sessionStart, sessionEnd, windowStartUtc, windowEndUtc),
|
||||||
|
SegmentCount: overlappingTasks.Length,
|
||||||
|
DanmakuCount: danmakuCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
var logsInWindow = allLogs
|
||||||
|
.Where(log => log.CreatedAt >= windowStartUtc && log.CreatedAt < windowEndUtc)
|
||||||
|
.ToArray();
|
||||||
|
var warningCountsBySessionId = new Dictionary<Guid, int>();
|
||||||
|
var errorCountsBySessionId = new Dictionary<Guid, int>();
|
||||||
|
var warningCountsByRoomId = new Dictionary<Guid, int>();
|
||||||
|
var errorCountsByRoomId = new Dictionary<Guid, int>();
|
||||||
|
|
||||||
|
foreach (var log in logsInWindow)
|
||||||
|
{
|
||||||
|
var resolvedSessionId = ResolveSessionId(log, taskToSessionId);
|
||||||
|
if (resolvedSessionId.HasValue && overlappingSessionIds.Contains(resolvedSessionId.Value))
|
||||||
|
{
|
||||||
|
if (log.Level == SystemLogLevel.Warning)
|
||||||
|
{
|
||||||
|
Increment(warningCountsBySessionId, resolvedSessionId.Value);
|
||||||
|
}
|
||||||
|
else if (log.Level == SystemLogLevel.Error)
|
||||||
|
{
|
||||||
|
Increment(errorCountsBySessionId, resolvedSessionId.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedRoomId = ResolveLiveRoomId(log, resolvedSessionId, sessionById);
|
||||||
|
if (!resolvedRoomId.HasValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.Level == SystemLogLevel.Warning)
|
||||||
|
{
|
||||||
|
Increment(warningCountsByRoomId, resolvedRoomId.Value);
|
||||||
|
}
|
||||||
|
else if (log.Level == SystemLogLevel.Error)
|
||||||
|
{
|
||||||
|
Increment(errorCountsByRoomId, resolvedRoomId.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rooms = sessionSnapshots
|
||||||
|
.GroupBy(snapshot => snapshot.Session.LiveRoomId)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var representative = group
|
||||||
|
.OrderByDescending(static item => item.Session.StartedAt ?? item.Session.CreatedAt)
|
||||||
|
.First();
|
||||||
|
return new DailyReviewRoomDto
|
||||||
|
{
|
||||||
|
LiveRoomId = group.Key,
|
||||||
|
PlatformName = ResolvePlatformName(representative.Session),
|
||||||
|
RoomId = ResolveRoomId(representative.Session),
|
||||||
|
Title = representative.Session.LiveRoom?.Title,
|
||||||
|
AnchorName = representative.Session.LiveRoom?.AnchorName,
|
||||||
|
SessionCount = group.Count(),
|
||||||
|
SegmentCount = group.Sum(static item => item.SegmentCount),
|
||||||
|
TotalDurationSeconds = group.Sum(static item => item.DurationSeconds),
|
||||||
|
WarningCount = warningCountsByRoomId.GetValueOrDefault(group.Key),
|
||||||
|
ErrorCount = errorCountsByRoomId.GetValueOrDefault(group.Key),
|
||||||
|
DanmakuCount = group.Sum(static item => item.DanmakuCount)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderByDescending(static item => item.TotalDurationSeconds)
|
||||||
|
.ThenByDescending(static item => item.DanmakuCount)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var highlights = BuildHighlights(sessionSnapshots, warningCountsBySessionId, errorCountsBySessionId);
|
||||||
|
var totalWarningCount = logsInWindow.Count(static log => log.Level == SystemLogLevel.Warning);
|
||||||
|
var totalErrorCount = logsInWindow.Count(static log => log.Level == SystemLogLevel.Error);
|
||||||
|
|
||||||
|
return new DailyReviewReportDto
|
||||||
|
{
|
||||||
|
Date = localDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
UtcOffsetMinutes = normalizedOffsetMinutes,
|
||||||
|
WindowStartUtc = windowStartUtc,
|
||||||
|
WindowEndUtc = windowEndUtc,
|
||||||
|
Summary = new DailyReviewSummaryDto
|
||||||
|
{
|
||||||
|
ActiveLiveRoomCount = rooms.Count,
|
||||||
|
SessionCount = sessionSnapshots.Count,
|
||||||
|
SegmentCount = sessionSnapshots.Sum(static item => item.SegmentCount),
|
||||||
|
TotalDurationSeconds = sessionSnapshots.Sum(static item => item.DurationSeconds),
|
||||||
|
WarningCount = totalWarningCount,
|
||||||
|
ErrorCount = totalErrorCount,
|
||||||
|
TotalDanmakuCount = sessionSnapshots.Sum(static item => item.DanmakuCount)
|
||||||
|
},
|
||||||
|
Rooms = rooms,
|
||||||
|
Highlights = highlights,
|
||||||
|
Moments = moments
|
||||||
|
.OrderByDescending(static item => item.DanmakuCount)
|
||||||
|
.ThenBy(static item => item.BucketStartedAt)
|
||||||
|
.Take(12)
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SystemLogEntry>> ListRelatedLogsAsync(
|
||||||
|
RecordSession session,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var taskIds = session.RecordTasks
|
||||||
|
.Select(static item => item.Id)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var bySession = await _systemLogRepository.ListByRecordSessionIdsAsync([session.Id], cancellationToken);
|
||||||
|
var byTask = await _systemLogRepository.ListByRecordTaskIdsAsync(taskIds, cancellationToken);
|
||||||
|
|
||||||
|
return bySession
|
||||||
|
.Concat(byTask)
|
||||||
|
.GroupBy(static item => item.Id)
|
||||||
|
.Select(static group => group.First())
|
||||||
|
.OrderByDescending(static item => item.CreatedAt)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecordSessionTimelineDto> BuildTimelineAsync(
|
||||||
|
RecordSession session,
|
||||||
|
IReadOnlyList<SystemLogEntry> relatedLogs,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var anchorAt = GetSessionStart(session);
|
||||||
|
var sessionEnd = GetSessionEnd(session);
|
||||||
|
var segmentSnapshots = session.RecordTasks
|
||||||
|
.OrderBy(static item => item.SegmentIndex)
|
||||||
|
.ThenBy(static item => item.CreatedAt)
|
||||||
|
.Select(task =>
|
||||||
|
{
|
||||||
|
var startedAt = GetTaskStart(task);
|
||||||
|
var endedAt = GetTaskEnd(task, sessionEnd);
|
||||||
|
return new SegmentSnapshot(
|
||||||
|
Task: task,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
EndedAt: endedAt);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var heatBuckets = segmentSnapshots
|
||||||
|
.SelectMany(snapshot => ReadDanmakuBuckets(snapshot.Task, snapshot.StartedAt, null, null)
|
||||||
|
.Select(bucket => new RecordSessionHeatBucketDto
|
||||||
|
{
|
||||||
|
RecordTaskId = snapshot.Task.Id,
|
||||||
|
SegmentIndex = snapshot.Task.SegmentIndex,
|
||||||
|
BucketStartedAt = bucket.BucketStartedAt,
|
||||||
|
OffsetSeconds = 0,
|
||||||
|
DurationSeconds = 60,
|
||||||
|
MessageCount = bucket.MessageCount
|
||||||
|
}))
|
||||||
|
.OrderBy(static item => item.BucketStartedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var allLogs = await _systemLogRepository.ListAllAsync(cancellationToken);
|
||||||
|
var supplementalLiveRoomLogs = allLogs
|
||||||
|
.Where(log =>
|
||||||
|
log.LiveRoomId == session.LiveRoomId &&
|
||||||
|
log.CreatedAt >= anchorAt - TimelineLeadTime &&
|
||||||
|
log.CreatedAt <= sessionEnd + TimelineTailTime &&
|
||||||
|
IsRelevantLiveRoomTimelineLog(log))
|
||||||
|
.Where(log => relatedLogs.All(existing => existing.Id != log.Id))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var combinedLogs = relatedLogs
|
||||||
|
.Concat(supplementalLiveRoomLogs)
|
||||||
|
.GroupBy(static item => item.Id)
|
||||||
|
.Select(static group => group.First())
|
||||||
|
.OrderBy(static item => item.CreatedAt)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var maxEventTime = combinedLogs.Length == 0
|
||||||
|
? sessionEnd
|
||||||
|
: combinedLogs.Max(static item => item.CreatedAt);
|
||||||
|
var maxSegmentTime = segmentSnapshots.Count == 0
|
||||||
|
? sessionEnd
|
||||||
|
: segmentSnapshots.Max(static item => item.EndedAt);
|
||||||
|
var maxHeatTime = heatBuckets.Count == 0
|
||||||
|
? sessionEnd
|
||||||
|
: heatBuckets.Max(static item => item.BucketStartedAt.AddMinutes(1));
|
||||||
|
var timelineEnd = new[]
|
||||||
|
{
|
||||||
|
sessionEnd,
|
||||||
|
maxEventTime,
|
||||||
|
maxSegmentTime,
|
||||||
|
maxHeatTime
|
||||||
|
}.Max();
|
||||||
|
if (timelineEnd <= anchorAt)
|
||||||
|
{
|
||||||
|
timelineEnd = anchorAt.AddMinutes(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalDurationSeconds = Math.Max(60, (timelineEnd - anchorAt).TotalSeconds);
|
||||||
|
var segments = segmentSnapshots
|
||||||
|
.Select(snapshot => new RecordSessionTimelineSegmentDto
|
||||||
|
{
|
||||||
|
RecordTaskId = snapshot.Task.Id,
|
||||||
|
SegmentIndex = snapshot.Task.SegmentIndex,
|
||||||
|
Status = snapshot.Task.Status,
|
||||||
|
StartedAt = snapshot.StartedAt,
|
||||||
|
EndedAt = snapshot.EndedAt,
|
||||||
|
OffsetSeconds = CalculateOffsetSeconds(snapshot.StartedAt, anchorAt),
|
||||||
|
DurationSeconds = Math.Max(1, (snapshot.EndedAt - snapshot.StartedAt).TotalSeconds),
|
||||||
|
Label = Path.GetFileName(snapshot.Task.Result?.FilePath ?? snapshot.Task.OutputFilePath ?? string.Empty),
|
||||||
|
Detail = snapshot.Task.ErrorMessage ?? snapshot.Task.PostProcessDetail()
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
for (var i = 0; i < heatBuckets.Count; i++)
|
||||||
|
{
|
||||||
|
heatBuckets[i] = new RecordSessionHeatBucketDto
|
||||||
|
{
|
||||||
|
RecordTaskId = heatBuckets[i].RecordTaskId,
|
||||||
|
SegmentIndex = heatBuckets[i].SegmentIndex,
|
||||||
|
BucketStartedAt = heatBuckets[i].BucketStartedAt,
|
||||||
|
OffsetSeconds = CalculateOffsetSeconds(heatBuckets[i].BucketStartedAt, anchorAt),
|
||||||
|
DurationSeconds = heatBuckets[i].DurationSeconds,
|
||||||
|
MessageCount = heatBuckets[i].MessageCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var events = new List<RecordSessionTimelineEventDto>();
|
||||||
|
events.Add(new RecordSessionTimelineEventDto
|
||||||
|
{
|
||||||
|
Id = $"session-created-{session.Id}",
|
||||||
|
Layer = TimelineLayers.Session,
|
||||||
|
Title = "Session created",
|
||||||
|
Detail = ResolveLiveRoomTitle(session),
|
||||||
|
OccurredAt = session.CreatedAt,
|
||||||
|
OffsetSeconds = CalculateOffsetSeconds(session.CreatedAt, anchorAt)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.StartedAt.HasValue)
|
||||||
|
{
|
||||||
|
events.Add(new RecordSessionTimelineEventDto
|
||||||
|
{
|
||||||
|
Id = $"session-started-{session.Id}",
|
||||||
|
Layer = TimelineLayers.Session,
|
||||||
|
Title = "Recording started",
|
||||||
|
Detail = session.StreamUrl,
|
||||||
|
OccurredAt = session.StartedAt.Value,
|
||||||
|
OffsetSeconds = CalculateOffsetSeconds(session.StartedAt.Value, anchorAt)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.EndedAt.HasValue)
|
||||||
|
{
|
||||||
|
events.Add(new RecordSessionTimelineEventDto
|
||||||
|
{
|
||||||
|
Id = $"session-ended-{session.Id}",
|
||||||
|
Layer = TimelineLayers.Session,
|
||||||
|
Title = $"Session {session.Status}",
|
||||||
|
Detail = session.ErrorMessage,
|
||||||
|
OccurredAt = session.EndedAt.Value,
|
||||||
|
OffsetSeconds = CalculateOffsetSeconds(session.EndedAt.Value, anchorAt)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var log in combinedLogs)
|
||||||
|
{
|
||||||
|
var layer = ClassifyTimelineLayer(log);
|
||||||
|
if (layer is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relatedTask = log.RecordTaskId.HasValue
|
||||||
|
? session.RecordTasks.FirstOrDefault(task => task.Id == log.RecordTaskId.Value)
|
||||||
|
: null;
|
||||||
|
events.Add(new RecordSessionTimelineEventDto
|
||||||
|
{
|
||||||
|
Id = $"log-{log.Id}",
|
||||||
|
Layer = layer,
|
||||||
|
Title = log.Message,
|
||||||
|
Detail = log.Detail,
|
||||||
|
RecordTaskId = relatedTask?.Id,
|
||||||
|
SegmentIndex = relatedTask?.SegmentIndex,
|
||||||
|
Level = log.Level,
|
||||||
|
OccurredAt = log.CreatedAt,
|
||||||
|
OffsetSeconds = CalculateOffsetSeconds(log.CreatedAt, anchorAt)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RecordSessionTimelineDto
|
||||||
|
{
|
||||||
|
AnchorAt = anchorAt,
|
||||||
|
TotalDurationSeconds = totalDurationSeconds,
|
||||||
|
Segments = segments,
|
||||||
|
Events = events
|
||||||
|
.OrderBy(static item => item.OccurredAt)
|
||||||
|
.ThenBy(static item => item.Layer)
|
||||||
|
.ToList(),
|
||||||
|
HeatBuckets = heatBuckets
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<DailyReviewSessionHighlightDto> BuildHighlights(
|
||||||
|
IReadOnlyList<DailySessionSnapshot> snapshots,
|
||||||
|
IReadOnlyDictionary<Guid, int> warningCountsBySessionId,
|
||||||
|
IReadOnlyDictionary<Guid, int> errorCountsBySessionId)
|
||||||
|
{
|
||||||
|
if (snapshots.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var longest = snapshots
|
||||||
|
.OrderByDescending(static item => item.DurationSeconds)
|
||||||
|
.ThenByDescending(static item => item.SegmentCount)
|
||||||
|
.First();
|
||||||
|
var mostDanmaku = snapshots
|
||||||
|
.OrderByDescending(static item => item.DanmakuCount)
|
||||||
|
.ThenByDescending(static item => item.DurationSeconds)
|
||||||
|
.First();
|
||||||
|
var mostExceptional = snapshots
|
||||||
|
.OrderByDescending(item => item.Session.Status == RecordSessionStatus.Failed)
|
||||||
|
.ThenByDescending(item => errorCountsBySessionId.GetValueOrDefault(item.Session.Id))
|
||||||
|
.ThenByDescending(item => warningCountsBySessionId.GetValueOrDefault(item.Session.Id))
|
||||||
|
.ThenByDescending(static item => item.DurationSeconds)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
CreateHighlight(
|
||||||
|
"longest_session",
|
||||||
|
"Longest session",
|
||||||
|
longest,
|
||||||
|
warningCountsBySessionId.GetValueOrDefault(longest.Session.Id),
|
||||||
|
errorCountsBySessionId.GetValueOrDefault(longest.Session.Id),
|
||||||
|
$"Recorded {longest.DurationSeconds:F0}s across {longest.SegmentCount} segment(s)."),
|
||||||
|
CreateHighlight(
|
||||||
|
"most_danmaku",
|
||||||
|
"Most danmaku",
|
||||||
|
mostDanmaku,
|
||||||
|
warningCountsBySessionId.GetValueOrDefault(mostDanmaku.Session.Id),
|
||||||
|
errorCountsBySessionId.GetValueOrDefault(mostDanmaku.Session.Id),
|
||||||
|
$"Captured {mostDanmaku.DanmakuCount} danmaku event(s)."),
|
||||||
|
CreateHighlight(
|
||||||
|
"exceptional_session",
|
||||||
|
"Most exceptions",
|
||||||
|
mostExceptional,
|
||||||
|
warningCountsBySessionId.GetValueOrDefault(mostExceptional.Session.Id),
|
||||||
|
errorCountsBySessionId.GetValueOrDefault(mostExceptional.Session.Id),
|
||||||
|
errorCountsBySessionId.GetValueOrDefault(mostExceptional.Session.Id) > 0 ||
|
||||||
|
warningCountsBySessionId.GetValueOrDefault(mostExceptional.Session.Id) > 0
|
||||||
|
? $"Warnings={warningCountsBySessionId.GetValueOrDefault(mostExceptional.Session.Id)}, Errors={errorCountsBySessionId.GetValueOrDefault(mostExceptional.Session.Id)}"
|
||||||
|
: "No warnings or errors were recorded for this session.")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DailyReviewSessionHighlightDto CreateHighlight(
|
||||||
|
string key,
|
||||||
|
string label,
|
||||||
|
DailySessionSnapshot snapshot,
|
||||||
|
int warningCount,
|
||||||
|
int errorCount,
|
||||||
|
string summary) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Key = key,
|
||||||
|
Label = label,
|
||||||
|
RecordSessionId = snapshot.Session.Id,
|
||||||
|
LiveRoomId = snapshot.Session.LiveRoomId,
|
||||||
|
PlatformName = ResolvePlatformName(snapshot.Session),
|
||||||
|
RoomId = ResolveRoomId(snapshot.Session),
|
||||||
|
LiveRoomTitle = ResolveLiveRoomTitle(snapshot.Session),
|
||||||
|
AnchorName = snapshot.Session.LiveRoom?.AnchorName,
|
||||||
|
Status = snapshot.Session.Status,
|
||||||
|
SegmentCount = snapshot.SegmentCount,
|
||||||
|
DurationSeconds = snapshot.DurationSeconds,
|
||||||
|
DanmakuCount = snapshot.DanmakuCount,
|
||||||
|
WarningCount = warningCount,
|
||||||
|
ErrorCount = errorCount,
|
||||||
|
StartedAt = snapshot.Session.StartedAt,
|
||||||
|
EndedAt = snapshot.Session.EndedAt,
|
||||||
|
Summary = summary
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (DateTimeOffset WindowStartUtc, DateTimeOffset WindowEndUtc) GetUtcWindow(
|
||||||
|
DateOnly localDate,
|
||||||
|
int utcOffsetMinutes)
|
||||||
|
{
|
||||||
|
var offset = TimeSpan.FromMinutes(utcOffsetMinutes);
|
||||||
|
var localStart = new DateTimeOffset(localDate.ToDateTime(TimeOnly.MinValue), offset);
|
||||||
|
return (localStart.ToUniversalTime(), localStart.AddDays(1).ToUniversalTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool OverlapsWindow(
|
||||||
|
DateTimeOffset startedAt,
|
||||||
|
DateTimeOffset endedAt,
|
||||||
|
DateTimeOffset windowStartUtc,
|
||||||
|
DateTimeOffset windowEndUtc) =>
|
||||||
|
startedAt < windowEndUtc && endedAt > windowStartUtc;
|
||||||
|
|
||||||
|
private static bool IsFullyInsideWindow(
|
||||||
|
DateTimeOffset startedAt,
|
||||||
|
DateTimeOffset endedAt,
|
||||||
|
DateTimeOffset windowStartUtc,
|
||||||
|
DateTimeOffset windowEndUtc) =>
|
||||||
|
startedAt >= windowStartUtc && endedAt <= windowEndUtc;
|
||||||
|
|
||||||
|
private static double CalculateOverlapSeconds(
|
||||||
|
DateTimeOffset startedAt,
|
||||||
|
DateTimeOffset endedAt,
|
||||||
|
DateTimeOffset windowStartUtc,
|
||||||
|
DateTimeOffset windowEndUtc)
|
||||||
|
{
|
||||||
|
var overlapStart = startedAt > windowStartUtc ? startedAt : windowStartUtc;
|
||||||
|
var overlapEnd = endedAt < windowEndUtc ? endedAt : windowEndUtc;
|
||||||
|
return overlapEnd <= overlapStart ? 0 : (overlapEnd - overlapStart).TotalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid? ResolveSessionId(
|
||||||
|
SystemLogEntry log,
|
||||||
|
IReadOnlyDictionary<Guid, Guid> taskToSessionId)
|
||||||
|
{
|
||||||
|
if (log.RecordSessionId.HasValue)
|
||||||
|
{
|
||||||
|
return log.RecordSessionId.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.RecordTaskId.HasValue && taskToSessionId.TryGetValue(log.RecordTaskId.Value, out var sessionId))
|
||||||
|
{
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid? ResolveLiveRoomId(
|
||||||
|
SystemLogEntry log,
|
||||||
|
Guid? resolvedSessionId,
|
||||||
|
IReadOnlyDictionary<Guid, RecordSession> sessionById)
|
||||||
|
{
|
||||||
|
if (log.LiveRoomId.HasValue)
|
||||||
|
{
|
||||||
|
return log.LiveRoomId.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedSessionId.HasValue && sessionById.TryGetValue(resolvedSessionId.Value, out var session))
|
||||||
|
{
|
||||||
|
return session.LiveRoomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Increment(IDictionary<Guid, int> lookup, Guid key)
|
||||||
|
{
|
||||||
|
if (!lookup.TryAdd(key, 1))
|
||||||
|
{
|
||||||
|
lookup[key]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePlatformName(RecordSession session) =>
|
||||||
|
(session.LiveRoom?.Platform ?? LivePlatformType.Unknown).ToString();
|
||||||
|
|
||||||
|
private static string ResolveRoomId(RecordSession session) =>
|
||||||
|
session.LiveRoom?.RoomId ?? string.Empty;
|
||||||
|
|
||||||
|
private static string ResolveLiveRoomTitle(RecordSession session) =>
|
||||||
|
session.LiveRoom?.Title ??
|
||||||
|
session.LiveRoom?.AnchorName ??
|
||||||
|
session.LiveRoom?.RoomId ??
|
||||||
|
"Unknown Room";
|
||||||
|
|
||||||
|
private static DateTimeOffset GetSessionStart(RecordSession session) =>
|
||||||
|
session.StartedAt ?? session.CreatedAt;
|
||||||
|
|
||||||
|
private static DateTimeOffset GetSessionEnd(RecordSession session)
|
||||||
|
{
|
||||||
|
var effectiveEnd = session.EndedAt ?? DateTimeOffset.UtcNow;
|
||||||
|
var startedAt = GetSessionStart(session);
|
||||||
|
return effectiveEnd >= startedAt ? effectiveEnd : startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset GetTaskStart(RecordTask task) =>
|
||||||
|
task.StartedAt ?? task.CreatedAt;
|
||||||
|
|
||||||
|
private static DateTimeOffset GetTaskEnd(RecordTask task, DateTimeOffset fallbackEnd)
|
||||||
|
{
|
||||||
|
var effectiveEnd = task.EndedAt ?? fallbackEnd;
|
||||||
|
var startedAt = GetTaskStart(task);
|
||||||
|
return effectiveEnd >= startedAt ? effectiveEnd : startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CalculateOffsetSeconds(DateTimeOffset occurredAt, DateTimeOffset anchorAt) =>
|
||||||
|
Math.Max(0, (occurredAt - anchorAt).TotalSeconds);
|
||||||
|
|
||||||
|
private static string? ClassifyTimelineLayer(SystemLogEntry log)
|
||||||
|
{
|
||||||
|
if (string.Equals(log.Category, "Danmaku", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return TimelineLayers.Danmaku;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(log.Category, "Webhook", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(log.Category, "Script", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(log.Category, "ScriptTest", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return TimelineLayers.Automation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(log.Category, "FFmpeg", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
IsProcessingMessage(log.Message))
|
||||||
|
{
|
||||||
|
return TimelineLayers.Processing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(log.Category, "RecordSession", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(log.Category, "Scheduler", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(log.Category, "LiveRoom", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(log.Category, "FFmpeg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return TimelineLayers.Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRelevantLiveRoomTimelineLog(SystemLogEntry log)
|
||||||
|
{
|
||||||
|
if (string.Equals(log.Category, "Scheduler", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(log.Category, "LiveRoom", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsProcessingMessage(string message) =>
|
||||||
|
message.Contains("finalization", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
message.Contains("transcode", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
message.Contains("seek index", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
message.Contains("post-process", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
message.Contains("rollover", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static IReadOnlyList<DanmakuBucketSnapshot> ReadDanmakuBuckets(
|
||||||
|
RecordTask task,
|
||||||
|
DateTimeOffset taskStartedAt,
|
||||||
|
DateTimeOffset? windowStartUtc,
|
||||||
|
DateTimeOffset? windowEndUtc)
|
||||||
|
{
|
||||||
|
var danmakuPath = ResolveDanmakuPath(task);
|
||||||
|
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var buckets = new Dictionary<DateTimeOffset, int>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
double? offsetSeconds = null;
|
||||||
|
if (string.Equals(reader.Name, "d", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var payload = reader.GetAttribute("p");
|
||||||
|
if (!string.IsNullOrWhiteSpace(payload))
|
||||||
|
{
|
||||||
|
var firstPart = payload.Split(',').FirstOrDefault();
|
||||||
|
if (double.TryParse(firstPart, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedSeconds))
|
||||||
|
{
|
||||||
|
offsetSeconds = parsedSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (string.Equals(reader.Name, "event", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var payload = reader.GetAttribute("offset");
|
||||||
|
if (!string.IsNullOrWhiteSpace(payload) &&
|
||||||
|
double.TryParse(payload, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedSeconds))
|
||||||
|
{
|
||||||
|
offsetSeconds = parsedSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!offsetSeconds.HasValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var occurredAt = taskStartedAt.AddSeconds(Math.Max(0, offsetSeconds.Value));
|
||||||
|
if (windowStartUtc.HasValue && occurredAt < windowStartUtc.Value)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowEndUtc.HasValue && occurredAt >= windowEndUtc.Value)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bucketStartAt = TruncateToMinute(occurredAt);
|
||||||
|
if (!buckets.TryAdd(bucketStartAt, 1))
|
||||||
|
{
|
||||||
|
buckets[bucketStartAt]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets
|
||||||
|
.OrderBy(static pair => pair.Key)
|
||||||
|
.Select(static pair => new DanmakuBucketSnapshot(pair.Key, pair.Value))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset TruncateToMinute(DateTimeOffset value)
|
||||||
|
{
|
||||||
|
var utcValue = value.ToUniversalTime();
|
||||||
|
var truncatedUtc = new DateTime(
|
||||||
|
utcValue.Year,
|
||||||
|
utcValue.Month,
|
||||||
|
utcValue.Day,
|
||||||
|
utcValue.Hour,
|
||||||
|
utcValue.Minute,
|
||||||
|
0,
|
||||||
|
DateTimeKind.Utc);
|
||||||
|
return new DateTimeOffset(truncatedUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveDanmakuPath(RecordTask task)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(task.Result?.DanmakuFilePath))
|
||||||
|
{
|
||||||
|
return task.Result.DanmakuFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(task.OutputFilePath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var absoluteVideoPath = Path.IsPathRooted(task.OutputFilePath)
|
||||||
|
? task.OutputFilePath
|
||||||
|
: Path.GetFullPath(task.OutputFilePath, AppContext.BaseDirectory);
|
||||||
|
return Path.ChangeExtension(absoluteVideoPath, ".xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record DailySessionSnapshot(
|
||||||
|
RecordSession Session,
|
||||||
|
double DurationSeconds,
|
||||||
|
int SegmentCount,
|
||||||
|
int DanmakuCount);
|
||||||
|
|
||||||
|
private sealed record SegmentSnapshot(
|
||||||
|
RecordTask Task,
|
||||||
|
DateTimeOffset StartedAt,
|
||||||
|
DateTimeOffset EndedAt);
|
||||||
|
|
||||||
|
private sealed record DanmakuBucketSnapshot(
|
||||||
|
DateTimeOffset BucketStartedAt,
|
||||||
|
int MessageCount);
|
||||||
|
|
||||||
|
private static class TimelineLayers
|
||||||
|
{
|
||||||
|
public const string Session = "session";
|
||||||
|
public const string Processing = "processing";
|
||||||
|
public const string Danmaku = "danmaku";
|
||||||
|
public const string Automation = "automation";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RecordTaskAnalyticsExtensions
|
||||||
|
{
|
||||||
|
public static string? PostProcessDetail(this RecordTask task)
|
||||||
|
{
|
||||||
|
if (task.Status == RecordTaskStatus.Processing)
|
||||||
|
{
|
||||||
|
return task.ErrorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
private const string SegmentCompletedScriptPathKey = "event_scripts.segment_completed.path";
|
private const string SegmentCompletedScriptPathKey = "event_scripts.segment_completed.path";
|
||||||
private const string SegmentCompletedScriptContentKey = "event_scripts.segment_completed.content";
|
private const string SegmentCompletedScriptContentKey = "event_scripts.segment_completed.content";
|
||||||
private const string EventScriptTimeoutSecondsKey = "event_scripts.timeout_seconds";
|
private const string EventScriptTimeoutSecondsKey = "event_scripts.timeout_seconds";
|
||||||
|
private const string EnableRetentionCleanupKey = "retention.cleanup.enabled";
|
||||||
|
private const string RetentionDaysKey = "retention.cleanup.days";
|
||||||
|
private const string RetentionDeleteFilesKey = "retention.cleanup.delete_files";
|
||||||
private const string EnableEmailNotificationKey = "notification.email.enabled";
|
private const string EnableEmailNotificationKey = "notification.email.enabled";
|
||||||
private const string EmailSmtpHostKey = "notification.email.smtp_host";
|
private const string EmailSmtpHostKey = "notification.email.smtp_host";
|
||||||
private const string EmailSmtpPortKey = "notification.email.smtp_port";
|
private const string EmailSmtpPortKey = "notification.email.smtp_port";
|
||||||
@@ -58,6 +61,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
private const string EmailLiveStartedBodyTemplateHtmlKey = "notification.email.live_started.body_template_html";
|
private const string EmailLiveStartedBodyTemplateHtmlKey = "notification.email.live_started.body_template_html";
|
||||||
private const string EmailExceptionSubjectTemplateKey = "notification.email.exception.subject_template";
|
private const string EmailExceptionSubjectTemplateKey = "notification.email.exception.subject_template";
|
||||||
private const string EmailExceptionBodyTemplateHtmlKey = "notification.email.exception.body_template_html";
|
private const string EmailExceptionBodyTemplateHtmlKey = "notification.email.exception.body_template_html";
|
||||||
|
private const string EnableWebhookNotificationKey = "notification.webhook.enabled";
|
||||||
|
private const string WebhookUrlKey = "notification.webhook.url";
|
||||||
|
private const string WebhookHeadersKey = "notification.webhook.headers";
|
||||||
|
private const string WebhookTimeoutSecondsKey = "notification.webhook.timeout_seconds";
|
||||||
|
private const string NotifyWebhookOnLiveStartedKey = "notification.webhook.notify_live_started";
|
||||||
|
private const string NotifyWebhookOnExceptionKey = "notification.webhook.notify_exception";
|
||||||
private const string DouyinUserAgentKey = "douyin.user_agent";
|
private const string DouyinUserAgentKey = "douyin.user_agent";
|
||||||
private const string DouyinRefererKey = "douyin.referer";
|
private const string DouyinRefererKey = "douyin.referer";
|
||||||
private const string DouyinCookieKey = "douyin.cookie";
|
private const string DouyinCookieKey = "douyin.cookie";
|
||||||
@@ -119,6 +128,9 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
SegmentCompletedScriptPath = GetValue(lookup, SegmentCompletedScriptPathKey, string.Empty),
|
SegmentCompletedScriptPath = GetValue(lookup, SegmentCompletedScriptPathKey, string.Empty),
|
||||||
SegmentCompletedScriptContent = GetValue(lookup, SegmentCompletedScriptContentKey, string.Empty),
|
SegmentCompletedScriptContent = GetValue(lookup, SegmentCompletedScriptContentKey, string.Empty),
|
||||||
EventScriptTimeoutSeconds = GetIntValue(lookup, EventScriptTimeoutSecondsKey, 60, 1, 3600),
|
EventScriptTimeoutSeconds = GetIntValue(lookup, EventScriptTimeoutSecondsKey, 60, 1, 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,
|
||||||
EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification,
|
EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification,
|
||||||
EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty),
|
EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty),
|
||||||
EmailSmtpPort = GetIntValue(lookup, EmailSmtpPortKey, 587, 1, 65535),
|
EmailSmtpPort = GetIntValue(lookup, EmailSmtpPortKey, 587, 1, 65535),
|
||||||
@@ -167,6 +179,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||||
</div>
|
</div>
|
||||||
"""),
|
"""),
|
||||||
|
EnableWebhookNotification = bool.TryParse(GetValue(lookup, EnableWebhookNotificationKey, "false"), out var enableWebhookNotification) && enableWebhookNotification,
|
||||||
|
WebhookUrl = GetValue(lookup, WebhookUrlKey, string.Empty),
|
||||||
|
WebhookHeaders = GetValue(lookup, WebhookHeadersKey, string.Empty),
|
||||||
|
WebhookTimeoutSeconds = GetIntValue(lookup, WebhookTimeoutSecondsKey, 15, 1, 300),
|
||||||
|
NotifyWebhookOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyWebhookOnLiveStartedKey, "true"), out var notifyWebhookOnLiveStarted) && notifyWebhookOnLiveStarted,
|
||||||
|
NotifyWebhookOnException = bool.TryParse(GetValue(lookup, NotifyWebhookOnExceptionKey, "true"), out var notifyWebhookOnException) && notifyWebhookOnException,
|
||||||
DouyinUserAgent = GetValue(
|
DouyinUserAgent = GetValue(
|
||||||
lookup,
|
lookup,
|
||||||
DouyinUserAgentKey,
|
DouyinUserAgentKey,
|
||||||
@@ -233,6 +251,9 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
await UpsertAsync(SegmentCompletedScriptPathKey, request.SegmentCompletedScriptPath.Trim(), now, cancellationToken);
|
await UpsertAsync(SegmentCompletedScriptPathKey, request.SegmentCompletedScriptPath.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(SegmentCompletedScriptContentKey, request.SegmentCompletedScriptContent, now, cancellationToken);
|
await UpsertAsync(SegmentCompletedScriptContentKey, request.SegmentCompletedScriptContent, now, cancellationToken);
|
||||||
await UpsertAsync(EventScriptTimeoutSecondsKey, Math.Clamp(request.EventScriptTimeoutSeconds, 1, 3600).ToString(), now, cancellationToken);
|
await UpsertAsync(EventScriptTimeoutSecondsKey, Math.Clamp(request.EventScriptTimeoutSeconds, 1, 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);
|
||||||
await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken);
|
await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken);
|
||||||
await UpsertAsync(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), now, cancellationToken);
|
await UpsertAsync(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(EmailSmtpPortKey, request.EmailSmtpPort.ToString(), now, cancellationToken);
|
await UpsertAsync(EmailSmtpPortKey, request.EmailSmtpPort.ToString(), now, cancellationToken);
|
||||||
@@ -248,6 +269,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
await UpsertAsync(EmailLiveStartedBodyTemplateHtmlKey, request.EmailLiveStartedBodyTemplateHtml.Trim(), now, cancellationToken);
|
await UpsertAsync(EmailLiveStartedBodyTemplateHtmlKey, request.EmailLiveStartedBodyTemplateHtml.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(EmailExceptionSubjectTemplateKey, request.EmailExceptionSubjectTemplate.Trim(), now, cancellationToken);
|
await UpsertAsync(EmailExceptionSubjectTemplateKey, request.EmailExceptionSubjectTemplate.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(EmailExceptionBodyTemplateHtmlKey, request.EmailExceptionBodyTemplateHtml.Trim(), now, cancellationToken);
|
await UpsertAsync(EmailExceptionBodyTemplateHtmlKey, request.EmailExceptionBodyTemplateHtml.Trim(), now, cancellationToken);
|
||||||
|
await UpsertAsync(EnableWebhookNotificationKey, request.EnableWebhookNotification.ToString(), now, cancellationToken);
|
||||||
|
await UpsertAsync(WebhookUrlKey, request.WebhookUrl.Trim(), now, cancellationToken);
|
||||||
|
await UpsertAsync(WebhookHeadersKey, request.WebhookHeaders, now, cancellationToken);
|
||||||
|
await UpsertAsync(WebhookTimeoutSecondsKey, Math.Clamp(request.WebhookTimeoutSeconds, 1, 300).ToString(), now, cancellationToken);
|
||||||
|
await UpsertAsync(NotifyWebhookOnLiveStartedKey, request.NotifyWebhookOnLiveStarted.ToString(), now, cancellationToken);
|
||||||
|
await UpsertAsync(NotifyWebhookOnExceptionKey, request.NotifyWebhookOnException.ToString(), now, cancellationToken);
|
||||||
await UpsertAsync(DouyinUserAgentKey, request.DouyinUserAgent.Trim(), now, cancellationToken);
|
await UpsertAsync(DouyinUserAgentKey, request.DouyinUserAgent.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(DouyinRefererKey, request.DouyinReferer.Trim(), now, cancellationToken);
|
await UpsertAsync(DouyinRefererKey, request.DouyinReferer.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(DouyinCookieKey, request.DouyinCookie.Trim(), now, cancellationToken);
|
await UpsertAsync(DouyinCookieKey, request.DouyinCookie.Trim(), now, cancellationToken);
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ public class LiveRoom
|
|||||||
|
|
||||||
public LiveRoomAvailabilityStatus AvailabilityStatus { get; private set; }
|
public LiveRoomAvailabilityStatus AvailabilityStatus { get; private set; }
|
||||||
|
|
||||||
|
public string? LastAutoStartDecisionCode { get; private set; }
|
||||||
|
|
||||||
|
public string? LastAutoStartDecisionSummary { get; private set; }
|
||||||
|
|
||||||
|
public string? LastAutoStartDecisionDetail { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastAutoStartDecisionAt { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset UpdatedAt { get; private set; }
|
public DateTimeOffset UpdatedAt { get; private set; }
|
||||||
@@ -131,6 +139,18 @@ public class LiveRoom
|
|||||||
UpdatedAt = updatedAt;
|
UpdatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetLastAutoStartDecision(
|
||||||
|
string? code,
|
||||||
|
string? summary,
|
||||||
|
string? detail,
|
||||||
|
DateTimeOffset decidedAt)
|
||||||
|
{
|
||||||
|
LastAutoStartDecisionCode = NormalizeNullable(code);
|
||||||
|
LastAutoStartDecisionSummary = NormalizeNullable(summary);
|
||||||
|
LastAutoStartDecisionDetail = NormalizeNullable(detail);
|
||||||
|
LastAutoStartDecisionAt = decidedAt;
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateRecordingSettingsOverrides(
|
public void UpdateRecordingSettingsOverrides(
|
||||||
string? preferredQualityOverride,
|
string? preferredQualityOverride,
|
||||||
RecordOutputFormat? outputFormatOverride,
|
RecordOutputFormat? outputFormatOverride,
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ public sealed class DatabaseInitializer
|
|||||||
["event_scripts.live_ended.path"] = string.Empty,
|
["event_scripts.live_ended.path"] = string.Empty,
|
||||||
["event_scripts.segment_completed.path"] = string.Empty,
|
["event_scripts.segment_completed.path"] = string.Empty,
|
||||||
["event_scripts.timeout_seconds"] = "60",
|
["event_scripts.timeout_seconds"] = "60",
|
||||||
|
["retention.cleanup.enabled"] = "False",
|
||||||
|
["retention.cleanup.days"] = "30",
|
||||||
|
["retention.cleanup.delete_files"] = "False",
|
||||||
["notification.email.enabled"] = "False",
|
["notification.email.enabled"] = "False",
|
||||||
["notification.email.smtp_host"] = string.Empty,
|
["notification.email.smtp_host"] = string.Empty,
|
||||||
["notification.email.smtp_port"] = "587",
|
["notification.email.smtp_port"] = "587",
|
||||||
@@ -103,6 +106,12 @@ public sealed class DatabaseInitializer
|
|||||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
|
["notification.webhook.enabled"] = "False",
|
||||||
|
["notification.webhook.url"] = string.Empty,
|
||||||
|
["notification.webhook.headers"] = string.Empty,
|
||||||
|
["notification.webhook.timeout_seconds"] = "15",
|
||||||
|
["notification.webhook.notify_live_started"] = "True",
|
||||||
|
["notification.webhook.notify_exception"] = "True",
|
||||||
["douyin.user_agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
["douyin.user_agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||||
["douyin.referer"] = "https://live.douyin.com/",
|
["douyin.referer"] = "https://live.douyin.com/",
|
||||||
["douyin.cookie"] = string.Empty
|
["douyin.cookie"] = string.Empty
|
||||||
@@ -158,6 +167,10 @@ public sealed class DatabaseInitializer
|
|||||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuIncludeNonChatEventsOverride INTEGER NULL;", cancellationToken);
|
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuIncludeNonChatEventsOverride INTEGER NULL;", cancellationToken);
|
||||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuMinPollIntervalMillisecondsOverride INTEGER NULL;", cancellationToken);
|
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuMinPollIntervalMillisecondsOverride INTEGER NULL;", cancellationToken);
|
||||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuRetryDelayMaxSecondsOverride INTEGER NULL;", cancellationToken);
|
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuRetryDelayMaxSecondsOverride INTEGER NULL;", cancellationToken);
|
||||||
|
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionCode TEXT NULL;", cancellationToken);
|
||||||
|
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionSummary TEXT NULL;", cancellationToken);
|
||||||
|
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionDetail TEXT NULL;", cancellationToken);
|
||||||
|
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionAt TEXT NULL;", cancellationToken);
|
||||||
|
|
||||||
await _dbContext.Database.ExecuteSqlRawAsync(
|
await _dbContext.Database.ExecuteSqlRawAsync(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
|||||||
builder.Property(static x => x.OutputFormatOverride).HasConversion<int?>();
|
builder.Property(static x => x.OutputFormatOverride).HasConversion<int?>();
|
||||||
builder.Property(static x => x.SaveModeOverride).HasConversion<int?>();
|
builder.Property(static x => x.SaveModeOverride).HasConversion<int?>();
|
||||||
builder.Property(static x => x.RecordingTemplateOverride).HasConversion<int?>();
|
builder.Property(static x => x.RecordingTemplateOverride).HasConversion<int?>();
|
||||||
|
builder.Property(static x => x.LastAutoStartDecisionCode).HasMaxLength(64);
|
||||||
|
builder.Property(static x => x.LastAutoStartDecisionSummary).HasMaxLength(256);
|
||||||
|
builder.Property(static x => x.LastAutoStartDecisionDetail).HasMaxLength(2048);
|
||||||
builder.Property(static x => x.IsEnabled).HasDefaultValue(true);
|
builder.Property(static x => x.IsEnabled).HasDefaultValue(true);
|
||||||
builder.Property(static x => x.HasSentLiveNotificationForCurrentSession).HasDefaultValue(false);
|
builder.Property(static x => x.HasSentLiveNotificationForCurrentSession).HasDefaultValue(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -225,6 +225,9 @@ public sealed class SystemLogRepository : ISystemLogRepository
|
|||||||
public Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default) =>
|
public Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default) =>
|
||||||
_dbContext.SystemLogEntries.AddAsync(entry, cancellationToken).AsTask();
|
_dbContext.SystemLogEntries.AddAsync(entry, cancellationToken).AsTask();
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SystemLogEntry>> ListAllAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
await _dbContext.SystemLogEntries.AsNoTracking().ToListAsync(cancellationToken);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SystemLogEntry>> ListByRecordTaskIdsAsync(
|
public async Task<IReadOnlyList<SystemLogEntry>> ListByRecordTaskIdsAsync(
|
||||||
IReadOnlyCollection<Guid> recordTaskIds,
|
IReadOnlyCollection<Guid> recordTaskIds,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ namespace LiveRecorder.Infrastructure.Services;
|
|||||||
|
|
||||||
public sealed class EventScriptService : IEventScriptService
|
public sealed class EventScriptService : IEventScriptService
|
||||||
{
|
{
|
||||||
|
private const string ScriptLogPathEnvironmentVariable = "LIVE_RECORDER_SCRIPT_LOG_PATH";
|
||||||
|
private const int SystemLogDetailMaxLength = 4000;
|
||||||
|
private const string TruncatedDetailSuffix = "... [truncated to fit system log detail limit]";
|
||||||
|
|
||||||
private readonly ISystemSettingsService _settingsService;
|
private readonly ISystemSettingsService _settingsService;
|
||||||
private readonly ISystemLogService _systemLogService;
|
private readonly ISystemLogService _systemLogService;
|
||||||
private readonly ILogger<EventScriptService> _logger;
|
private readonly ILogger<EventScriptService> _logger;
|
||||||
@@ -38,6 +42,7 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
settings.EventScriptTimeoutSeconds,
|
settings.EventScriptTimeoutSeconds,
|
||||||
"live_started",
|
"live_started",
|
||||||
environment,
|
environment,
|
||||||
|
"Script",
|
||||||
liveRoom.Id,
|
liveRoom.Id,
|
||||||
recordSessionId: null,
|
recordSessionId: null,
|
||||||
recordTaskId: null,
|
recordTaskId: null,
|
||||||
@@ -57,6 +62,7 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
settings.EventScriptTimeoutSeconds,
|
settings.EventScriptTimeoutSeconds,
|
||||||
"live_ended",
|
"live_ended",
|
||||||
environment,
|
environment,
|
||||||
|
"Script",
|
||||||
liveRoom.Id,
|
liveRoom.Id,
|
||||||
recordSessionId: null,
|
recordSessionId: null,
|
||||||
recordTaskId: null,
|
recordTaskId: null,
|
||||||
@@ -93,12 +99,61 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
settings.EventScriptTimeoutSeconds,
|
settings.EventScriptTimeoutSeconds,
|
||||||
"segment_completed",
|
"segment_completed",
|
||||||
environment,
|
environment,
|
||||||
|
"Script",
|
||||||
liveRoom?.Id ?? recordSession.LiveRoomId,
|
liveRoom?.Id ?? recordSession.LiveRoomId,
|
||||||
recordSession.Id,
|
recordSession.Id,
|
||||||
recordTask.Id,
|
recordTask.Id,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<EventScriptTestResultDto> TestAsync(
|
||||||
|
TestEventScriptRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var eventName = NormalizeEventType(request.EventType);
|
||||||
|
if (eventName is null)
|
||||||
|
{
|
||||||
|
var result = new EventScriptTestResultDto
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Unsupported event script test type.",
|
||||||
|
Detail = request.EventType
|
||||||
|
};
|
||||||
|
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
SystemLogLevel.Warning,
|
||||||
|
"ScriptTest",
|
||||||
|
result.Message,
|
||||||
|
result.Detail,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcome = await ExecuteAsync(
|
||||||
|
request.ScriptMode,
|
||||||
|
request.ScriptPath,
|
||||||
|
request.ScriptContent,
|
||||||
|
request.TimeoutSeconds,
|
||||||
|
eventName,
|
||||||
|
BuildTestEnvironment(eventName, DateTimeOffset.UtcNow),
|
||||||
|
"ScriptTest",
|
||||||
|
liveRoomId: null,
|
||||||
|
recordSessionId: null,
|
||||||
|
recordTaskId: null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new EventScriptTestResultDto
|
||||||
|
{
|
||||||
|
Success = outcome.Success,
|
||||||
|
Message = outcome.Message,
|
||||||
|
Detail = outcome.Detail,
|
||||||
|
CustomLogOutput = outcome.CustomLogOutput
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RunAsync(
|
private async Task RunAsync(
|
||||||
bool enabled,
|
bool enabled,
|
||||||
string scriptMode,
|
string scriptMode,
|
||||||
@@ -107,6 +162,7 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
int timeoutSeconds,
|
int timeoutSeconds,
|
||||||
string eventName,
|
string eventName,
|
||||||
IReadOnlyDictionary<string, string> environment,
|
IReadOnlyDictionary<string, string> environment,
|
||||||
|
string logCategory,
|
||||||
Guid? liveRoomId,
|
Guid? liveRoomId,
|
||||||
Guid? recordSessionId,
|
Guid? recordSessionId,
|
||||||
Guid? recordTaskId,
|
Guid? recordTaskId,
|
||||||
@@ -117,24 +173,70 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ExecuteAsync(
|
||||||
|
scriptMode,
|
||||||
|
scriptPath,
|
||||||
|
scriptContent,
|
||||||
|
timeoutSeconds,
|
||||||
|
eventName,
|
||||||
|
environment,
|
||||||
|
logCategory,
|
||||||
|
liveRoomId,
|
||||||
|
recordSessionId,
|
||||||
|
recordTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ScriptExecutionOutcome> ExecuteAsync(
|
||||||
|
string scriptMode,
|
||||||
|
string scriptPath,
|
||||||
|
string scriptContent,
|
||||||
|
int timeoutSeconds,
|
||||||
|
string eventName,
|
||||||
|
IReadOnlyDictionary<string, string> environment,
|
||||||
|
string logCategory,
|
||||||
|
Guid? liveRoomId,
|
||||||
|
Guid? recordSessionId,
|
||||||
|
Guid? recordTaskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
var execution = CreateExecution(scriptMode, scriptPath, scriptContent);
|
var execution = CreateExecution(scriptMode, scriptPath, scriptContent);
|
||||||
if (execution is null)
|
if (execution is null)
|
||||||
{
|
{
|
||||||
return;
|
var missingConfiguration = new ScriptExecutionOutcome(
|
||||||
}
|
false,
|
||||||
|
$"Event script was not configured for {eventName}.",
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
|
||||||
if (execution.IsMissing)
|
await WriteOutcomeLogAsync(
|
||||||
{
|
missingConfiguration,
|
||||||
await _systemLogService.WriteAsync(
|
logCategory,
|
||||||
SystemLogLevel.Warning,
|
SystemLogLevel.Warning,
|
||||||
"Script",
|
|
||||||
$"Event script was not found for {eventName}.",
|
|
||||||
execution.Detail,
|
|
||||||
liveRoomId,
|
liveRoomId,
|
||||||
recordSessionId,
|
recordSessionId,
|
||||||
recordTaskId,
|
recordTaskId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return;
|
return missingConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (execution.IsMissing)
|
||||||
|
{
|
||||||
|
var missingScript = new ScriptExecutionOutcome(
|
||||||
|
false,
|
||||||
|
$"Event script was not found for {eventName}.",
|
||||||
|
execution.Detail,
|
||||||
|
null);
|
||||||
|
|
||||||
|
await WriteOutcomeLogAsync(
|
||||||
|
missingScript,
|
||||||
|
logCategory,
|
||||||
|
SystemLogLevel.Warning,
|
||||||
|
liveRoomId,
|
||||||
|
recordSessionId,
|
||||||
|
recordTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
return missingScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
var startInfo = execution.StartInfo!;
|
var startInfo = execution.StartInfo!;
|
||||||
@@ -143,12 +245,25 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
startInfo.Environment[pair.Key] = pair.Value;
|
startInfo.Environment[pair.Key] = pair.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var scriptLogPath = CreateScriptLogPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(scriptLogPath, string.Empty, CancellationToken.None);
|
||||||
|
startInfo.Environment[ScriptLogPathEnvironmentVariable] = scriptLogPath;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to prepare event script log file for {EventName}", eventName);
|
||||||
|
}
|
||||||
|
|
||||||
using var process = new Process
|
using var process = new Process
|
||||||
{
|
{
|
||||||
StartInfo = startInfo,
|
StartInfo = startInfo,
|
||||||
EnableRaisingEvents = true
|
EnableRaisingEvents = true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ScriptExecutionOutcome outcome;
|
||||||
|
SystemLogLevel outcomeLevel;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
process.Start();
|
process.Start();
|
||||||
@@ -156,44 +271,59 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 3600)));
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 3600)));
|
||||||
await process.WaitForExitAsync(timeoutCts.Token);
|
await process.WaitForExitAsync(timeoutCts.Token);
|
||||||
|
|
||||||
await _systemLogService.WriteAsync(
|
outcome = new ScriptExecutionOutcome(
|
||||||
process.ExitCode == 0 ? SystemLogLevel.Info : SystemLogLevel.Warning,
|
process.ExitCode == 0,
|
||||||
"Script",
|
|
||||||
process.ExitCode == 0
|
process.ExitCode == 0
|
||||||
? $"Event script completed for {eventName}."
|
? $"Event script completed for {eventName}."
|
||||||
: $"Event script exited with code {process.ExitCode} for {eventName}.",
|
: $"Event script exited with code {process.ExitCode} for {eventName}.",
|
||||||
execution.Detail,
|
execution.Detail,
|
||||||
liveRoomId,
|
null);
|
||||||
recordSessionId,
|
outcomeLevel = process.ExitCode == 0 ? SystemLogLevel.Info : SystemLogLevel.Warning;
|
||||||
recordTaskId,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
TryKill(process);
|
TryKill(process);
|
||||||
await _systemLogService.WriteAsync(
|
outcome = new ScriptExecutionOutcome(
|
||||||
SystemLogLevel.Warning,
|
false,
|
||||||
"Script",
|
|
||||||
$"Event script timed out for {eventName}.",
|
$"Event script timed out for {eventName}.",
|
||||||
execution.Detail,
|
execution.Detail,
|
||||||
liveRoomId,
|
null);
|
||||||
recordSessionId,
|
outcomeLevel = SystemLogLevel.Warning;
|
||||||
recordTaskId,
|
|
||||||
CancellationToken.None);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Event script failed for {EventName}", eventName);
|
_logger.LogWarning(ex, "Event script failed for {EventName}", eventName);
|
||||||
await _systemLogService.WriteAsync(
|
outcome = new ScriptExecutionOutcome(
|
||||||
SystemLogLevel.Warning,
|
false,
|
||||||
"Script",
|
|
||||||
$"Event script failed for {eventName}.",
|
$"Event script failed for {eventName}.",
|
||||||
ex.ToString(),
|
ex.ToString(),
|
||||||
liveRoomId,
|
null);
|
||||||
recordSessionId,
|
outcomeLevel = SystemLogLevel.Warning;
|
||||||
recordTaskId,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Any script-provided text is persisted and surfaced separately from the execution outcome.
|
||||||
|
}
|
||||||
|
|
||||||
|
var customLogOutput = await TryWriteCustomLogOutputAsync(
|
||||||
|
eventName,
|
||||||
|
logCategory,
|
||||||
|
scriptLogPath,
|
||||||
|
liveRoomId,
|
||||||
|
recordSessionId,
|
||||||
|
recordTaskId);
|
||||||
|
TryDeleteFile(scriptLogPath);
|
||||||
|
|
||||||
|
outcome = outcome with { CustomLogOutput = customLogOutput };
|
||||||
|
await WriteOutcomeLogAsync(
|
||||||
|
outcome,
|
||||||
|
logCategory,
|
||||||
|
outcomeLevel,
|
||||||
|
liveRoomId,
|
||||||
|
recordSessionId,
|
||||||
|
recordTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static EventScriptExecution? CreateExecution(string scriptMode, string scriptPath, string scriptContent)
|
private static EventScriptExecution? CreateExecution(string scriptMode, string scriptPath, string scriptContent)
|
||||||
@@ -237,6 +367,47 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> BuildTestEnvironment(string eventName, DateTimeOffset occurredAt)
|
||||||
|
{
|
||||||
|
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["LIVE_RECORDER_EVENT"] = eventName,
|
||||||
|
["LIVE_RECORDER_PLATFORM"] = "Douyin",
|
||||||
|
["LIVE_RECORDER_LIVE_ROOM_ID"] = "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901",
|
||||||
|
["LIVE_RECORDER_ROOM_ID"] = "676493068539",
|
||||||
|
["LIVE_RECORDER_TITLE"] = "Sample live title",
|
||||||
|
["LIVE_RECORDER_ANCHOR"] = "Sample anchor",
|
||||||
|
["LIVE_RECORDER_SOURCE_URL"] = "https://live.douyin.com/676493068539",
|
||||||
|
["LIVE_RECORDER_OCCURRED_AT_UTC"] = occurredAt.ToString("O")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.Equals(eventName, "segment_completed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
environment["LIVE_RECORDER_RECORD_SESSION_ID"] = "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77";
|
||||||
|
environment["LIVE_RECORDER_RECORD_TASK_ID"] = "2a4810a2-7ef4-4a22-90d4-0211b90cc54c";
|
||||||
|
environment["LIVE_RECORDER_SEGMENT_INDEX"] = "1";
|
||||||
|
environment["LIVE_RECORDER_SEGMENT_FILE_PATH"] = "/app/records/Douyin/Sample Anchor/2026-04-25/203000_Sample live title__00001.mp4";
|
||||||
|
environment["LIVE_RECORDER_DANMAKU_FILE_PATH"] = "/app/records/Douyin/Sample Anchor/2026-04-25/203000_Sample live title__00001.xml";
|
||||||
|
environment["LIVE_RECORDER_DURATION_SECONDS"] = "2185.1";
|
||||||
|
environment["LIVE_RECORDER_FILE_SIZE_BYTES"] = "734003200";
|
||||||
|
environment["LIVE_RECORDER_TASK_STATUS"] = "Completed";
|
||||||
|
environment["LIVE_RECORDER_SESSION_STATUS"] = "Completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeEventType(string? eventType)
|
||||||
|
{
|
||||||
|
return eventType?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"live_started" => "live_started",
|
||||||
|
"live_ended" => "live_ended",
|
||||||
|
"segment_completed" => "segment_completed",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static ProcessStartInfo CreateStartInfo(string scriptPath)
|
private static ProcessStartInfo CreateStartInfo(string scriptPath)
|
||||||
{
|
{
|
||||||
var extension = Path.GetExtension(scriptPath);
|
var extension = Path.GetExtension(scriptPath);
|
||||||
@@ -330,6 +501,85 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
return new FileInfo(normalizedPath).Length.ToString();
|
return new FileInfo(normalizedPath).Length.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryWriteCustomLogOutputAsync(
|
||||||
|
string eventName,
|
||||||
|
string logCategory,
|
||||||
|
string scriptLogPath,
|
||||||
|
Guid? liveRoomId,
|
||||||
|
Guid? recordSessionId,
|
||||||
|
Guid? recordTaskId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptLogPath) || !File.Exists(scriptLogPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var output = await File.ReadAllTextAsync(scriptLogPath, CancellationToken.None);
|
||||||
|
var trimmedOutput = output.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmedOutput))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
SystemLogLevel.Info,
|
||||||
|
logCategory,
|
||||||
|
$"Event script emitted custom log output for {eventName}.",
|
||||||
|
TruncateSystemLogDetail(trimmedOutput),
|
||||||
|
liveRoomId,
|
||||||
|
recordSessionId,
|
||||||
|
recordTaskId,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
return trimmedOutput;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to collect event script custom log output for {EventName}", eventName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteOutcomeLogAsync(
|
||||||
|
ScriptExecutionOutcome outcome,
|
||||||
|
string logCategory,
|
||||||
|
SystemLogLevel level,
|
||||||
|
Guid? liveRoomId,
|
||||||
|
Guid? recordSessionId,
|
||||||
|
Guid? recordTaskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
level,
|
||||||
|
logCategory,
|
||||||
|
outcome.Message,
|
||||||
|
outcome.Detail,
|
||||||
|
liveRoomId,
|
||||||
|
recordSessionId,
|
||||||
|
recordTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateScriptLogPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"live-recorder-script-log-{Guid.NewGuid():N}.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TruncateSystemLogDetail(string detail)
|
||||||
|
{
|
||||||
|
if (detail.Length <= SystemLogDetailMaxLength)
|
||||||
|
{
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefixLength = Math.Max(0, SystemLogDetailMaxLength - TruncatedDetailSuffix.Length);
|
||||||
|
return string.Concat(detail[..prefixLength], TruncatedDetailSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
private static void TryKill(Process process)
|
private static void TryKill(Process process)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -344,5 +594,25 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteFile(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record EventScriptExecution(ProcessStartInfo? StartInfo, string Detail, bool IsMissing = false);
|
private sealed record EventScriptExecution(ProcessStartInfo? StartInfo, string Detail, bool IsMissing = false);
|
||||||
|
|
||||||
|
private sealed record ScriptExecutionOutcome(
|
||||||
|
bool Success,
|
||||||
|
string Message,
|
||||||
|
string? Detail,
|
||||||
|
string? CustomLogOutput);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ public sealed partial class FfmpegService
|
|||||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||||
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
|
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
|
||||||
|
var webhookNotificationService = scope.ServiceProvider.GetRequiredService<IWebhookNotificationService>();
|
||||||
var settings = await settingsService.GetAsync();
|
var settings = await settingsService.GetAsync();
|
||||||
|
|
||||||
var session = await dbContext.RecordSessions
|
var session = await dbContext.RecordSessions
|
||||||
@@ -783,6 +784,12 @@ public sealed partial class FfmpegService
|
|||||||
$"exitCode={process.ExitCode}; output={effectiveOutputPath}",
|
$"exitCode={process.ExitCode}; output={effectiveOutputPath}",
|
||||||
session.LiveRoom,
|
session.LiveRoom,
|
||||||
currentTask);
|
currentTask);
|
||||||
|
await webhookNotificationService.SendExceptionAsync(
|
||||||
|
"FFmpeg",
|
||||||
|
"Recording session exited abnormally.",
|
||||||
|
$"exitCode={process.ExitCode}; output={effectiveOutputPath}",
|
||||||
|
session.LiveRoom,
|
||||||
|
currentTask);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using LiveRecorder.Application.Common;
|
||||||
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;
|
||||||
@@ -95,6 +96,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
using var notificationScope = _serviceScopeFactory.CreateScope();
|
using var notificationScope = _serviceScopeFactory.CreateScope();
|
||||||
var logService = notificationScope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
var logService = notificationScope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||||
var emailNotificationService = notificationScope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
|
var emailNotificationService = notificationScope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
|
||||||
|
var webhookNotificationService = notificationScope.ServiceProvider.GetRequiredService<IWebhookNotificationService>();
|
||||||
await logService.WriteAsync(
|
await logService.WriteAsync(
|
||||||
SystemLogLevel.Error,
|
SystemLogLevel.Error,
|
||||||
"Scheduler",
|
"Scheduler",
|
||||||
@@ -108,6 +110,11 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
"Background live room polling failed.",
|
"Background live room polling failed.",
|
||||||
ex.ToString(),
|
ex.ToString(),
|
||||||
cancellationToken: stoppingToken);
|
cancellationToken: stoppingToken);
|
||||||
|
await webhookNotificationService.SendExceptionAsync(
|
||||||
|
"Scheduler",
|
||||||
|
"Background live room polling failed.",
|
||||||
|
ex.ToString(),
|
||||||
|
cancellationToken: stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception notificationEx)
|
catch (Exception notificationEx)
|
||||||
@@ -133,6 +140,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>();
|
var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>();
|
||||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||||
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
|
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
|
||||||
|
var webhookNotificationService = scope.ServiceProvider.GetRequiredService<IWebhookNotificationService>();
|
||||||
var storageGuardService = scope.ServiceProvider.GetRequiredService<IStorageGuardService>();
|
var storageGuardService = scope.ServiceProvider.GetRequiredService<IStorageGuardService>();
|
||||||
|
|
||||||
var liveRoom = await dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
|
var liveRoom = await dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
|
||||||
@@ -175,12 +183,26 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
|
|
||||||
if (!settings.AutoStartRecordingOnLive)
|
if (!settings.AutoStartRecordingOnLive)
|
||||||
{
|
{
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
dbContext,
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.SkippedDisabled,
|
||||||
|
"Auto-start skipped because automatic start is disabled.",
|
||||||
|
detail: null,
|
||||||
|
cancellationToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var startCheck = storageGuardService.CheckCanStartOrResume(settings);
|
var startCheck = storageGuardService.CheckCanStartOrResume(settings);
|
||||||
if (!startCheck.HasEnoughSpace)
|
if (!startCheck.HasEnoughSpace)
|
||||||
{
|
{
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
dbContext,
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.SkippedStorage,
|
||||||
|
"Auto-start skipped because storage is below threshold.",
|
||||||
|
startCheck.Message,
|
||||||
|
cancellationToken);
|
||||||
await logService.WriteAsync(
|
await logService.WriteAsync(
|
||||||
SystemLogLevel.Warning,
|
SystemLogLevel.Warning,
|
||||||
"Storage",
|
"Storage",
|
||||||
@@ -216,6 +238,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
|
|
||||||
if (hasRunningSession)
|
if (hasRunningSession)
|
||||||
{
|
{
|
||||||
|
await UpdateAutoStartDecisionAsync(
|
||||||
|
dbContext,
|
||||||
|
liveRoom,
|
||||||
|
AutoStartDecisionCodes.SkippedActiveSession,
|
||||||
|
"Auto-start skipped because an active recording session already exists.",
|
||||||
|
detail: null,
|
||||||
|
cancellationToken);
|
||||||
await logService.WriteAsync(
|
await logService.WriteAsync(
|
||||||
SystemLogLevel.Info,
|
SystemLogLevel.Info,
|
||||||
"Scheduler",
|
"Scheduler",
|
||||||
@@ -238,6 +267,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
{
|
{
|
||||||
LiveRoomId = liveRoom.Id
|
LiveRoomId = liveRoom.Id
|
||||||
},
|
},
|
||||||
|
trackAutoStartDecision: true,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -263,6 +293,12 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
ex.ToString(),
|
ex.ToString(),
|
||||||
liveRoom,
|
liveRoom,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
await webhookNotificationService.SendExceptionAsync(
|
||||||
|
"Scheduler",
|
||||||
|
"Background polling failed for a live room.",
|
||||||
|
ex.ToString(),
|
||||||
|
liveRoom,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -515,6 +551,22 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
return exception.InnerException is not null && IsSqliteStorageFullException(exception.InnerException);
|
return exception.InnerException is not null && IsSqliteStorageFullException(exception.InnerException);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task UpdateAutoStartDecisionAsync(
|
||||||
|
LiveRecorderDbContext dbContext,
|
||||||
|
Domain.Entities.LiveRoom liveRoom,
|
||||||
|
string code,
|
||||||
|
string summary,
|
||||||
|
string? detail,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
liveRoom.SetLastAutoStartDecision(
|
||||||
|
Truncate(code, 64),
|
||||||
|
Truncate(summary, 256),
|
||||||
|
Truncate(detail, 2048),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
await SaveChangesWithRetryAsync(dbContext, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task SaveChangesWithRetryAsync(LiveRecorderDbContext dbContext, CancellationToken cancellationToken)
|
private static async Task SaveChangesWithRetryAsync(LiveRecorderDbContext dbContext, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
for (var attempt = 1; attempt <= 5; attempt++)
|
for (var attempt = 1; attempt <= 5; attempt++)
|
||||||
@@ -539,4 +591,15 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
|
|||||||
exception.SqliteErrorCode is 5 or 6 ||
|
exception.SqliteErrorCode is 5 or 6 ||
|
||||||
exception.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase) ||
|
exception.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase) ||
|
||||||
exception.Message.Contains("database table is locked", StringComparison.OrdinalIgnoreCase);
|
exception.Message.Contains("database table is locked", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string? Truncate(string? value, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = value.Trim();
|
||||||
|
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
using LiveRecorder.Application.Abstractions.Recording;
|
||||||
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Application.Abstractions.Storage;
|
||||||
|
using LiveRecorder.Application.Common;
|
||||||
|
using LiveRecorder.Application.Models.Recovery;
|
||||||
|
using LiveRecorder.Application.Models.RecordTasks;
|
||||||
|
using LiveRecorder.Application.Services;
|
||||||
|
using LiveRecorder.Domain.Entities;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class RecoveryService
|
||||||
|
{
|
||||||
|
private readonly LiveRecorderDbContext _dbContext;
|
||||||
|
private readonly ISystemSettingsService _systemSettingsService;
|
||||||
|
private readonly IStorageGuardService _storageGuardService;
|
||||||
|
private readonly IFfmpegService _ffmpegService;
|
||||||
|
private readonly RecordService _recordService;
|
||||||
|
|
||||||
|
public RecoveryService(
|
||||||
|
LiveRecorderDbContext dbContext,
|
||||||
|
ISystemSettingsService systemSettingsService,
|
||||||
|
IStorageGuardService storageGuardService,
|
||||||
|
IFfmpegService ffmpegService,
|
||||||
|
RecordService recordService)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_systemSettingsService = systemSettingsService;
|
||||||
|
_storageGuardService = storageGuardService;
|
||||||
|
_ffmpegService = ffmpegService;
|
||||||
|
_recordService = recordService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecoveryOverviewDto> GetOverviewAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
|
var storage = _storageGuardService.CheckCanStartOrResume(settings);
|
||||||
|
var liveRooms = await ListRecoverableLiveRoomsAsync(cancellationToken);
|
||||||
|
var finalizations = await ListRecoverableFinalizationsAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new RecoveryOverviewDto
|
||||||
|
{
|
||||||
|
Storage = new StorageGuardStatusDto
|
||||||
|
{
|
||||||
|
IsEnabled = storage.IsEnabled,
|
||||||
|
HasEnoughSpace = storage.HasEnoughSpace,
|
||||||
|
CheckedPath = storage.CheckedPath,
|
||||||
|
AvailableBytes = storage.AvailableBytes,
|
||||||
|
RequiredBytes = storage.RequiredBytes,
|
||||||
|
Message = storage.Message
|
||||||
|
},
|
||||||
|
LiveRooms = liveRooms,
|
||||||
|
Finalizations = finalizations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecoveryActionResultDto> RetryLiveRoomAsync(Guid liveRoomId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var room = await _dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
|
||||||
|
if (room is null)
|
||||||
|
{
|
||||||
|
return FailureResult("Live room was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.IsEnabled)
|
||||||
|
{
|
||||||
|
return FailureResult("Live room is disabled and cannot be retried.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.AvailabilityStatus != LiveRoomAvailabilityStatus.Live)
|
||||||
|
{
|
||||||
|
return FailureResult("Live room is not currently online.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasActiveSession = await _dbContext.RecordSessions.AnyAsync(
|
||||||
|
item => item.LiveRoomId == room.Id &&
|
||||||
|
(item.Status == RecordSessionStatus.Starting ||
|
||||||
|
item.Status == RecordSessionStatus.Running ||
|
||||||
|
item.Status == RecordSessionStatus.Stopping),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (hasActiveSession)
|
||||||
|
{
|
||||||
|
room.SetLastAutoStartDecision(
|
||||||
|
AutoStartDecisionCodes.SkippedActiveSession,
|
||||||
|
"Auto-start skipped because an active recording session already exists.",
|
||||||
|
null,
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return FailureResult("An active recording session already exists for this live room.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var task = await _recordService.StartAsync(
|
||||||
|
new StartRecordTaskRequest
|
||||||
|
{
|
||||||
|
LiveRoomId = liveRoomId
|
||||||
|
},
|
||||||
|
trackAutoStartDecision: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var started = task.Status is RecordTaskStatus.Starting or RecordTaskStatus.Running;
|
||||||
|
return new RecoveryActionResultDto
|
||||||
|
{
|
||||||
|
RequestedCount = 1,
|
||||||
|
SuccessCount = started ? 1 : 0,
|
||||||
|
FailedCount = started ? 0 : 1,
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
started
|
||||||
|
? $"Recording retry started for room {room.RoomId}."
|
||||||
|
: $"Recording retry did not start for room {room.RoomId}. Status={task.Status}; Error={task.ErrorMessage ?? "n/a"}"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FailureResult($"Recording retry failed for room {room.RoomId}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecoveryActionResultDto> RetryAllLiveRoomsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var liveRooms = await ListRecoverableLiveRoomsAsync(cancellationToken);
|
||||||
|
if (liveRooms.Count == 0)
|
||||||
|
{
|
||||||
|
return new RecoveryActionResultDto
|
||||||
|
{
|
||||||
|
RequestedCount = 0,
|
||||||
|
SuccessCount = 0,
|
||||||
|
FailedCount = 0,
|
||||||
|
Messages = ["No live rooms currently require retry."]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages = new List<string>();
|
||||||
|
var successCount = 0;
|
||||||
|
foreach (var item in liveRooms)
|
||||||
|
{
|
||||||
|
var result = await RetryLiveRoomAsync(item.LiveRoomId, cancellationToken);
|
||||||
|
successCount += result.SuccessCount;
|
||||||
|
messages.AddRange(result.Messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RecoveryActionResultDto
|
||||||
|
{
|
||||||
|
RequestedCount = liveRooms.Count,
|
||||||
|
SuccessCount = successCount,
|
||||||
|
FailedCount = liveRooms.Count - successCount,
|
||||||
|
Messages = messages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecoveryActionResultDto> ResumeFinalizationAsync(Guid recordTaskId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var started = await _ffmpegService.StartManualFinalizeTaskAsync(recordTaskId, cancellationToken);
|
||||||
|
return new RecoveryActionResultDto
|
||||||
|
{
|
||||||
|
RequestedCount = 1,
|
||||||
|
SuccessCount = started ? 1 : 0,
|
||||||
|
FailedCount = started ? 0 : 1,
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
started
|
||||||
|
? $"MP4 finalization resumed for task {recordTaskId}."
|
||||||
|
: $"MP4 finalization could not be resumed for task {recordTaskId}."
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecoveryActionResultDto> ResumeAllFinalizationsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var finalizations = await ListRecoverableFinalizationsAsync(cancellationToken);
|
||||||
|
if (finalizations.Count == 0)
|
||||||
|
{
|
||||||
|
return new RecoveryActionResultDto
|
||||||
|
{
|
||||||
|
RequestedCount = 0,
|
||||||
|
SuccessCount = 0,
|
||||||
|
FailedCount = 0,
|
||||||
|
Messages = ["No MP4 finalization tasks currently require recovery."]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var successCount = 0;
|
||||||
|
var messages = new List<string>();
|
||||||
|
foreach (var item in finalizations)
|
||||||
|
{
|
||||||
|
var started = await _ffmpegService.StartManualFinalizeTaskAsync(item.RecordTaskId, cancellationToken);
|
||||||
|
if (started)
|
||||||
|
{
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.Add(
|
||||||
|
started
|
||||||
|
? $"MP4 finalization resumed for task {item.RecordTaskId}."
|
||||||
|
: $"MP4 finalization could not be resumed for task {item.RecordTaskId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RecoveryActionResultDto
|
||||||
|
{
|
||||||
|
RequestedCount = finalizations.Count,
|
||||||
|
SuccessCount = successCount,
|
||||||
|
FailedCount = finalizations.Count - successCount,
|
||||||
|
Messages = messages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<RecoverableLiveRoomDto>> ListRecoverableLiveRoomsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var activeLiveRoomIds = await _dbContext.RecordSessions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Status == RecordSessionStatus.Starting ||
|
||||||
|
item.Status == RecordSessionStatus.Running ||
|
||||||
|
item.Status == RecordSessionStatus.Stopping)
|
||||||
|
.Select(item => item.LiveRoomId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rooms = await _dbContext.LiveRooms
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.IsEnabled &&
|
||||||
|
item.AvailabilityStatus == LiveRoomAvailabilityStatus.Live &&
|
||||||
|
item.LastAutoStartDecisionCode != AutoStartDecisionCodes.Started)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rooms
|
||||||
|
.Where(item => !activeLiveRoomIds.Contains(item.Id))
|
||||||
|
.OrderByDescending(item => item.LastAutoStartDecisionAt ?? item.LastCheckedAt ?? item.UpdatedAt)
|
||||||
|
.Select(item => new RecoverableLiveRoomDto
|
||||||
|
{
|
||||||
|
LiveRoomId = item.Id,
|
||||||
|
PlatformName = item.Platform.ToString(),
|
||||||
|
RoomId = item.RoomId,
|
||||||
|
Title = item.Title,
|
||||||
|
AnchorName = item.AnchorName,
|
||||||
|
LastAutoStartDecisionCode = item.LastAutoStartDecisionCode,
|
||||||
|
LastAutoStartDecisionSummary = item.LastAutoStartDecisionSummary,
|
||||||
|
LastAutoStartDecisionDetail = item.LastAutoStartDecisionDetail,
|
||||||
|
LastAutoStartDecisionAt = item.LastAutoStartDecisionAt,
|
||||||
|
LastCheckedAt = item.LastCheckedAt
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<RecoverableFinalizationDto>> ListRecoverableFinalizationsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tasks = await _dbContext.RecordTasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(item => item.LiveRoom)
|
||||||
|
.Include(item => item.RecordSession)
|
||||||
|
.Include(item => item.Result)
|
||||||
|
.Where(item => item.OutputFormat == RecordOutputFormat.Mp4 &&
|
||||||
|
item.RecordSession != null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
.Where(IsRecoverableFinalization)
|
||||||
|
.OrderByDescending(item => item.UpdatedAt)
|
||||||
|
.Select(item => new RecoverableFinalizationDto
|
||||||
|
{
|
||||||
|
RecordTaskId = item.Id,
|
||||||
|
RecordSessionId = item.RecordSessionId,
|
||||||
|
LiveRoomId = item.LiveRoomId,
|
||||||
|
LiveRoomTitle = item.LiveRoom?.Title ?? item.LiveRoom?.AnchorName ?? item.LiveRoom?.RoomId ?? item.LiveRoomId.ToString(),
|
||||||
|
RoomId = item.LiveRoom?.RoomId ?? "-",
|
||||||
|
PlatformName = item.LiveRoom?.Platform.ToString() ?? "Unknown",
|
||||||
|
SegmentIndex = item.SegmentIndex,
|
||||||
|
Status = item.Status,
|
||||||
|
OutputFilePath = item.OutputFilePath,
|
||||||
|
Reason = BuildFinalizationReason(item),
|
||||||
|
CreatedAt = item.CreatedAt,
|
||||||
|
EndedAt = item.EndedAt
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRecoverableFinalization(RecordTask recordTask)
|
||||||
|
{
|
||||||
|
if (recordTask.RecordSession is null ||
|
||||||
|
recordTask.OutputFormat != RecordOutputFormat.Mp4)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordTask.Status == RecordTaskStatus.Processing)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordTask.Status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordTask.RecordSession.Status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(recordTask.OutputFilePath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var recorderOutputPath = ResolveManualFinalizeSourcePath(recordTask, recordTask.RecordSession);
|
||||||
|
if (!File.Exists(recorderOutputPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordTask.Status == RecordTaskStatus.Completed)
|
||||||
|
{
|
||||||
|
var finalOutputPath = NormalizeAbsolutePath(recordTask.OutputFilePath);
|
||||||
|
return !HasUsableOutput(finalOutputPath, CalculateFileSize(finalOutputPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildFinalizationReason(RecordTask recordTask)
|
||||||
|
{
|
||||||
|
if (recordTask.Status == RecordTaskStatus.Processing)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(recordTask.ErrorMessage)
|
||||||
|
? "MP4 finalization is queued or paused and can be resumed."
|
||||||
|
: recordTask.ErrorMessage!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordTask.Status == RecordTaskStatus.Completed)
|
||||||
|
{
|
||||||
|
return "The final MP4 output is missing, but the intermediate recording file is still available.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Manual MP4 finalization can be retried from the intermediate recording file.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveManualFinalizeSourcePath(RecordTask recordTask, RecordSession recordSession)
|
||||||
|
{
|
||||||
|
var resultPath = recordTask.Result?.FilePath;
|
||||||
|
if (!string.IsNullOrWhiteSpace(resultPath) &&
|
||||||
|
resultPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var normalizedResultPath = NormalizeAbsolutePath(resultPath);
|
||||||
|
if (File.Exists(normalizedResultPath))
|
||||||
|
{
|
||||||
|
return normalizedResultPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NormalizeAbsolutePath(
|
||||||
|
GetRecorderOutputPath(
|
||||||
|
recordTask.OutputFilePath ?? resultPath ?? string.Empty,
|
||||||
|
recordSession.OutputFormat,
|
||||||
|
recordSession.SaveMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRecorderOutputPath(
|
||||||
|
string finalOutputPath,
|
||||||
|
RecordOutputFormat outputFormat,
|
||||||
|
RecordSaveMode saveMode)
|
||||||
|
{
|
||||||
|
if (outputFormat != RecordOutputFormat.Mp4)
|
||||||
|
{
|
||||||
|
return finalOutputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveMode == RecordSaveMode.SingleFile)
|
||||||
|
{
|
||||||
|
return Path.Combine(
|
||||||
|
Path.GetDirectoryName(finalOutputPath)!,
|
||||||
|
$"{Path.GetFileNameWithoutExtension(finalOutputPath)}.recording.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.ChangeExtension(finalOutputPath, ".ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long? CalculateFileSize(string? outputPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(outputPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(outputPath))
|
||||||
|
{
|
||||||
|
return new FileInfo(outputPath).Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(outputPath))
|
||||||
|
{
|
||||||
|
return new DirectoryInfo(outputPath)
|
||||||
|
.EnumerateFiles("*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Sum(static file => file.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasUsableOutput(string? outputPath, long? fileSize)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(outputPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(outputPath))
|
||||||
|
{
|
||||||
|
return fileSize.GetValueOrDefault() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(outputPath))
|
||||||
|
{
|
||||||
|
return Directory.EnumerateFiles(outputPath, "*", SearchOption.TopDirectoryOnly).Any();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeAbsolutePath(string path) =>
|
||||||
|
Path.IsPathRooted(path)
|
||||||
|
? path
|
||||||
|
: Path.GetFullPath(path, AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
private static RecoveryActionResultDto FailureResult(string message) => new()
|
||||||
|
{
|
||||||
|
RequestedCount = 1,
|
||||||
|
SuccessCount = 0,
|
||||||
|
FailedCount = 1,
|
||||||
|
Messages = [message]
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using LiveRecorder.Application.Abstractions.Logging;
|
||||||
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class RetentionCleanupBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(24);
|
||||||
|
|
||||||
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
|
private readonly ILogger<RetentionCleanupBackgroundService> _logger;
|
||||||
|
|
||||||
|
public RetentionCleanupBackgroundService(
|
||||||
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
|
ILogger<RetentionCleanupBackgroundService> logger)
|
||||||
|
{
|
||||||
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _serviceScopeFactory.CreateScope();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||||
|
var cleanupService = scope.ServiceProvider.GetRequiredService<RetentionCleanupService>();
|
||||||
|
var settings = await settingsService.GetAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (settings.EnableRetentionCleanup)
|
||||||
|
{
|
||||||
|
await cleanupService.RunAsync(ignoreEnabledSetting: false, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Retention cleanup background task failed");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _serviceScopeFactory.CreateScope();
|
||||||
|
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||||
|
await logService.WriteAsync(
|
||||||
|
SystemLogLevel.Error,
|
||||||
|
"Retention",
|
||||||
|
"Retention cleanup background task failed.",
|
||||||
|
ex.ToString(),
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception logEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(logEx, "Failed to persist retention cleanup background error log");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(CleanupInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using LiveRecorder.Application.Abstractions.Logging;
|
||||||
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Application.Models.Settings;
|
||||||
|
using LiveRecorder.Domain.Entities;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class RetentionCleanupService
|
||||||
|
{
|
||||||
|
private readonly LiveRecorderDbContext _dbContext;
|
||||||
|
private readonly ISystemSettingsService _systemSettingsService;
|
||||||
|
private readonly ISystemLogService _systemLogService;
|
||||||
|
|
||||||
|
public RetentionCleanupService(
|
||||||
|
LiveRecorderDbContext dbContext,
|
||||||
|
ISystemSettingsService systemSettingsService,
|
||||||
|
ISystemLogService systemLogService)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_systemSettingsService = systemSettingsService;
|
||||||
|
_systemLogService = systemLogService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RetentionCleanupResultDto> RunAsync(
|
||||||
|
bool ignoreEnabledSetting = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
|
if (!settings.EnableRetentionCleanup && !ignoreEnabledSetting)
|
||||||
|
{
|
||||||
|
return CreateEmptyResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var deletedFilePaths = new List<string>();
|
||||||
|
var deletedDanmakuPaths = new List<string>();
|
||||||
|
var deletedTaskIds = new HashSet<Guid>();
|
||||||
|
var deletedSessionIds = new HashSet<Guid>();
|
||||||
|
var deletedResultIds = new HashSet<Guid>();
|
||||||
|
var deletedLogIds = new HashSet<Guid>();
|
||||||
|
var cutoff = DateTimeOffset.UtcNow.AddDays(-Math.Max(1, settings.RetentionDays));
|
||||||
|
|
||||||
|
var staleTasks = await _dbContext.RecordTasks
|
||||||
|
.Include(item => item.LiveRoom)
|
||||||
|
.Include(item => item.RecordSession)
|
||||||
|
.Include(item => item.Result)
|
||||||
|
.Where(item => item.CreatedAt < cutoff &&
|
||||||
|
item.Status != RecordTaskStatus.Starting &&
|
||||||
|
item.Status != RecordTaskStatus.Running &&
|
||||||
|
item.Status != RecordTaskStatus.Stopping &&
|
||||||
|
item.Status != RecordTaskStatus.Processing)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var task in staleTasks)
|
||||||
|
{
|
||||||
|
if (settings.RetentionDeleteFiles)
|
||||||
|
{
|
||||||
|
TryDeleteRecordOutput(task, warnings, deletedFilePaths, deletedDanmakuPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskLogs = await _dbContext.SystemLogEntries
|
||||||
|
.Where(item => item.RecordTaskId == task.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var log in taskLogs)
|
||||||
|
{
|
||||||
|
deletedLogIds.Add(log.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.Result is not null)
|
||||||
|
{
|
||||||
|
deletedResultIds.Add(task.Result.Id);
|
||||||
|
_dbContext.RecordResults.Remove(task.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskLogs.Count > 0)
|
||||||
|
{
|
||||||
|
_dbContext.SystemLogEntries.RemoveRange(taskLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedTaskIds.Add(task.Id);
|
||||||
|
_dbContext.RecordTasks.Remove(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedTaskIds.Count > 0 || deletedResultIds.Count > 0 || deletedLogIds.Count > 0)
|
||||||
|
{
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var staleSessions = await _dbContext.RecordSessions
|
||||||
|
.Include(item => item.RecordTasks)
|
||||||
|
.Where(item => item.CreatedAt < cutoff &&
|
||||||
|
item.Status != RecordSessionStatus.Starting &&
|
||||||
|
item.Status != RecordSessionStatus.Running &&
|
||||||
|
item.Status != RecordSessionStatus.Stopping)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var session in staleSessions.Where(static item => item.RecordTasks.Count == 0))
|
||||||
|
{
|
||||||
|
var sessionLogs = await _dbContext.SystemLogEntries
|
||||||
|
.Where(item => item.RecordSessionId == session.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var log in sessionLogs)
|
||||||
|
{
|
||||||
|
deletedLogIds.Add(log.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionLogs.Count > 0)
|
||||||
|
{
|
||||||
|
_dbContext.SystemLogEntries.RemoveRange(sessionLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedSessionIds.Add(session.Id);
|
||||||
|
_dbContext.RecordSessions.Remove(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
var staleGlobalLogs = await _dbContext.SystemLogEntries
|
||||||
|
.Where(item => item.CreatedAt < cutoff)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var log in staleGlobalLogs)
|
||||||
|
{
|
||||||
|
deletedLogIds.Add(log.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staleGlobalLogs.Count > 0)
|
||||||
|
{
|
||||||
|
_dbContext.SystemLogEntries.RemoveRange(staleGlobalLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedSessionIds.Count > 0 || staleGlobalLogs.Count > 0)
|
||||||
|
{
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new RetentionCleanupResultDto
|
||||||
|
{
|
||||||
|
DeletedSessionCount = deletedSessionIds.Count,
|
||||||
|
DeletedTaskCount = deletedTaskIds.Count,
|
||||||
|
DeletedResultCount = deletedResultIds.Count,
|
||||||
|
DeletedLogCount = deletedLogIds.Count,
|
||||||
|
DeletedFileCount = deletedFilePaths.Count,
|
||||||
|
DeletedDanmakuFileCount = deletedDanmakuPaths.Count,
|
||||||
|
Warnings = warnings
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deletedSessionIds.Count > 0 ||
|
||||||
|
deletedTaskIds.Count > 0 ||
|
||||||
|
deletedResultIds.Count > 0 ||
|
||||||
|
deletedLogIds.Count > 0 ||
|
||||||
|
deletedFilePaths.Count > 0 ||
|
||||||
|
deletedDanmakuPaths.Count > 0 ||
|
||||||
|
warnings.Count > 0)
|
||||||
|
{
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
SystemLogLevel.Info,
|
||||||
|
"Retention",
|
||||||
|
"Retention cleanup completed.",
|
||||||
|
$"sessions={result.DeletedSessionCount}; tasks={result.DeletedTaskCount}; results={result.DeletedResultCount}; logs={result.DeletedLogCount}; video-files={result.DeletedFileCount}; danmaku-files={result.DeletedDanmakuFileCount}; warnings={warnings.Count}",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RetentionCleanupResultDto CreateEmptyResult() => new()
|
||||||
|
{
|
||||||
|
DeletedSessionCount = 0,
|
||||||
|
DeletedTaskCount = 0,
|
||||||
|
DeletedResultCount = 0,
|
||||||
|
DeletedLogCount = 0,
|
||||||
|
DeletedFileCount = 0,
|
||||||
|
DeletedDanmakuFileCount = 0,
|
||||||
|
Warnings = []
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void TryDeleteRecordOutput(
|
||||||
|
RecordTask recordTask,
|
||||||
|
List<string> warnings,
|
||||||
|
List<string> deletedFilePaths,
|
||||||
|
List<string> deletedDanmakuPaths)
|
||||||
|
{
|
||||||
|
var outputPath = recordTask.Result?.FilePath ?? recordTask.OutputFilePath;
|
||||||
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||||
|
{
|
||||||
|
TryDeletePath(outputPath, warnings, deletedFilePaths, $"output for task {recordTask.Id}");
|
||||||
|
TryDeleteIntermediateRecordingArtifacts(outputPath, recordTask.OutputFormat, warnings, deletedFilePaths, recordTask.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var danmakuPath = recordTask.Result?.DanmakuFilePath;
|
||||||
|
if (!string.IsNullOrWhiteSpace(danmakuPath))
|
||||||
|
{
|
||||||
|
TryDeletePath(danmakuPath, warnings, deletedDanmakuPaths, $"danmaku for task {recordTask.Id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteIntermediateRecordingArtifacts(
|
||||||
|
string finalOutputPath,
|
||||||
|
RecordOutputFormat outputFormat,
|
||||||
|
List<string> warnings,
|
||||||
|
List<string> deletedFilePaths,
|
||||||
|
Guid recordTaskId)
|
||||||
|
{
|
||||||
|
if (outputFormat != RecordOutputFormat.Mp4)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var absoluteFinalPath = Path.IsPathRooted(finalOutputPath)
|
||||||
|
? finalOutputPath
|
||||||
|
: Path.GetFullPath(finalOutputPath, AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
var intermediateCandidates = new[]
|
||||||
|
{
|
||||||
|
Path.ChangeExtension(absoluteFinalPath, ".ts"),
|
||||||
|
Path.Combine(
|
||||||
|
Path.GetDirectoryName(absoluteFinalPath) ?? string.Empty,
|
||||||
|
$"{Path.GetFileNameWithoutExtension(absoluteFinalPath)}.recording.ts")
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var candidate in intermediateCandidates
|
||||||
|
.Where(static path => !string.IsNullOrWhiteSpace(path))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (string.Equals(candidate, absoluteFinalPath, StringComparison.OrdinalIgnoreCase) || !File.Exists(candidate))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TryDeletePath(candidate, warnings, deletedFilePaths, $"intermediate output for task {recordTaskId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeletePath(
|
||||||
|
string path,
|
||||||
|
List<string> warnings,
|
||||||
|
List<string> deletedPaths,
|
||||||
|
string label)
|
||||||
|
{
|
||||||
|
var absolutePath = Path.IsPathRooted(path)
|
||||||
|
? path
|
||||||
|
: Path.GetFullPath(path, AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (IsUnsafeDeletionTarget(absolutePath))
|
||||||
|
{
|
||||||
|
warnings.Add($"Skipped deleting suspicious path: {absolutePath}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(absolutePath))
|
||||||
|
{
|
||||||
|
File.Delete(absolutePath);
|
||||||
|
deletedPaths.Add(absolutePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(absolutePath))
|
||||||
|
{
|
||||||
|
Directory.Delete(absolutePath, true);
|
||||||
|
deletedPaths.Add(absolutePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings.Add($"Path not found for {label}: {absolutePath}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
warnings.Add($"Failed to delete {label}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUnsafeDeletionTarget(string absolutePath)
|
||||||
|
{
|
||||||
|
var normalized = absolutePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var root = Path.GetPathRoot(normalized)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
return string.IsNullOrWhiteSpace(normalized) ||
|
||||||
|
normalized.Length < 4 ||
|
||||||
|
string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LiveRecorder.Application.Abstractions.Logging;
|
||||||
|
using LiveRecorder.Application.Abstractions.Notifications;
|
||||||
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Application.Models.Settings;
|
||||||
|
using LiveRecorder.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||||
|
{
|
||||||
|
private const string AppName = "LiveRecorder";
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ISystemSettingsService _systemSettingsService;
|
||||||
|
private readonly ISystemLogService _systemLogService;
|
||||||
|
private readonly ILogger<WebhookNotificationService> _logger;
|
||||||
|
|
||||||
|
public WebhookNotificationService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ISystemSettingsService systemSettingsService,
|
||||||
|
ISystemLogService systemLogService,
|
||||||
|
ILogger<WebhookNotificationService> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_systemSettingsService = systemSettingsService;
|
||||||
|
_systemLogService = systemLogService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
|
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnLiveStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = BuildPayload(
|
||||||
|
"live_started",
|
||||||
|
$"Live started: {liveRoom.AnchorName ?? liveRoom.RoomId}",
|
||||||
|
liveRoom.SourceUrl,
|
||||||
|
liveRoom,
|
||||||
|
recordTask: null);
|
||||||
|
|
||||||
|
await SendConfiguredWebhookAsync(
|
||||||
|
settings,
|
||||||
|
payload,
|
||||||
|
"Webhook notification sent for live_started.",
|
||||||
|
"Webhook notification failed for live_started.",
|
||||||
|
liveRoom.Id,
|
||||||
|
recordSessionId: null,
|
||||||
|
recordTaskId: null,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendExceptionAsync(
|
||||||
|
string source,
|
||||||
|
string summary,
|
||||||
|
string? detail = null,
|
||||||
|
LiveRoom? liveRoom = null,
|
||||||
|
RecordTask? recordTask = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
|
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = BuildPayload(
|
||||||
|
"exception",
|
||||||
|
summary,
|
||||||
|
detail,
|
||||||
|
liveRoom,
|
||||||
|
recordTask,
|
||||||
|
source);
|
||||||
|
|
||||||
|
await SendConfiguredWebhookAsync(
|
||||||
|
settings,
|
||||||
|
payload,
|
||||||
|
"Webhook notification sent for exception.",
|
||||||
|
"Webhook notification failed for exception.",
|
||||||
|
liveRoom?.Id,
|
||||||
|
recordSessionId: recordTask?.RecordSessionId,
|
||||||
|
recordTaskId: recordTask?.Id,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebhookTestResultDto> SendTestAsync(
|
||||||
|
SendTestWebhookRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var settings = new SystemSettingsDto
|
||||||
|
{
|
||||||
|
EnableWebhookNotification = true,
|
||||||
|
WebhookUrl = request.WebhookUrl.Trim(),
|
||||||
|
WebhookHeaders = request.WebhookHeaders,
|
||||||
|
WebhookTimeoutSeconds = request.WebhookTimeoutSeconds,
|
||||||
|
NotifyWebhookOnLiveStarted = true,
|
||||||
|
NotifyWebhookOnException = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var payload = BuildPayload(
|
||||||
|
"live_started",
|
||||||
|
"Webhook test from LiveRecorder.",
|
||||||
|
"This is a sample webhook payload generated from the settings test action.",
|
||||||
|
new LiveRoom(
|
||||||
|
Domain.Enums.LivePlatformType.Douyin,
|
||||||
|
"https://live.douyin.com/676493068539",
|
||||||
|
"676493068539",
|
||||||
|
"https://live.douyin.com/676493068539",
|
||||||
|
DateTimeOffset.UtcNow),
|
||||||
|
recordTask: null);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await SendInternalAsync(settings, payload, cancellationToken);
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
result.Success ? Domain.Enums.SystemLogLevel.Info : Domain.Enums.SystemLogLevel.Warning,
|
||||||
|
"Webhook",
|
||||||
|
result.Success
|
||||||
|
? "Webhook test completed successfully."
|
||||||
|
: "Webhook test failed.",
|
||||||
|
result.Detail,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return new WebhookTestResultDto
|
||||||
|
{
|
||||||
|
Success = result.Success,
|
||||||
|
Message = result.Success
|
||||||
|
? "Webhook test completed successfully."
|
||||||
|
: "Webhook test failed.",
|
||||||
|
Detail = result.Detail
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Webhook test failed");
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
Domain.Enums.SystemLogLevel.Warning,
|
||||||
|
"Webhook",
|
||||||
|
"Webhook test failed.",
|
||||||
|
ex.ToString(),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return new WebhookTestResultDto
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Webhook test failed.",
|
||||||
|
Detail = ex.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendConfiguredWebhookAsync(
|
||||||
|
SystemSettingsDto settings,
|
||||||
|
object payload,
|
||||||
|
string successMessage,
|
||||||
|
string failureMessage,
|
||||||
|
Guid? liveRoomId,
|
||||||
|
Guid? recordSessionId,
|
||||||
|
Guid? recordTaskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await SendInternalAsync(settings, payload, cancellationToken);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
Domain.Enums.SystemLogLevel.Warning,
|
||||||
|
"Webhook",
|
||||||
|
failureMessage,
|
||||||
|
result.Detail,
|
||||||
|
liveRoomId,
|
||||||
|
recordSessionId,
|
||||||
|
recordTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
Domain.Enums.SystemLogLevel.Info,
|
||||||
|
"Webhook",
|
||||||
|
successMessage,
|
||||||
|
result.Detail,
|
||||||
|
liveRoomId,
|
||||||
|
recordSessionId,
|
||||||
|
recordTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "{FailureMessage}", failureMessage);
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
Domain.Enums.SystemLogLevel.Warning,
|
||||||
|
"Webhook",
|
||||||
|
failureMessage,
|
||||||
|
ex.ToString(),
|
||||||
|
liveRoomId,
|
||||||
|
recordSessionId,
|
||||||
|
recordTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WebhookSendResult> SendInternalAsync(
|
||||||
|
SystemSettingsDto settings,
|
||||||
|
object payload,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.WebhookUrl))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Webhook URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(settings.WebhookTimeoutSeconds, 1, 300));
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, settings.WebhookUrl.Trim())
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var header in ParseHeaders(settings.WebhookHeaders))
|
||||||
|
{
|
||||||
|
if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||||
|
{
|
||||||
|
request.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(request, cancellationToken);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var detail = BuildResponseDetail(settings.WebhookUrl, response, responseBody);
|
||||||
|
return new WebhookSendResult(response.IsSuccessStatusCode, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object BuildPayload(
|
||||||
|
string eventType,
|
||||||
|
string summary,
|
||||||
|
string? detail,
|
||||||
|
LiveRoom? liveRoom,
|
||||||
|
RecordTask? recordTask,
|
||||||
|
string? source = null)
|
||||||
|
{
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
appName = AppName,
|
||||||
|
eventType,
|
||||||
|
sentAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
summary,
|
||||||
|
detail,
|
||||||
|
source,
|
||||||
|
liveRoom = liveRoom is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
id = liveRoom.Id,
|
||||||
|
platform = liveRoom.Platform.ToString(),
|
||||||
|
roomId = liveRoom.RoomId,
|
||||||
|
title = liveRoom.Title,
|
||||||
|
anchorName = liveRoom.AnchorName,
|
||||||
|
sourceUrl = liveRoom.SourceUrl
|
||||||
|
},
|
||||||
|
recordTask = recordTask is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
id = recordTask.Id,
|
||||||
|
recordSessionId = recordTask.RecordSessionId,
|
||||||
|
status = recordTask.Status.ToString(),
|
||||||
|
segmentIndex = recordTask.SegmentIndex,
|
||||||
|
outputFilePath = recordTask.OutputFilePath
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<KeyValuePair<string, string>> ParseHeaders(string rawHeaders)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawHeaders))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = new List<KeyValuePair<string, string>>();
|
||||||
|
var lines = rawHeaders
|
||||||
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||||
|
.Replace('\r', '\n')
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var separatorIndex = line.IndexOf(':');
|
||||||
|
if (separatorIndex <= 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Invalid webhook header format: {line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = line[..separatorIndex].Trim();
|
||||||
|
var value = line[(separatorIndex + 1)..].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Invalid webhook header format: {line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Add(new KeyValuePair<string, string>(name, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildResponseDetail(string webhookUrl, HttpResponseMessage response, string responseBody)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.Append("url=").Append(webhookUrl.Trim());
|
||||||
|
builder.Append("; status=").Append((int)response.StatusCode);
|
||||||
|
builder.Append(' ').Append(response.ReasonPhrase);
|
||||||
|
|
||||||
|
var normalizedBody = responseBody.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedBody))
|
||||||
|
{
|
||||||
|
var truncatedBody = normalizedBody.Length <= 1000 ? normalizedBody : normalizedBody[..1000];
|
||||||
|
builder.Append("; body=").Append(truncatedBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record WebhookSendResult(bool Success, string Detail);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using LiveRecorder.Application.Models.Recovery;
|
||||||
|
using LiveRecorder.Infrastructure.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace LiveRecorder.WebApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/recovery")]
|
||||||
|
public sealed class RecoveryController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly RecoveryService _recoveryService;
|
||||||
|
|
||||||
|
public RecoveryController(RecoveryService recoveryService)
|
||||||
|
{
|
||||||
|
_recoveryService = recoveryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<RecoveryOverviewDto>> Get(CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _recoveryService.GetOverviewAsync(cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("live-rooms/{id:guid}/retry")]
|
||||||
|
public async Task<ActionResult<RecoveryActionResultDto>> RetryLiveRoom(Guid id, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _recoveryService.RetryLiveRoomAsync(id, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("live-rooms/retry-all")]
|
||||||
|
public async Task<ActionResult<RecoveryActionResultDto>> RetryAllLiveRooms(CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _recoveryService.RetryAllLiveRoomsAsync(cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("finalizations/{taskId:guid}/resume")]
|
||||||
|
public async Task<ActionResult<RecoveryActionResultDto>> ResumeFinalization(Guid taskId, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _recoveryService.ResumeFinalizationAsync(taskId, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("finalizations/resume-all")]
|
||||||
|
public async Task<ActionResult<RecoveryActionResultDto>> ResumeAllFinalizations(CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _recoveryService.ResumeAllFinalizationsAsync(cancellationToken));
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using LiveRecorder.Application.Models.Reports;
|
||||||
|
using LiveRecorder.Application.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace LiveRecorder.WebApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/reports")]
|
||||||
|
public sealed class ReportsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly SessionAnalyticsService _sessionAnalyticsService;
|
||||||
|
|
||||||
|
public ReportsController(SessionAnalyticsService sessionAnalyticsService)
|
||||||
|
{
|
||||||
|
_sessionAnalyticsService = sessionAnalyticsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("daily")]
|
||||||
|
public async Task<ActionResult<DailyReviewReportDto>> GetDaily(
|
||||||
|
[FromQuery] string? date,
|
||||||
|
[FromQuery] int utcOffsetMinutes = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(date) ||
|
||||||
|
!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var localDate))
|
||||||
|
{
|
||||||
|
return BadRequest("The date query parameter must use YYYY-MM-DD format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(await _sessionAnalyticsService.GetDailyReviewAsync(localDate, utcOffsetMinutes, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using LiveRecorder.Application.Abstractions.Settings;
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
using LiveRecorder.Application.Abstractions.Notifications;
|
using LiveRecorder.Application.Abstractions.Notifications;
|
||||||
|
using LiveRecorder.Application.Abstractions.Scripting;
|
||||||
using LiveRecorder.Application.Models.Settings;
|
using LiveRecorder.Application.Models.Settings;
|
||||||
|
using LiveRecorder.Infrastructure.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace LiveRecorder.WebApi.Controllers;
|
namespace LiveRecorder.WebApi.Controllers;
|
||||||
@@ -11,13 +13,22 @@ public sealed class SettingsController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly ISystemSettingsService _systemSettingsService;
|
private readonly ISystemSettingsService _systemSettingsService;
|
||||||
private readonly IEmailNotificationService _emailNotificationService;
|
private readonly IEmailNotificationService _emailNotificationService;
|
||||||
|
private readonly IEventScriptService _eventScriptService;
|
||||||
|
private readonly IWebhookNotificationService _webhookNotificationService;
|
||||||
|
private readonly RetentionCleanupService _retentionCleanupService;
|
||||||
|
|
||||||
public SettingsController(
|
public SettingsController(
|
||||||
ISystemSettingsService systemSettingsService,
|
ISystemSettingsService systemSettingsService,
|
||||||
IEmailNotificationService emailNotificationService)
|
IEmailNotificationService emailNotificationService,
|
||||||
|
IEventScriptService eventScriptService,
|
||||||
|
IWebhookNotificationService webhookNotificationService,
|
||||||
|
RetentionCleanupService retentionCleanupService)
|
||||||
{
|
{
|
||||||
_systemSettingsService = systemSettingsService;
|
_systemSettingsService = systemSettingsService;
|
||||||
_emailNotificationService = emailNotificationService;
|
_emailNotificationService = emailNotificationService;
|
||||||
|
_eventScriptService = eventScriptService;
|
||||||
|
_webhookNotificationService = webhookNotificationService;
|
||||||
|
_retentionCleanupService = retentionCleanupService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -38,4 +49,20 @@ public sealed class SettingsController : ControllerBase
|
|||||||
await _emailNotificationService.SendTestAsync(request, cancellationToken);
|
await _emailNotificationService.SendTestAsync(request, cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("test-event-script")]
|
||||||
|
public async Task<ActionResult<EventScriptTestResultDto>> TestEventScript(
|
||||||
|
[FromBody] TestEventScriptRequest request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _eventScriptService.TestAsync(request, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("test-webhook")]
|
||||||
|
public async Task<ActionResult<WebhookTestResultDto>> TestWebhook(
|
||||||
|
[FromBody] SendTestWebhookRequest request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _webhookNotificationService.SendTestAsync(request, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("retention/run-now")]
|
||||||
|
public async Task<ActionResult<RetentionCleanupResultDto>> RunRetentionCleanupNow(CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _retentionCleanupService.RunAsync(ignoreEnabledSetting: true, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ builder.Services.AddScoped<IUserSessionRepository, UserSessionRepository>();
|
|||||||
builder.Services.AddScoped<ISystemSettingsService, SystemSettingsService>();
|
builder.Services.AddScoped<ISystemSettingsService, SystemSettingsService>();
|
||||||
builder.Services.AddScoped<ISystemLogService, SystemLogService>();
|
builder.Services.AddScoped<ISystemLogService, SystemLogService>();
|
||||||
builder.Services.AddScoped<IEmailNotificationService, EmailNotificationService>();
|
builder.Services.AddScoped<IEmailNotificationService, EmailNotificationService>();
|
||||||
|
builder.Services.AddScoped<IWebhookNotificationService, WebhookNotificationService>();
|
||||||
builder.Services.AddScoped<IEventScriptService, EventScriptService>();
|
builder.Services.AddScoped<IEventScriptService, EventScriptService>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<LiveRoomService>();
|
builder.Services.AddScoped<LiveRoomService>();
|
||||||
@@ -142,6 +143,9 @@ builder.Services.AddScoped<LiveRoomStatusService>();
|
|||||||
builder.Services.AddScoped<LiveRoomRecordingSettingsResolver>();
|
builder.Services.AddScoped<LiveRoomRecordingSettingsResolver>();
|
||||||
builder.Services.AddScoped<RecordService>();
|
builder.Services.AddScoped<RecordService>();
|
||||||
builder.Services.AddScoped<RecordSessionService>();
|
builder.Services.AddScoped<RecordSessionService>();
|
||||||
|
builder.Services.AddScoped<SessionAnalyticsService>();
|
||||||
|
builder.Services.AddScoped<RecoveryService>();
|
||||||
|
builder.Services.AddScoped<RetentionCleanupService>();
|
||||||
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
|
|
||||||
@@ -162,6 +166,7 @@ builder.Services.AddSingleton<IFfmpegService, FfmpegService>();
|
|||||||
builder.Services.AddSingleton<IStorageGuardService, StorageGuardService>();
|
builder.Services.AddSingleton<IStorageGuardService, StorageGuardService>();
|
||||||
builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
|
builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
|
||||||
builder.Services.AddHostedService<LiveRoomPollingBackgroundService>();
|
builder.Services.AddHostedService<LiveRoomPollingBackgroundService>();
|
||||||
|
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user