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
@@ -25,6 +25,10 @@ public interface ILiveRoomRepository
|
||||
|
||||
Task<IReadOnlyList<LiveRoom>> ListAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByAvailabilityAsync(LiveRoomAvailabilityStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(LiveRoom liveRoom);
|
||||
@@ -42,6 +46,8 @@ public interface IRecordTaskRepository
|
||||
|
||||
Task<RecordTask?> GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<double> SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(RecordTask recordTask);
|
||||
@@ -59,6 +65,14 @@ public interface IRecordSessionRepository
|
||||
|
||||
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByStatusAsync(RecordSessionStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountActiveAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListRecentAsync(int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListInDateRangeAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(RecordSession recordSession);
|
||||
@@ -68,6 +82,8 @@ public interface IRecordResultRepository
|
||||
{
|
||||
Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(long TotalBytes, int TotalDanmaku)> GetTodayAggregateAsync(DateTimeOffset createdFrom, DateTimeOffset createdTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
|
||||
|
||||
void Update(RecordResult recordResult);
|
||||
@@ -100,6 +116,8 @@ public interface ISystemLogRepository
|
||||
int take = 200,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountRecentErrorsAsync(DateTimeOffset since, CancellationToken cancellationToken = default);
|
||||
|
||||
void RemoveRange(IEnumerable<SystemLogEntry> entries);
|
||||
|
||||
Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Service for extracting video metadata and generating thumbnails using ffmpeg/ffprobe.
|
||||
/// </summary>
|
||||
public interface IVideoMetadataService
|
||||
{
|
||||
Task<VideoMetadata?> ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<string?> GenerateThumbnailAsync(string filePath, string outputDir, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record VideoMetadata(
|
||||
double? DurationSeconds,
|
||||
int? Width,
|
||||
int? Height,
|
||||
string? VideoCodec,
|
||||
string? AudioCodec,
|
||||
double? FrameRate,
|
||||
long? BitRate);
|
||||
|
||||
public sealed record VideoMetadataDto(
|
||||
double? DurationSeconds,
|
||||
int? Width,
|
||||
int? Height,
|
||||
string? VideoCodec,
|
||||
string? AudioCodec,
|
||||
double? FrameRate,
|
||||
long? BitRate);
|
||||
@@ -22,6 +22,16 @@ public sealed class MediaBrowserItemDto
|
||||
public bool CanTranscode { get; init; }
|
||||
|
||||
public bool CanPreview { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Video metadata (only populated when includeMetadata is requested and item is a video file).
|
||||
/// </summary>
|
||||
public Abstractions.Recording.VideoMetadataDto? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Thumbnail URL relative path (only populated when includeMetadata is requested and item is a video file).
|
||||
/// </summary>
|
||||
public string? ThumbnailUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MediaBrowserResponseDto
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Bandwidth summary statistics.
|
||||
/// </summary>
|
||||
public sealed class BandwidthSummaryDto
|
||||
{
|
||||
public double TotalTrafficMB { get; init; }
|
||||
public double AverageBitrateKbps { get; init; }
|
||||
public double PeakBitrateKbps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bandwidth timeline for a recording session.
|
||||
/// </summary>
|
||||
public sealed class BandwidthTimelineDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
public required IReadOnlyList<BandwidthPointDto> Points { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single bandwidth sample point in time.
|
||||
/// </summary>
|
||||
public sealed class BandwidthPointDto
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public long BytesDownloaded { get; init; }
|
||||
public double? BitrateKbps { get; init; }
|
||||
}
|
||||
@@ -166,3 +166,23 @@ public sealed class RecordSessionDeletionBatchResult
|
||||
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionPlaylistDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
public string LiveRoomTitle { get; init; } = string.Empty;
|
||||
|
||||
public required IReadOnlyList<SessionPlaylistSegmentDto> Segments { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionPlaylistSegmentDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
|
||||
public int SegmentIndex { get; init; }
|
||||
|
||||
public string PreviewTicketUrl { get; init; } = string.Empty;
|
||||
|
||||
public double? DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace LiveRecorder.Application.Models.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time system dashboard overview DTO.
|
||||
/// </summary>
|
||||
public sealed class DashboardDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of sessions currently recording (Running status).
|
||||
/// </summary>
|
||||
public int ActiveRecordingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of live rooms currently live.
|
||||
/// </summary>
|
||||
public int LiveRoomCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of live rooms currently offline.
|
||||
/// </summary>
|
||||
public int OfflineRoomCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of live rooms in the system.
|
||||
/// </summary>
|
||||
public int TotalRoomCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total recording duration in seconds for sessions started today (Beijing time).
|
||||
/// </summary>
|
||||
public double TodayRecordingSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total data recorded today in bytes (sum of FileSizeBytes).
|
||||
/// </summary>
|
||||
public long TodayDataBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total danmaku events recorded today.
|
||||
/// </summary>
|
||||
public int TodayDanmakuCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of sessions with Starting or Running status.
|
||||
/// </summary>
|
||||
public int ActiveSessionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of Error-level system logs in the last 24 hours.
|
||||
/// </summary>
|
||||
public int RecentErrorCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current storage guard status.
|
||||
/// </summary>
|
||||
public StorageStatusDto StorageStatus { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Most recent active/completed sessions (up to 5).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RecentSessionItemDto> RecentSessions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top live rooms by recording duration today (up to 5).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<TopRoomItemDto> TopRooms { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StorageStatusDto
|
||||
{
|
||||
public bool HasEnoughSpace { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public long AvailableBytes { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecentSessionItemDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid LiveRoomId { get; init; }
|
||||
public string LiveRoomTitle { get; init; } = string.Empty;
|
||||
public string PlatformName { get; init; } = string.Empty;
|
||||
public int SegmentCount { get; init; }
|
||||
public int Status { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public double? DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TopRoomItemDto
|
||||
{
|
||||
public Guid LiveRoomId { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? AnchorName { get; init; }
|
||||
public string PlatformName { get; init; } = string.Empty;
|
||||
public string RoomId { get; init; } = string.Empty;
|
||||
public int SessionCount { get; init; }
|
||||
public double TotalDurationSeconds { get; init; }
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user