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
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
@@ -56,6 +57,70 @@ public sealed partial class FfmpegService
{
_ = ValidateRuntimeSourceFailureAsync(runtime, line);
}
TryUpdateBandwidthFromProgressLine(runtime, line);
// Flush bandwidth sample periodically
_ = runtime.FlushBandwidthSampleIfNeededAsync(WriteBandwidthSampleAsync, CancellationToken.None);
}
private async Task WriteBandwidthSampleAsync(
Guid liveRoomId,
Guid recordSessionId,
Guid recordTaskId,
string detail,
CancellationToken cancellationToken)
{
try
{
using var scope = _serviceScopeFactory.CreateScope();
var systemLogService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await systemLogService.WriteAsync(
SystemLogLevel.Info,
"Bandwidth",
"bandwidth_sample",
detail,
liveRoomId: liveRoomId,
recordSessionId: recordSessionId,
recordTaskId: recordTaskId,
cancellationToken: cancellationToken);
}
catch
{
// Silently ignore bandwidth logging failures
}
}
private void TryUpdateBandwidthFromProgressLine(SessionProcessRuntime runtime, string line)
{
if (line.StartsWith("total_size=", StringComparison.Ordinal))
{
if (long.TryParse(line.AsSpan("total_size=".Length), out var totalSize))
{
runtime.UpdateBandwidthTotalSize(totalSize);
}
}
else if (line.StartsWith("bitrate=", StringComparison.Ordinal))
{
// bitrate format: "1234.5kbits/s"
var bitrateStr = line.AsSpan("bitrate=".Length).Trim();
if (bitrateStr.EndsWith("kbits/s", StringComparison.OrdinalIgnoreCase))
{
bitrateStr = bitrateStr.Slice(0, bitrateStr.Length - "kbits/s".Length).Trim();
}
if (double.TryParse(bitrateStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var bitrate))
{
runtime.UpdateBandwidthBitrate(bitrate);
}
}
else if (line.StartsWith("speed=", StringComparison.Ordinal))
{
var speedStr = line.AsSpan("speed=".Length).TrimEnd('x').Trim();
if (double.TryParse(speedStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var speed))
{
runtime.UpdateBandwidthSpeed(speed);
}
}
}
private static bool TryClassifyPersistedFfmpegLine(string line, bool isError, out SystemLogLevel level)
@@ -1839,6 +1904,54 @@ public sealed partial class FfmpegService
public ILiveDanmakuConnection? DanmakuConnection { get; set; }
public Task? DanmakuPumpTask { get; set; }
private List<string> CurrentRecorderSegmentPaths { get; } = [];
// Bandwidth tracking fields
private long _lastBandwidthTotalSize;
private double? _lastBandwidthBitrate;
private double _lastBandwidthSpeed;
private DateTimeOffset _lastBandwidthFlushAt = DateTimeOffset.MinValue;
private static readonly TimeSpan BandwidthFlushInterval = TimeSpan.FromSeconds(30);
public void UpdateBandwidthTotalSize(long totalSize)
{
_lastBandwidthTotalSize = Math.Max(0, totalSize);
}
public void UpdateBandwidthBitrate(double bitrateKbps)
{
_lastBandwidthBitrate = Math.Max(0, bitrateKbps);
}
public void UpdateBandwidthSpeed(double speed)
{
_lastBandwidthSpeed = speed;
}
public async Task FlushBandwidthSampleIfNeededAsync(
Func<Guid, Guid, Guid, string, System.Threading.CancellationToken, Task> writeLogAsync,
System.Threading.CancellationToken cancellationToken)
{
var nowUtc = DateTimeOffset.UtcNow;
if (nowUtc - _lastBandwidthFlushAt < BandwidthFlushInterval)
{
return;
}
_lastBandwidthFlushAt = nowUtc;
if (_lastBandwidthTotalSize <= 0 && !_lastBandwidthBitrate.HasValue)
{
return;
}
var detail = $$"""{"bytesDownloaded":{{_lastBandwidthTotalSize}},"bitrateKbps":{{(_lastBandwidthBitrate?.ToString("F1", CultureInfo.InvariantCulture) ?? "null")}},"speed":{{_lastBandwidthSpeed.ToString("F2", CultureInfo.InvariantCulture)}}}""";
await writeLogAsync(
LiveRoomId,
RecordSessionId,
CurrentTaskId,
detail,
cancellationToken);
}
private object RuntimeSourceFailureSync { get; } = new();
private object RecentOutputSync { get; } = new();
private Queue<string> RecentOutputLines { get; } = new();