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:
co-authored by
Claude Opus 4.8 noreply@anthropic.com
parent
a5c2cc3202
commit
cbee29bef9
@@ -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();
|
||||
|
||||
@@ -656,7 +656,7 @@ public sealed partial class FfmpegService
|
||||
string? selectedVideoCodec,
|
||||
FfmpegInputOptionProfile inputOptionProfile)
|
||||
{
|
||||
var arguments = new List<string> { "-hide_banner", "-y" };
|
||||
var arguments = new List<string> { "-hide_banner", "-y", "-progress", "pipe:1" };
|
||||
var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode);
|
||||
|
||||
if (IsHttpInput(streamUrl))
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class FfmpegVideoMetadataService : IVideoMetadataService
|
||||
{
|
||||
private const string ThumbnailsSubDir = ".thumbnails";
|
||||
|
||||
public async Task<VideoMetadata?> ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetFfprobePath(),
|
||||
Arguments = $"-v quiet -print_format json -show_format -show_streams \"{filePath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseFfprobeOutput(output);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GenerateThumbnailAsync(string filePath, string outputDir, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relativePath = Path.GetRelativePath(Path.GetFullPath(outputDir, AppContext.BaseDirectory), filePath);
|
||||
// Sanitize: replace directory separators with safe characters
|
||||
var safeRelativePath = relativePath
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('/')
|
||||
.Replace('/', '_');
|
||||
var thumbDir = Path.Combine(outputDir, ThumbnailsSubDir);
|
||||
var thumbPath = Path.Combine(thumbDir, $"{safeRelativePath}.jpg");
|
||||
|
||||
// Return cached thumbnail if it exists
|
||||
if (File.Exists(thumbPath) && new FileInfo(thumbPath).Length > 0)
|
||||
{
|
||||
return thumbPath;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Calculate snapshot time: 10% of duration or 30 seconds default
|
||||
var metadata = await ExtractMetadataAsync(filePath, cancellationToken);
|
||||
var seekSeconds = metadata?.DurationSeconds.HasValue == true && metadata.DurationSeconds.Value > 60
|
||||
? (int)(metadata.DurationSeconds.Value * 0.1)
|
||||
: 30;
|
||||
|
||||
Directory.CreateDirectory(thumbDir);
|
||||
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetFfmpegPath(),
|
||||
Arguments = $"-ss {seekSeconds} -i \"{filePath}\" -vframes 1 -q:v 2 -y \"{thumbPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode == 0 && File.Exists(thumbPath) && new FileInfo(thumbPath).Length > 0)
|
||||
{
|
||||
return thumbPath;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Thumbnail generation failed silently
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VideoMetadata? ParseFfprobeOutput(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var format = doc.RootElement.TryGetProperty("format", out var fmt) ? fmt : (JsonElement?)null;
|
||||
var streams = doc.RootElement.TryGetProperty("streams", out var str) ? str : (JsonElement?)null;
|
||||
|
||||
double? duration = null;
|
||||
long? bitRate = null;
|
||||
if (format.HasValue)
|
||||
{
|
||||
if (format.Value.TryGetProperty("duration", out var dur) && dur.TryGetDouble(out var d))
|
||||
duration = d;
|
||||
if (format.Value.TryGetProperty("bit_rate", out var br) && br.TryGetInt64(out var b))
|
||||
bitRate = b;
|
||||
}
|
||||
|
||||
int? width = null;
|
||||
int? height = null;
|
||||
string? videoCodec = null;
|
||||
string? audioCodec = null;
|
||||
double? frameRate = null;
|
||||
|
||||
if (streams.HasValue && streams.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var stream in streams.Value.EnumerateArray())
|
||||
{
|
||||
var codecType = stream.TryGetProperty("codec_type", out var ct) ? ct.GetString() : null;
|
||||
var codecName = stream.TryGetProperty("codec_name", out var cn) ? cn.GetString() : null;
|
||||
|
||||
if (codecType == "video")
|
||||
{
|
||||
if (stream.TryGetProperty("width", out var w) && w.TryGetInt32(out var wv))
|
||||
width = wv;
|
||||
if (stream.TryGetProperty("height", out var h) && h.TryGetInt32(out var hv))
|
||||
height = hv;
|
||||
videoCodec = codecName;
|
||||
if (stream.TryGetProperty("r_frame_rate", out var fr) && fr.GetString() is { } frStr)
|
||||
frameRate = ParseFrameRate(frStr);
|
||||
}
|
||||
else if (codecType == "audio")
|
||||
{
|
||||
audioCodec = codecName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new VideoMetadata(duration, width, height, videoCodec, audioCodec, frameRate, bitRate);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static double? ParseFrameRate(string fraction)
|
||||
{
|
||||
var parts = fraction.Split('/');
|
||||
if (parts.Length == 2 &&
|
||||
double.TryParse(parts[0], out var num) &&
|
||||
double.TryParse(parts[1], out var den) &&
|
||||
den > 0)
|
||||
{
|
||||
return num / den;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetFfmpegPath() => "ffmpeg";
|
||||
private static string GetFfprobePath() => "ffprobe";
|
||||
}
|
||||
Reference in New Issue
Block a user