feat: add recovery center and daily reviews
This commit is contained in:
+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<IReadOnlyList<SystemLogEntry>> ListAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<SystemLogEntry>> ListByRecordTaskIdsAsync(
|
||||
IReadOnlyCollection<Guid> recordTaskIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Scripting;
|
||||
|
||||
@@ -16,5 +17,8 @@ public interface IEventScriptService
|
||||
string segmentFilePath,
|
||||
DateTimeOffset occurredAt,
|
||||
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 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 CreatedAt { get; init; }
|
||||
|
||||
@@ -48,9 +48,81 @@ public sealed class RecordSessionDetailDto
|
||||
{
|
||||
public required RecordSessionDto Session { get; init; }
|
||||
|
||||
public required RecordSessionTimelineDto Timeline { 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 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 bool EnableRetentionCleanup { get; set; } = false;
|
||||
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
|
||||
public bool RetentionDeleteFiles { get; set; } = false;
|
||||
|
||||
public bool EnableEmailNotification { get; set; } = false;
|
||||
|
||||
public string EmailSmtpHost { get; set; } = string.Empty;
|
||||
@@ -138,6 +144,18 @@ public sealed class SystemSettingsDto
|
||||
</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; } =
|
||||
"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 bool EnableRetentionCleanup { get; set; } = false;
|
||||
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
|
||||
public bool RetentionDeleteFiles { get; set; } = false;
|
||||
|
||||
public bool EnableEmailNotification { get; set; } = false;
|
||||
|
||||
public string EmailSmtpHost { get; set; } = string.Empty;
|
||||
@@ -275,6 +299,18 @@ public sealed class UpdateSystemSettingsRequest
|
||||
</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; } =
|
||||
"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>
|
||||
""";
|
||||
}
|
||||
|
||||
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.Recording;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Models.LiveRooms;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
@@ -424,6 +425,13 @@ public sealed class LiveRoomService
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
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(
|
||||
SystemLogLevel.Info,
|
||||
"LiveRoom",
|
||||
@@ -447,6 +455,7 @@ public sealed class LiveRoomService
|
||||
{
|
||||
LiveRoomId = liveRoom.Id
|
||||
},
|
||||
trackAutoStartDecision: true,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -478,6 +487,10 @@ public sealed class LiveRoomService
|
||||
EffectiveSettings = _liveRoomRecordingSettingsResolver.BuildEffectiveDto(effectiveSettings),
|
||||
IsEnabled = room.IsEnabled,
|
||||
AvailabilityStatus = room.AvailabilityStatus,
|
||||
LastAutoStartDecisionCode = room.LastAutoStartDecisionCode,
|
||||
LastAutoStartDecisionSummary = room.LastAutoStartDecisionSummary,
|
||||
LastAutoStartDecisionDetail = room.LastAutoStartDecisionDetail,
|
||||
LastAutoStartDecisionAt = room.LastAutoStartDecisionAt,
|
||||
LastCheckedAt = room.LastCheckedAt,
|
||||
CreatedAt = room.CreatedAt,
|
||||
UpdatedAt = room.UpdatedAt
|
||||
|
||||
@@ -9,13 +9,16 @@ namespace LiveRecorder.Application.Services;
|
||||
public sealed class LiveRoomStatusService
|
||||
{
|
||||
private readonly IEmailNotificationService _emailNotificationService;
|
||||
private readonly IWebhookNotificationService _webhookNotificationService;
|
||||
private readonly IEventScriptService _eventScriptService;
|
||||
|
||||
public LiveRoomStatusService(
|
||||
IEmailNotificationService emailNotificationService,
|
||||
IWebhookNotificationService webhookNotificationService,
|
||||
IEventScriptService eventScriptService)
|
||||
{
|
||||
_emailNotificationService = emailNotificationService;
|
||||
_webhookNotificationService = webhookNotificationService;
|
||||
_eventScriptService = eventScriptService;
|
||||
}
|
||||
|
||||
@@ -53,6 +56,7 @@ public sealed class LiveRoomStatusService
|
||||
}
|
||||
|
||||
await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
||||
await _webhookNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
||||
await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
|
||||
liveRoom.MarkLiveNotificationSent(observedAt);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
@@ -25,6 +26,7 @@ public sealed class RecordService
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
private readonly IEmailNotificationService _emailNotificationService;
|
||||
private readonly IWebhookNotificationService _webhookNotificationService;
|
||||
private readonly LiveRoomStatusService _liveRoomStatusService;
|
||||
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
|
||||
private readonly IStorageGuardService _storageGuardService;
|
||||
@@ -42,6 +44,7 @@ public sealed class RecordService
|
||||
ISystemSettingsService systemSettingsService,
|
||||
ISystemLogService systemLogService,
|
||||
IEmailNotificationService emailNotificationService,
|
||||
IWebhookNotificationService webhookNotificationService,
|
||||
LiveRoomStatusService liveRoomStatusService,
|
||||
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
|
||||
IStorageGuardService storageGuardService,
|
||||
@@ -58,6 +61,7 @@ public sealed class RecordService
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_systemLogService = systemLogService;
|
||||
_emailNotificationService = emailNotificationService;
|
||||
_webhookNotificationService = webhookNotificationService;
|
||||
_liveRoomStatusService = liveRoomStatusService;
|
||||
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
|
||||
_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);
|
||||
|
||||
@@ -123,6 +133,16 @@ public sealed class RecordService
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -137,6 +157,17 @@ public sealed class RecordService
|
||||
liveRoomId: liveRoom.Id,
|
||||
recordSessionId: activeSession.Id,
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -151,6 +182,17 @@ public sealed class RecordService
|
||||
storageCheck.Message,
|
||||
liveRoomId: liveRoom.Id,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -180,6 +222,15 @@ public sealed class RecordService
|
||||
{
|
||||
initialTask.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 _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
@@ -218,6 +269,16 @@ public sealed class RecordService
|
||||
initialTask.MarkRunning(DateTimeOffset.UtcNow);
|
||||
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(
|
||||
SystemLogLevel.Info,
|
||||
"RecordSession",
|
||||
@@ -232,6 +293,15 @@ public sealed class RecordService
|
||||
{
|
||||
initialTask.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 _systemLogService.WriteAsync(
|
||||
@@ -251,6 +321,13 @@ public sealed class RecordService
|
||||
liveRoom,
|
||||
initialTask,
|
||||
cancellationToken);
|
||||
await _webhookNotificationService.SendExceptionAsync(
|
||||
"RecordSession",
|
||||
"Recording session startup failed.",
|
||||
ex.ToString(),
|
||||
liveRoom,
|
||||
initialTask,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return RecordModelMapper.MapTask(initialTask);
|
||||
@@ -484,6 +561,16 @@ public sealed class RecordService
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -492,6 +579,12 @@ public sealed class RecordService
|
||||
{
|
||||
foreach (var liveRoomId in liveRoomIds)
|
||||
{
|
||||
await TryUpdateAutoStartDecisionAsync(
|
||||
liveRoomId,
|
||||
AutoStartDecisionCodes.SkippedStorage,
|
||||
"Auto-start skipped because storage is below threshold.",
|
||||
storageCheck.Message,
|
||||
cancellationToken);
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Storage",
|
||||
@@ -507,13 +600,35 @@ public sealed class RecordService
|
||||
foreach (var liveRoomId in liveRoomIds)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -531,6 +646,7 @@ public sealed class RecordService
|
||||
{
|
||||
LiveRoomId = liveRoom.Id
|
||||
},
|
||||
trackAutoStartDecision: true,
|
||||
cancellationToken);
|
||||
}
|
||||
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()
|
||||
{
|
||||
DeletedTaskIds = [],
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class RecordSessionService
|
||||
private readonly IFfmpegService _ffmpegService;
|
||||
private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
private readonly SessionAnalyticsService _sessionAnalyticsService;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
@@ -31,6 +32,7 @@ public sealed class RecordSessionService
|
||||
IFfmpegService ffmpegService,
|
||||
StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService,
|
||||
ISystemLogService systemLogService,
|
||||
SessionAnalyticsService sessionAnalyticsService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
@@ -41,6 +43,7 @@ public sealed class RecordSessionService
|
||||
_ffmpegService = ffmpegService;
|
||||
_stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService;
|
||||
_systemLogService = systemLogService;
|
||||
_sessionAnalyticsService = sessionAnalyticsService;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_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(
|
||||
session.RecordTasks.Select(static item => item.Id).ToArray());
|
||||
return new RecordSessionDetailDto
|
||||
{
|
||||
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>();
|
||||
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 SegmentCompletedScriptContentKey = "event_scripts.segment_completed.content";
|
||||
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 EmailSmtpHostKey = "notification.email.smtp_host";
|
||||
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 EmailExceptionSubjectTemplateKey = "notification.email.exception.subject_template";
|
||||
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 DouyinRefererKey = "douyin.referer";
|
||||
private const string DouyinCookieKey = "douyin.cookie";
|
||||
@@ -119,6 +128,9 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
SegmentCompletedScriptPath = GetValue(lookup, SegmentCompletedScriptPathKey, string.Empty),
|
||||
SegmentCompletedScriptContent = GetValue(lookup, SegmentCompletedScriptContentKey, string.Empty),
|
||||
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,
|
||||
EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty),
|
||||
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>
|
||||
"""),
|
||||
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(
|
||||
lookup,
|
||||
DouyinUserAgentKey,
|
||||
@@ -233,6 +251,9 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(SegmentCompletedScriptPathKey, request.SegmentCompletedScriptPath.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(SegmentCompletedScriptContentKey, request.SegmentCompletedScriptContent, now, cancellationToken);
|
||||
await UpsertAsync(EventScriptTimeoutSecondsKey, Math.Clamp(request.EventScriptTimeoutSeconds, 1, 3600).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(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(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), 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(EmailExceptionSubjectTemplateKey, request.EmailExceptionSubjectTemplate.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(DouyinRefererKey, request.DouyinReferer.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(DouyinCookieKey, request.DouyinCookie.Trim(), now, cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user