diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index 540a454..c0b040c 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -8,6 +8,7 @@ import { useViewport } from "@/composables/useViewport"; import { ArrowDown, Bell, + DataAnalysis, Document, Fold, House, @@ -36,6 +37,7 @@ const navigationGroups = [ key: "monitor", title: "直播监控", items: [ + { index: "/", label: "仪表盘", icon: DataAnalysis }, { index: "/live-rooms", label: "直播间", icon: House }, { index: "/record-tasks", label: "录制任务", icon: VideoCamera }, { index: "/recovery", label: "恢复中心", icon: RefreshRight } @@ -72,6 +74,8 @@ const backendStatusDescription = computed(() => const backendStatusTone = computed(() => (backendUnavailable.value ? "is-danger" : "is-healthy")); const pageEyebrow = computed(() => { switch (route.name) { + case "dashboard": + return "系统概览"; case "live-rooms": return "直播间监控"; case "record-tasks": diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 25a833e..e8cba85 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -3,6 +3,7 @@ import { useAuthStore } from "@/stores/auth"; const LoginView = () => import("@/views/LoginView.vue"); const MainLayout = () => import("@/components/layout/MainLayout.vue"); +const DashboardView = () => import("@/views/DashboardView.vue"); const LiveRoomsView = () => import("@/views/LiveRoomsView.vue"); const RecordTasksView = () => import("@/views/RecordTasksView.vue"); const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue"); @@ -29,7 +30,8 @@ const router = createRouter({ children: [ { path: "", - redirect: "/live-rooms" + name: "dashboard", + component: DashboardView }, { path: "live-rooms", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 893d714..a7f2f58 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -808,3 +808,88 @@ export interface SessionDanmakuResponse { recordSessionId: string; tasks: DanmakuResponse[]; } + +// Dashboard types +export interface DashboardData { + activeRecordingCount: number; + liveRoomCount: number; + offlineRoomCount: number; + totalRoomCount: number; + todayRecordingSeconds: number; + todayDataBytes: number; + todayDanmakuCount: number; + activeSessionCount: number; + recentErrorCount: number; + storageStatus: DashboardStorageStatus; + recentSessions: DashboardRecentSession[]; + topRooms: DashboardTopRoom[]; +} + +export interface DashboardStorageStatus { + hasEnoughSpace: boolean; + message: string; + availableBytes: number; +} + +export interface DashboardRecentSession { + id: string; + liveRoomId: string; + liveRoomTitle: string; + platformName: string; + segmentCount: number; + status: number; + startedAt?: string; + durationSeconds?: number; +} + +export interface DashboardTopRoom { + liveRoomId: string; + title?: string; + anchorName?: string; + platformName: string; + roomId: string; + sessionCount: number; + totalDurationSeconds: number; +} + +// Video metadata types +export interface VideoMetadata { + durationSeconds?: number; + width?: number; + height?: number; + videoCodec?: string; + audioCodec?: string; + frameRate?: number; + bitRate?: number; +} + +// Session playlist types +export interface SessionPlaylistSegment { + recordTaskId: string; + segmentIndex: number; + previewTicketUrl: string; + durationSeconds?: number; +} + +export interface SessionPlaylist { + recordSessionId: string; + liveRoomTitle: string; + segments: SessionPlaylistSegment[]; +} + +// Bandwidth types +export interface BandwidthSummary { + totalTrafficMB: number; + averageBitrateKbps: number; + peakBitrateKbps: number; +} + +export interface BandwidthTimeline { + points: BandwidthPoint[]; +} + +export interface BandwidthPoint { + timestamp: string; + bytesDownloaded: number; + bitrateKbps?: number; +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..d6c79d9 --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs index fa12730..7415132 100644 --- a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs +++ b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs @@ -25,6 +25,10 @@ public interface ILiveRoomRepository Task> ListAsync(CancellationToken cancellationToken = default); + Task CountAsync(CancellationToken cancellationToken = default); + + Task 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 GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default); + Task 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 GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default); + Task CountByStatusAsync(RecordSessionStatus status, CancellationToken cancellationToken = default); + + Task CountActiveAsync(CancellationToken cancellationToken = default); + + Task> ListRecentAsync(int take, CancellationToken cancellationToken = default); + + Task> 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 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 CountRecentErrorsAsync(DateTimeOffset since, CancellationToken cancellationToken = default); + void RemoveRange(IEnumerable entries); Task> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default); diff --git a/src/LiveRecorder.Application/Abstractions/Recording/IVideoMetadataService.cs b/src/LiveRecorder.Application/Abstractions/Recording/IVideoMetadataService.cs new file mode 100644 index 0000000..588fe75 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Recording/IVideoMetadataService.cs @@ -0,0 +1,29 @@ +namespace LiveRecorder.Application.Abstractions.Recording; + +/// +/// Service for extracting video metadata and generating thumbnails using ffmpeg/ffprobe. +/// +public interface IVideoMetadataService +{ + Task ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default); + + Task 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); diff --git a/src/LiveRecorder.Application/Models/Media/MediaBrowserModels.cs b/src/LiveRecorder.Application/Models/Media/MediaBrowserModels.cs index 82d9a82..0d9bcaf 100644 --- a/src/LiveRecorder.Application/Models/Media/MediaBrowserModels.cs +++ b/src/LiveRecorder.Application/Models/Media/MediaBrowserModels.cs @@ -22,6 +22,16 @@ public sealed class MediaBrowserItemDto public bool CanTranscode { get; init; } public bool CanPreview { get; init; } + + /// + /// Video metadata (only populated when includeMetadata is requested and item is a video file). + /// + public Abstractions.Recording.VideoMetadataDto? Metadata { get; init; } + + /// + /// Thumbnail URL relative path (only populated when includeMetadata is requested and item is a video file). + /// + public string? ThumbnailUrl { get; init; } } public sealed class MediaBrowserResponseDto diff --git a/src/LiveRecorder.Application/Models/RecordTasks/BandwidthModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/BandwidthModels.cs new file mode 100644 index 0000000..ceda083 --- /dev/null +++ b/src/LiveRecorder.Application/Models/RecordTasks/BandwidthModels.cs @@ -0,0 +1,30 @@ +namespace LiveRecorder.Application.Models.RecordTasks; + +/// +/// Bandwidth summary statistics. +/// +public sealed class BandwidthSummaryDto +{ + public double TotalTrafficMB { get; init; } + public double AverageBitrateKbps { get; init; } + public double PeakBitrateKbps { get; init; } +} + +/// +/// Bandwidth timeline for a recording session. +/// +public sealed class BandwidthTimelineDto +{ + public Guid RecordSessionId { get; init; } + public required IReadOnlyList Points { get; init; } +} + +/// +/// A single bandwidth sample point in time. +/// +public sealed class BandwidthPointDto +{ + public DateTimeOffset Timestamp { get; init; } + public long BytesDownloaded { get; init; } + public double? BitrateKbps { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs index e903e53..edf0a2d 100644 --- a/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs +++ b/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs @@ -166,3 +166,23 @@ public sealed class RecordSessionDeletionBatchResult public required IReadOnlyList Warnings { get; init; } } + +public sealed class SessionPlaylistDto +{ + public Guid RecordSessionId { get; init; } + + public string LiveRoomTitle { get; init; } = string.Empty; + + public required IReadOnlyList 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; } +} diff --git a/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs b/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs new file mode 100644 index 0000000..7cc746e --- /dev/null +++ b/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs @@ -0,0 +1,97 @@ +namespace LiveRecorder.Application.Models.Reports; + +/// +/// Real-time system dashboard overview DTO. +/// +public sealed class DashboardDto +{ + /// + /// Number of sessions currently recording (Running status). + /// + public int ActiveRecordingCount { get; init; } + + /// + /// Number of live rooms currently live. + /// + public int LiveRoomCount { get; init; } + + /// + /// Number of live rooms currently offline. + /// + public int OfflineRoomCount { get; init; } + + /// + /// Total number of live rooms in the system. + /// + public int TotalRoomCount { get; init; } + + /// + /// Total recording duration in seconds for sessions started today (Beijing time). + /// + public double TodayRecordingSeconds { get; init; } + + /// + /// Total data recorded today in bytes (sum of FileSizeBytes). + /// + public long TodayDataBytes { get; init; } + + /// + /// Total danmaku events recorded today. + /// + public int TodayDanmakuCount { get; init; } + + /// + /// Number of sessions with Starting or Running status. + /// + public int ActiveSessionCount { get; init; } + + /// + /// Number of Error-level system logs in the last 24 hours. + /// + public int RecentErrorCount { get; init; } + + /// + /// Current storage guard status. + /// + public StorageStatusDto StorageStatus { get; init; } = new(); + + /// + /// Most recent active/completed sessions (up to 5). + /// + public required IReadOnlyList RecentSessions { get; init; } + + /// + /// Top live rooms by recording duration today (up to 5). + /// + public required IReadOnlyList 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; } +} diff --git a/src/LiveRecorder.Application/Services/BandwidthStatisticsService.cs b/src/LiveRecorder.Application/Services/BandwidthStatisticsService.cs new file mode 100644 index 0000000..d9df2c9 --- /dev/null +++ b/src/LiveRecorder.Application/Services/BandwidthStatisticsService.cs @@ -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 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(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 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(); + 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 + }; + } +} diff --git a/src/LiveRecorder.Application/Services/DashboardService.cs b/src/LiveRecorder.Application/Services/DashboardService.cs new file mode 100644 index 0000000..2d3133d --- /dev/null +++ b/src/LiveRecorder.Application/Services/DashboardService.cs @@ -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 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 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(); + } +} diff --git a/src/LiveRecorder.Application/Services/MediaBrowserService.cs b/src/LiveRecorder.Application/Services/MediaBrowserService.cs index ed620cf..3bc9dce 100644 --- a/src/LiveRecorder.Application/Services/MediaBrowserService.cs +++ b/src/LiveRecorder.Application/Services/MediaBrowserService.cs @@ -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 BrowseAsync(string? relativePath, CancellationToken cancellationToken = default) + public async Task 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(); + 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(); diff --git a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs index 5bf20fc..7656b80 100644 --- a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs +++ b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs @@ -51,6 +51,12 @@ public sealed class LiveRoomRepository : ILiveRoomRepository .OrderByDescending(static item => item.UpdatedAt) .ToList(); + public Task CountAsync(CancellationToken cancellationToken = default) => + _dbContext.LiveRooms.CountAsync(cancellationToken); + + public Task CountByAvailabilityAsync(LiveRoomAvailabilityStatus status, CancellationToken cancellationToken = default) => + _dbContext.LiveRooms.CountAsync(item => item.AvailabilityStatus == status, cancellationToken); + public Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) => _dbContext.LiveRooms.AddAsync(liveRoom, cancellationToken).AsTask(); @@ -130,6 +136,12 @@ public sealed class RecordTaskRepository : IRecordTaskRepository (item.Status == RecordTaskStatus.Starting || item.Status == RecordTaskStatus.Running), cancellationToken); + public Task SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default) => + _dbContext.RecordTasks + .Include(item => item.RecordSession) + .Where(item => item.RecordSession != null && item.RecordSession.StartedAt >= startedFrom && item.RecordSession.StartedAt <= startedTo) + .SumAsync(item => item.DurationSeconds ?? 0, cancellationToken); + public Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) => _dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask(); @@ -195,6 +207,32 @@ public sealed class RecordSessionRepository : IRecordSessionRepository item.Status == RecordSessionStatus.Stopping), cancellationToken); + public Task CountByStatusAsync(RecordSessionStatus status, CancellationToken cancellationToken = default) => + _dbContext.RecordSessions.CountAsync(item => item.Status == status, cancellationToken); + + public Task CountActiveAsync(CancellationToken cancellationToken = default) => + _dbContext.RecordSessions.CountAsync(item => + item.Status == RecordSessionStatus.Starting || item.Status == RecordSessionStatus.Running, cancellationToken); + + public async Task> ListRecentAsync(int take, CancellationToken cancellationToken = default) => + await _dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex)) + .ThenInclude(item => item.Result) + .AsNoTracking() + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(take, 1, 50)) + .ToListAsync(cancellationToken); + + public async Task> ListInDateRangeAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default) => + await _dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex)) + .ThenInclude(item => item.Result) + .AsNoTracking() + .Where(item => item.StartedAt >= startedFrom && item.StartedAt <= startedTo) + .ToListAsync(cancellationToken); + public Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default) => _dbContext.RecordSessions.AddAsync(recordSession, cancellationToken).AsTask(); @@ -213,6 +251,21 @@ public sealed class RecordResultRepository : IRecordResultRepository public Task GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default) => _dbContext.RecordResults.FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken); + public async Task<(long TotalBytes, int TotalDanmaku)> GetTodayAggregateAsync(DateTimeOffset createdFrom, DateTimeOffset createdTo, CancellationToken cancellationToken = default) + { + var result = await _dbContext.RecordResults + .Where(item => item.CreatedAt >= createdFrom && item.CreatedAt <= createdTo) + .GroupBy(_ => 1) + .Select(g => new + { + TotalBytes = g.Sum(item => item.FileSizeBytes ?? 0L), + TotalDanmaku = g.Sum(item => item.DanmakuMessageCount) + }) + .FirstOrDefaultAsync(cancellationToken); + + return (result?.TotalBytes ?? 0L, result?.TotalDanmaku ?? 0); + } + public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) => _dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask(); @@ -310,6 +363,9 @@ public sealed class SystemLogRepository : ISystemLogRepository .ToListAsync(cancellationToken); } + public Task CountRecentErrorsAsync(DateTimeOffset since, CancellationToken cancellationToken = default) => + _dbContext.SystemLogEntries.CountAsync(item => item.Level == SystemLogLevel.Error && item.CreatedAt >= since, cancellationToken); + public void RemoveRange(IEnumerable entries) => _dbContext.SystemLogEntries.RemoveRange(entries); public async Task> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default) diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs index f9cbf69..85ca934 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs @@ -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(); + 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 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 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 RecentOutputLines { get; } = new(); diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs index df15e08..7cbb750 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs @@ -656,7 +656,7 @@ public sealed partial class FfmpegService string? selectedVideoCodec, FfmpegInputOptionProfile inputOptionProfile) { - var arguments = new List { "-hide_banner", "-y" }; + var arguments = new List { "-hide_banner", "-y", "-progress", "pipe:1" }; var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode); if (IsHttpInput(streamUrl)) diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegVideoMetadataService.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegVideoMetadataService.cs new file mode 100644 index 0000000..1872b03 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegVideoMetadataService.cs @@ -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 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 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"; +} diff --git a/src/LiveRecorder.WebApi/Controllers/BandwidthController.cs b/src/LiveRecorder.WebApi/Controllers/BandwidthController.cs new file mode 100644 index 0000000..e35680e --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/BandwidthController.cs @@ -0,0 +1,38 @@ +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/bandwidth")] +public sealed class BandwidthController : ControllerBase +{ + private readonly BandwidthStatisticsService _bandwidthService; + + public BandwidthController(BandwidthStatisticsService bandwidthService) + { + _bandwidthService = bandwidthService; + } + + [HttpGet("session/{id:guid}")] + public async Task> GetSessionTimeline(Guid id, CancellationToken cancellationToken) + { + var result = await _bandwidthService.GetSessionTimelineAsync(id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("daily")] + public async Task> GetDaily( + [FromQuery] string? date = null, + [FromQuery] int utcOffsetMinutes = 480, + CancellationToken cancellationToken = default) + { + var targetDate = date is not null && DateOnly.TryParse(date, out var parsed) + ? parsed + : DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromMinutes(utcOffsetMinutes)).DateTime); + + var result = await _bandwidthService.GetDailySummaryAsync(targetDate, utcOffsetMinutes, cancellationToken); + return result is null ? NotFound() : Ok(result); + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/DashboardController.cs b/src/LiveRecorder.WebApi/Controllers/DashboardController.cs new file mode 100644 index 0000000..a28e4a1 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/DashboardController.cs @@ -0,0 +1,21 @@ +using LiveRecorder.Application.Models.Reports; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/dashboard")] +public sealed class DashboardController : ControllerBase +{ + private readonly DashboardService _dashboardService; + + public DashboardController(DashboardService dashboardService) + { + _dashboardService = dashboardService; + } + + [HttpGet] + public async Task> Get(CancellationToken cancellationToken) => + Ok(await _dashboardService.GetDashboardAsync(cancellationToken)); +} diff --git a/src/LiveRecorder.WebApi/Controllers/MediaBrowserController.cs b/src/LiveRecorder.WebApi/Controllers/MediaBrowserController.cs index f3520c5..9f02f0e 100644 --- a/src/LiveRecorder.WebApi/Controllers/MediaBrowserController.cs +++ b/src/LiveRecorder.WebApi/Controllers/MediaBrowserController.cs @@ -18,9 +18,10 @@ public sealed class MediaBrowserController : ControllerBase [HttpGet("browser")] public async Task> Browse( [FromQuery] string? path, - CancellationToken cancellationToken) + [FromQuery] bool includeMetadata = false, + CancellationToken cancellationToken = default) { - return Ok(await _mediaBrowserService.BrowseAsync(path, cancellationToken)); + return Ok(await _mediaBrowserService.BrowseAsync(path, includeMetadata, cancellationToken)); } [HttpGet("file")] @@ -37,6 +38,55 @@ public sealed class MediaBrowserController : ControllerBase : PhysicalFile(filePath, contentType, enableRangeProcessing: contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)); } + [HttpGet("thumbnail")] + public async Task GetThumbnail( + [FromQuery] string path, + CancellationToken cancellationToken = default) + { + var filePath = await _mediaBrowserService.ResolveFilePathAsync(path, cancellationToken); + // Build thumbnail path: the same way FfmpegVideoMetadataService does + var settingsOutputRoot = filePath; + // We need the output root. Use the service to resolve it. + // Simpler approach: serve the thumbnail from the .thumbnails dir relative to the file + var dirName = Path.GetDirectoryName(filePath); + if (string.IsNullOrWhiteSpace(dirName)) + { + return NotFound(); + } + + // Walk up to find output root by looking for .thumbnails directory + var currentDir = dirName; + string? thumbDir = null; + while (currentDir is not null && Directory.Exists(currentDir)) + { + var candidate = Path.Combine(currentDir, ".thumbnails"); + if (Directory.Exists(candidate)) + { + thumbDir = candidate; + break; + } + + var parent = Directory.GetParent(currentDir); + currentDir = parent?.FullName; + } + + if (string.IsNullOrWhiteSpace(thumbDir)) + { + return NotFound(); + } + + // Find the thumbnail file matching the relative path pattern + var relativePath = path.Replace('\\', '/').TrimStart('/').Replace('/', '_'); + var thumbPath = Path.Combine(thumbDir, $"{relativePath}.jpg"); + + if (!System.IO.File.Exists(thumbPath)) + { + return NotFound(); + } + + return PhysicalFile(thumbPath, "image/jpeg"); + } + [HttpPost("transcode-file")] public async Task> TranscodeFile( [FromBody] TranscodeMediaFileRequest request, diff --git a/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs b/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs index 1e23c73..18f95d6 100644 --- a/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs +++ b/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs @@ -1,7 +1,9 @@ +using LiveRecorder.Application.Abstractions.Persistence; using LiveRecorder.Application.Models.Cleanup; using LiveRecorder.Application.Models.RecordTasks; using LiveRecorder.Application.Services; using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Domain.Enums; using LiveRecorder.Infrastructure.Services; using Microsoft.AspNetCore.Mvc; using System.Text.Json; @@ -111,4 +113,61 @@ public sealed class RecordSessionsController : ControllerBase var result = await _danmakuService.GetSessionDanmakuAsync(id, cancellationToken); return result is null ? NotFound() : Ok(result); } + + [HttpGet("{id:guid}/playlist")] + public async Task> GetPlaylist( + Guid id, + [FromServices] IRecordMediaService recordMediaService, + [FromServices] IRecordSessionRepository sessionRepository, + [FromServices] LinkGenerator linkGenerator, + CancellationToken cancellationToken) + { + var session = await sessionRepository.GetByIdAsync(id, cancellationToken); + if (session is null) + { + return NotFound(); + } + + var segments = new List(); + foreach (var task in session.RecordTasks + .Where(item => item.Status is RecordTaskStatus.Completed or RecordTaskStatus.Stopped) + .OrderBy(item => item.SegmentIndex) + .ThenBy(item => item.CreatedAt)) + { + try + { + var ticket = await recordMediaService.CreatePreviewTicketAsync(task.Id, cancellationToken); + var ticketUrl = linkGenerator.GetUriByAction( + HttpContext, + action: nameof(MediaController.GetRecordTaskMedia), + controller: "Media", + values: new { ticket = ticket.Ticket }) + ?? $"{Request.Scheme}://{Request.Host}/media/record-tasks/{ticket.Ticket}"; + + segments.Add(new SessionPlaylistSegmentDto + { + RecordTaskId = task.Id, + SegmentIndex = task.SegmentIndex, + PreviewTicketUrl = ticketUrl, + DurationSeconds = task.DurationSeconds + }); + } + catch + { + // Skip segments that can't be previewed + } + } + + if (segments.Count == 0) + { + return NotFound(); + } + + return Ok(new SessionPlaylistDto + { + RecordSessionId = session.Id, + LiveRoomTitle = session.LiveRoom?.Title ?? "-", + Segments = segments + }); + } } diff --git a/src/LiveRecorder.WebApi/Program.cs b/src/LiveRecorder.WebApi/Program.cs index cefc53b..33202a7 100644 --- a/src/LiveRecorder.WebApi/Program.cs +++ b/src/LiveRecorder.WebApi/Program.cs @@ -175,6 +175,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -184,6 +185,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();