feat: add dashboard, file preview metadata, and bandwidth statistics

Dashboard:
- Add DashboardDto, DashboardService with SQL-level aggregate queries
- Add GET /api/dashboard endpoint with real-time system status
- Add repository aggregate methods (CountByAvailability, SumDuration, etc.)
- Add DashboardView.vue as new landing page with KPI cards, storage status, recent sessions, top rooms
- Update router to make dashboard the new / route, add nav item in sidebar

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

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

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
This commit is contained in:
2026-06-04 00:30:02 +08:00
co-authored by Claude Opus 4.8 noreply@anthropic.com
parent a5c2cc3202
commit cbee29bef9
22 changed files with 1392 additions and 27 deletions
@@ -0,0 +1,135 @@
using System.Text.Json;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class BandwidthStatisticsService
{
private const string BandwidthCategory = "Bandwidth";
private const string SampleMessage = "bandwidth_sample";
private readonly ISystemLogRepository _systemLogRepository;
public BandwidthStatisticsService(ISystemLogRepository systemLogRepository)
{
_systemLogRepository = systemLogRepository;
}
public async Task<BandwidthTimelineDto?> GetSessionTimelineAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
{
// Query all bandwidth sample log entries for this session
// Since there's no dedicated method, we use ListAsync with category filter
var allLogs = await _systemLogRepository.ListAllAsync(cancellationToken);
var bandwidthLogs = allLogs
.Where(item => item.Category == BandwidthCategory
&& item.Message == SampleMessage
&& item.RecordSessionId == recordSessionId)
.OrderBy(item => item.CreatedAt)
.ToList();
if (bandwidthLogs.Count == 0)
{
return null;
}
var points = new List<BandwidthPointDto>(bandwidthLogs.Count);
foreach (var entry in bandwidthLogs)
{
if (string.IsNullOrWhiteSpace(entry.Detail))
{
continue;
}
try
{
using var doc = JsonDocument.Parse(entry.Detail);
long bytesDownloaded = 0;
double? bitrateKbps = null;
if (doc.RootElement.TryGetProperty("bytesDownloaded", out var bd) && bd.TryGetInt64(out var bv))
bytesDownloaded = bv;
if (doc.RootElement.TryGetProperty("bitrateKbps", out var br) && br.TryGetDouble(out var bkv))
bitrateKbps = bkv;
points.Add(new BandwidthPointDto
{
Timestamp = entry.CreatedAt,
BytesDownloaded = bytesDownloaded,
BitrateKbps = bitrateKbps
});
}
catch
{
// Skip malformed entries
}
}
return new BandwidthTimelineDto
{
RecordSessionId = recordSessionId,
Points = points
};
}
public async Task<BandwidthSummaryDto?> GetDailySummaryAsync(DateOnly date, int utcOffsetMinutes, CancellationToken cancellationToken = default)
{
var windowStartUtc = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.FromMinutes(utcOffsetMinutes));
var windowEndUtc = new DateTimeOffset(date.ToDateTime(TimeOnly.MaxValue), TimeSpan.FromMinutes(utcOffsetMinutes));
var allLogs = await _systemLogRepository.ListAllAsync(cancellationToken);
var bandwidthLogs = allLogs
.Where(item => item.Category == BandwidthCategory
&& item.Message == SampleMessage
&& item.CreatedAt >= windowStartUtc
&& item.CreatedAt <= windowEndUtc)
.OrderBy(item => item.CreatedAt)
.ToList();
if (bandwidthLogs.Count == 0)
{
return null;
}
var bitrates = new List<double>();
long maxBytes = 0;
long finalBytes = 0;
foreach (var entry in bandwidthLogs)
{
if (string.IsNullOrWhiteSpace(entry.Detail))
continue;
try
{
using var doc = JsonDocument.Parse(entry.Detail);
if (doc.RootElement.TryGetProperty("bytesDownloaded", out var bd) && bd.TryGetInt64(out var bv))
{
if (bv > maxBytes) maxBytes = bv;
finalBytes = bv;
}
if (doc.RootElement.TryGetProperty("bitrateKbps", out var br) && br.TryGetDouble(out var bkv))
{
bitrates.Add(bkv);
}
}
catch
{
// Skip
}
}
var avgBitrate = bitrates.Count > 0 ? bitrates.Average() : 0;
var peakBitrate = bitrates.Count > 0 ? bitrates.Max() : 0;
return new BandwidthSummaryDto
{
TotalTrafficMB = finalBytes / (1024.0 * 1024.0),
AverageBitrateKbps = avgBitrate,
PeakBitrateKbps = peakBitrate
};
}
}
@@ -0,0 +1,147 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Models.Reports;
using LiveRecorder.Application.Common;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class DashboardService
{
private readonly ILiveRoomRepository _liveRoomRepository;
private readonly IRecordSessionRepository _recordSessionRepository;
private readonly IRecordTaskRepository _recordTaskRepository;
private readonly IRecordResultRepository _recordResultRepository;
private readonly ISystemLogRepository _systemLogRepository;
private readonly ISystemSettingsService _systemSettingsService;
private readonly IStorageGuardService _storageGuardService;
public DashboardService(
ILiveRoomRepository liveRoomRepository,
IRecordSessionRepository recordSessionRepository,
IRecordTaskRepository recordTaskRepository,
IRecordResultRepository recordResultRepository,
ISystemLogRepository systemLogRepository,
ISystemSettingsService systemSettingsService,
IStorageGuardService storageGuardService)
{
_liveRoomRepository = liveRoomRepository;
_recordSessionRepository = recordSessionRepository;
_recordTaskRepository = recordTaskRepository;
_recordResultRepository = recordResultRepository;
_systemLogRepository = systemLogRepository;
_systemSettingsService = systemSettingsService;
_storageGuardService = storageGuardService;
}
public async Task<DashboardDto> GetDashboardAsync(CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var beijingNow = ChinaTime.ToBeijingTime(now);
var todayBeijingDate = DateOnly.FromDateTime(beijingNow.DateTime);
var todayUtcStart = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MinValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime));
var todayUtcEnd = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MaxValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime));
var recentErrorSince = now.AddHours(-24);
// Run independent queries in parallel for efficiency
var activeRecordingTask = _recordSessionRepository.CountByStatusAsync(RecordSessionStatus.Running, cancellationToken);
var liveRoomCountTask = _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Live, cancellationToken);
var offlineRoomCountTask = _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Offline, cancellationToken);
var totalRoomCountTask = _liveRoomRepository.CountAsync(cancellationToken);
var activeSessionCountTask = _recordSessionRepository.CountActiveAsync(cancellationToken);
var recentErrorCountTask = _systemLogRepository.CountRecentErrorsAsync(recentErrorSince, cancellationToken);
var todayDurationTask = _recordTaskRepository.SumDurationSecondsAsync(todayUtcStart, todayUtcEnd, cancellationToken);
var todayAggregateTask = _recordResultRepository.GetTodayAggregateAsync(todayUtcStart, todayUtcEnd, cancellationToken);
var recentSessionsTask = _recordSessionRepository.ListRecentAsync(5, cancellationToken);
var todaySessionsTask = _recordSessionRepository.ListInDateRangeAsync(todayUtcStart, todayUtcEnd, cancellationToken);
var settingsTask = _systemSettingsService.GetAsync(cancellationToken);
await Task.WhenAll(
activeRecordingTask,
liveRoomCountTask,
offlineRoomCountTask,
totalRoomCountTask,
activeSessionCountTask,
recentErrorCountTask,
todayDurationTask,
todayAggregateTask,
recentSessionsTask,
todaySessionsTask,
(Task)settingsTask
);
var (todayTotalBytes, todayTotalDanmaku) = todayAggregateTask.Result;
var settings = settingsTask.Result;
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
return new DashboardDto
{
ActiveRecordingCount = activeRecordingTask.Result,
LiveRoomCount = liveRoomCountTask.Result,
OfflineRoomCount = offlineRoomCountTask.Result,
TotalRoomCount = totalRoomCountTask.Result,
TodayRecordingSeconds = todayDurationTask.Result,
TodayDataBytes = todayTotalBytes,
TodayDanmakuCount = todayTotalDanmaku,
ActiveSessionCount = activeSessionCountTask.Result,
RecentErrorCount = recentErrorCountTask.Result,
StorageStatus = new StorageStatusDto
{
HasEnoughSpace = storageCheck.HasEnoughSpace,
Message = storageCheck.Message ?? string.Empty,
AvailableBytes = storageCheck.AvailableBytes
},
RecentSessions = recentSessionsTask.Result
.Select(MapRecentSession)
.ToList(),
TopRooms = ComputeTopRooms(todaySessionsTask.Result)
};
}
private static RecentSessionItemDto MapRecentSession(Domain.Entities.RecordSession session)
{
var duration = session.RecordTasks
.Where(item => item.DurationSeconds.HasValue)
.Sum(item => item.DurationSeconds ?? 0);
return new RecentSessionItemDto
{
Id = session.Id,
LiveRoomId = session.LiveRoomId,
LiveRoomTitle = session.LiveRoom?.Title ?? session.LiveRoom?.Alias ?? session.LiveRoom?.AnchorName ?? "-",
PlatformName = session.LiveRoom?.Platform.ToString() ?? "-",
SegmentCount = session.SegmentCount,
Status = (int)session.Status,
StartedAt = session.StartedAt ?? session.CreatedAt,
DurationSeconds = duration > 0 ? duration : null
};
}
private static IReadOnlyList<TopRoomItemDto> ComputeTopRooms(IReadOnlyCollection<Domain.Entities.RecordSession> sessions)
{
return sessions
.GroupBy(item => item.LiveRoomId)
.Select(group =>
{
var first = group.First();
var totalDuration = group
.SelectMany(item => item.RecordTasks)
.Sum(item => item.DurationSeconds ?? 0);
return new TopRoomItemDto
{
LiveRoomId = group.Key,
Title = first.LiveRoom?.Title ?? first.LiveRoom?.Alias ?? first.LiveRoom?.AnchorName,
AnchorName = first.LiveRoom?.AnchorName,
PlatformName = first.LiveRoom?.Platform.ToString() ?? "-",
RoomId = first.LiveRoom?.RoomId ?? "-",
SessionCount = group.Count(),
TotalDurationSeconds = totalDuration
};
})
.OrderByDescending(item => item.TotalDurationSeconds)
.Take(5)
.ToList();
}
}
@@ -11,16 +11,19 @@ public sealed class MediaBrowserService
private readonly ISystemSettingsService _systemSettingsService;
private readonly IFfmpegService _ffmpegService;
private readonly IVideoMetadataService _videoMetadataService;
public MediaBrowserService(
ISystemSettingsService systemSettingsService,
IFfmpegService ffmpegService)
IFfmpegService ffmpegService,
IVideoMetadataService videoMetadataService)
{
_systemSettingsService = systemSettingsService;
_ffmpegService = ffmpegService;
_videoMetadataService = videoMetadataService;
}
public async Task<MediaBrowserResponseDto> BrowseAsync(string? relativePath, CancellationToken cancellationToken = default)
public async Task<MediaBrowserResponseDto> BrowseAsync(string? relativePath, bool includeMetadata = false, CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var rootPath = ResolveOutputRoot(settings.OutputRoot);
@@ -50,28 +53,56 @@ public sealed class MediaBrowserService
};
});
var files = Directory
.EnumerateFiles(targetPath)
.Select(filePath =>
{
var info = new FileInfo(filePath);
var extension = info.Extension.ToLowerInvariant();
return new MediaBrowserItemDto
{
Name = info.Name,
RelativePath = Path.GetRelativePath(rootPath, info.FullName).Replace('\\', '/'),
Type = ResolveItemType(extension),
SizeBytes = info.Length,
ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue
? null
: new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero),
CanPreview = PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
CanTranscode = TranscodableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)
};
});
var items = new List<MediaBrowserItemDto>();
items.AddRange(directories);
var items = directories
.Concat(files)
foreach (var filePath in Directory.EnumerateFiles(targetPath))
{
var info = new FileInfo(filePath);
var extension = info.Extension.ToLowerInvariant();
var absolutePath = info.FullName;
VideoMetadataDto? metadata = null;
string? thumbnailUrl = null;
if (includeMetadata && PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
var extracted = await _videoMetadataService.ExtractMetadataAsync(absolutePath, cancellationToken);
if (extracted is not null)
{
metadata = new VideoMetadataDto(
extracted.DurationSeconds,
extracted.Width,
extracted.Height,
extracted.VideoCodec,
extracted.AudioCodec,
extracted.FrameRate,
extracted.BitRate);
}
var thumb = await _videoMetadataService.GenerateThumbnailAsync(absolutePath, rootPath, cancellationToken);
if (thumb is not null)
{
thumbnailUrl = Path.GetRelativePath(rootPath, thumb).Replace('\\', '/');
}
}
items.Add(new MediaBrowserItemDto
{
Name = info.Name,
RelativePath = Path.GetRelativePath(rootPath, absolutePath).Replace('\\', '/'),
Type = ResolveItemType(extension),
SizeBytes = info.Length,
ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue
? null
: new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero),
CanPreview = PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
CanTranscode = TranscodableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
Metadata = metadata,
ThumbnailUrl = thumbnailUrl
});
}
var sortedItems = items
.OrderBy(static item => item.Type != "directory")
.ThenBy(static item => item.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();