using LiveRecorder.Application.Abstractions.Persistence; using LiveRecorder.Application.Abstractions.Settings; using LiveRecorder.Application.Abstractions.Storage; using LiveRecorder.Application.Models.Reports; using LiveRecorder.Application.Common; using LiveRecorder.Domain.Enums; namespace LiveRecorder.Application.Services; public sealed class DashboardService { private readonly ILiveRoomRepository _liveRoomRepository; private readonly IRecordSessionRepository _recordSessionRepository; private readonly IRecordTaskRepository _recordTaskRepository; private readonly IRecordResultRepository _recordResultRepository; private readonly ISystemLogRepository _systemLogRepository; private readonly ISystemSettingsService _systemSettingsService; private readonly IStorageGuardService _storageGuardService; private readonly IOperationsMetricsRepository _operationsMetricsRepository; public DashboardService( ILiveRoomRepository liveRoomRepository, IRecordSessionRepository recordSessionRepository, IRecordTaskRepository recordTaskRepository, IRecordResultRepository recordResultRepository, ISystemLogRepository systemLogRepository, ISystemSettingsService systemSettingsService, IStorageGuardService storageGuardService, IOperationsMetricsRepository operationsMetricsRepository) { _liveRoomRepository = liveRoomRepository; _recordSessionRepository = recordSessionRepository; _recordTaskRepository = recordTaskRepository; _recordResultRepository = recordResultRepository; _systemLogRepository = systemLogRepository; _systemSettingsService = systemSettingsService; _storageGuardService = storageGuardService; _operationsMetricsRepository = operationsMetricsRepository; } public async Task GetDashboardAsync(CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var beijingNow = ChinaTime.ToBeijingTime(now); var todayBeijingDate = DateOnly.FromDateTime(beijingNow.DateTime); // Bound "today in Beijing" but normalize to UTC — Npgsql only accepts offset 0 // when writing to a `timestamp with time zone` column. var todayUtcStart = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MinValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime)).ToUniversalTime(); var todayUtcEnd = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MaxValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime)).ToUniversalTime(); var recentErrorSince = now.AddHours(-24); var currentErrorSince = now.AddMinutes(-30); // Run queries sequentially — DbContext is not thread-safe var activeRecordingCount = await _recordSessionRepository.CountByStatusAsync(RecordSessionStatus.Running, cancellationToken); var liveRoomCount = await _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Live, cancellationToken); var offlineRoomCount = await _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Offline, cancellationToken); var totalRoomCount = await _liveRoomRepository.CountAsync(cancellationToken); var activeSessionCount = await _recordSessionRepository.CountActiveAsync(cancellationToken); var recentErrorCount = await _systemLogRepository.CountRecentErrorsAsync(recentErrorSince, cancellationToken); var currentErrorCount = await _systemLogRepository.CountRecentErrorsAsync(currentErrorSince, cancellationToken); var todayRecordingSeconds = await _recordTaskRepository.SumDurationSecondsAsync(todayUtcStart, todayUtcEnd, cancellationToken); var (todayTotalBytes, todayTotalDanmaku) = await _recordResultRepository.GetTodayAggregateAsync(todayUtcStart, todayUtcEnd, cancellationToken); var recentSessions = await _recordSessionRepository.ListRecentAsync(5, cancellationToken); var todaySessions = await _recordSessionRepository.ListInDateRangeAsync(todayUtcStart, todayUtcEnd, cancellationToken); var settings = await _systemSettingsService.GetAsync(cancellationToken); var storageCheck = _storageGuardService.CheckCanStartOrResume(settings); var pendingTranscodeCount = await _recordTaskRepository.CountByStatusAsync(RecordTaskStatus.Processing, cancellationToken); var pendingUploadCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken); var queuedDataBytes = await _recordResultRepository.SumPendingUploadBytesAsync(cancellationToken); var operationsMetrics = await _operationsMetricsRepository.GetAsync(cancellationToken); return new DashboardDto { ActiveRecordingCount = activeRecordingCount, LiveRoomCount = liveRoomCount, OfflineRoomCount = offlineRoomCount, TotalRoomCount = totalRoomCount, TodayRecordingSeconds = todayRecordingSeconds, TodayDataBytes = todayTotalBytes, TodayDanmakuCount = todayTotalDanmaku, ActiveSessionCount = activeSessionCount, RecentErrorCount = recentErrorCount, CurrentErrorCount = currentErrorCount, StorageStatus = new StorageStatusDto { IsEnabled = storageCheck.IsEnabled, IsAvailable = storageCheck.IsAvailable, HasEnoughSpace = storageCheck.HasEnoughSpace, Message = storageCheck.Message ?? string.Empty, CheckedPath = storageCheck.CheckedPath, TotalBytes = storageCheck.TotalBytes, UsedBytes = storageCheck.UsedBytes, AvailableBytes = storageCheck.AvailableBytes, RequiredBytes = storageCheck.RequiredBytes, Tier = storageCheck.Tier.ToString(), UsagePercent = storageCheck.UsagePercent, FreePercent = storageCheck.FreePercent, GreenThresholdPercent = storageCheck.GreenThresholdPercent, RedThresholdPercent = storageCheck.RedThresholdPercent }, PendingTranscodeCount = pendingTranscodeCount, PendingUploadCount = pendingUploadCount, QueuedDataBytes = queuedDataBytes, OldestTranscodeUpdatedAt = operationsMetrics.OldestTranscodeUpdatedAt, OldestUploadProgressAt = operationsMetrics.OldestUploadProgressAt, StalledUploadCount = operationsMetrics.StalledUploadCount, UploadCleanupFailureCount = operationsMetrics.CleanupFailureCount, RecentSessions = recentSessions .Select(MapRecentSession) .ToList(), TopRooms = ComputeTopRooms(todaySessions) }; } private static RecentSessionItemDto MapRecentSession(Domain.Entities.RecordSession session) { var duration = session.RecordTasks .Where(item => item.DurationSeconds.HasValue) .Sum(item => item.DurationSeconds ?? 0); return new RecentSessionItemDto { Id = session.Id, LiveRoomId = session.LiveRoomId, LiveRoomTitle = session.LiveRoom?.Title ?? session.LiveRoom?.Alias ?? session.LiveRoom?.AnchorName ?? "-", PlatformName = session.LiveRoom?.Platform.ToString() ?? "-", SegmentCount = session.SegmentCount, Status = (int)session.Status, StartedAt = session.StartedAt ?? session.CreatedAt, DurationSeconds = duration > 0 ? duration : null }; } private static IReadOnlyList ComputeTopRooms(IReadOnlyCollection sessions) { return sessions .GroupBy(item => item.LiveRoomId) .Select(group => { var first = group.First(); var totalDuration = group .SelectMany(item => item.RecordTasks) .Sum(item => item.DurationSeconds ?? 0); return new TopRoomItemDto { LiveRoomId = group.Key, Title = first.LiveRoom?.Title ?? first.LiveRoom?.Alias ?? first.LiveRoom?.AnchorName, AnchorName = first.LiveRoom?.AnchorName, PlatformName = first.LiveRoom?.Platform.ToString() ?? "-", RoomId = first.LiveRoom?.RoomId ?? "-", SessionCount = group.Count(), TotalDurationSeconds = totalDuration }; }) .OrderByDescending(item => item.TotalDurationSeconds) .Take(5) .ToList(); } }