diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index 69ead64..74d2400 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -756,6 +756,7 @@ async function handleLogout() { .app-main { padding: var(--content-padding); min-height: 0; + overflow-x: hidden; } .backend-alert { diff --git a/frontend/src/views/LiveRoomsView.vue b/frontend/src/views/LiveRoomsView.vue index 6dd8b7d..cd02bc3 100644 --- a/frontend/src/views/LiveRoomsView.vue +++ b/frontend/src/views/LiveRoomsView.vue @@ -1247,7 +1247,8 @@ onBeforeUnmount(() => { } .rooms-table-shell { - overflow: visible; + overflow-x: auto; + overflow-y: hidden; } .rooms-table { diff --git a/frontend/src/views/RecordTasksView.vue b/frontend/src/views/RecordTasksView.vue index b94b20e..268d4c3 100644 --- a/frontend/src/views/RecordTasksView.vue +++ b/frontend/src/views/RecordTasksView.vue @@ -940,7 +940,7 @@ onBeforeUnmount(() => { } .tasks-table-shell { - overflow: visible; + overflow-x: auto; } .sessions-card :deep(.el-card__body) { @@ -1040,6 +1040,7 @@ onBeforeUnmount(() => { display: grid; gap: 16px; padding: 18px 20px 20px; + overflow-x: hidden; } .session-summary { diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs index 9413a3f..94efd90 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs @@ -155,7 +155,7 @@ public sealed partial class FfmpegService } var observedAt = DateTimeOffset.UtcNow; - var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform); + var adapter = adapterFactory.GetByPlatform(session.LiveRoom!.Platform); var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId); await liveRoomStatusService.ApplySnapshotAsync(session.LiveRoom, liveStatus, observedAt); await dbContext.SaveChangesAsync(); @@ -577,14 +577,24 @@ public sealed partial class FfmpegService return true; } - if (runtime.StartupFailureKind != StartupFailureKind.StreamHandshake || runtime.HasRetriedWithRefreshedStream) + if (runtime.StartupFailureKind != StartupFailureKind.StreamHandshake) { return false; } + if (runtime.HasRetriedWithRefreshedStream) + { + if (runtime.HasRetriedWithAlternateProtocol) + { + return false; + } + + return await TryRetryWithAlternateProtocolAsync(runtime, session, currentTask, observedAt, scope); + } + var adapterFactory = scope.ServiceProvider.GetRequiredService(); var liveRoomStatusService = scope.ServiceProvider.GetRequiredService(); - var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform); + var adapter = adapterFactory.GetByPlatform(session.LiveRoom!.Platform); var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId); await liveRoomStatusService.ApplySnapshotAsync(session.LiveRoom, liveStatus, observedAt); await dbContext.SaveChangesAsync(); @@ -604,15 +614,52 @@ public sealed partial class FfmpegService currentTask.Id); var refreshedStream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality); - session.MarkStarting(refreshedStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt); + + var selectedUrl = refreshedStream.SelectedUrl; + var selectedProtocol = refreshedStream.SelectedProtocol; + var selectedQuality = refreshedStream.SelectedQuality; + if (refreshedStream.AvailableQualities.Count > 1) + { + var alternateProtocol = refreshedStream.SelectedProtocol.Equals("flv", StringComparison.OrdinalIgnoreCase) + ? "hls" + : "flv"; + var alternateOption = refreshedStream.AvailableQualities + .Where(o => o.Protocol.Equals(alternateProtocol, StringComparison.OrdinalIgnoreCase) && + o.QualityKey == refreshedStream.SelectedQuality) + .MaxBy(o => o.Rank); + + if (alternateOption is not null) + { + selectedUrl = alternateOption.Url; + selectedProtocol = alternateOption.Protocol; + selectedQuality = alternateOption.QualityKey; + await logService.WriteAsync( + SystemLogLevel.Info, + "FFmpeg", + $"ffmpeg retry will use alternate protocol {selectedProtocol.ToUpperInvariant()} for the same quality tier.", + liveRoomId: session.LiveRoomId, + recordSessionId: session.Id, + recordTaskId: currentTask.Id); + } + } + + var streamForRetry = new StreamUrlResult( + selectedQuality, + selectedProtocol, + selectedUrl, + refreshedStream.InputHeaders, + refreshedStream.AvailableQualities, + refreshedStream.SelectedVideoCodec); + + session.MarkStarting(streamForRetry.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt); session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt); - currentTask.MarkStarting(refreshedStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt); + currentTask.MarkStarting(streamForRetry.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt); await dbContext.SaveChangesAsync(); await StartInternalAsync( session, currentTask, - refreshedStream, + streamForRetry, runtime.RecordingSettings, runtime.InputOptionProfile, hasRetriedWithCompatibilityProfile: runtime.HasRetriedWithCompatibilityProfile, @@ -641,6 +688,107 @@ public sealed partial class FfmpegService } } + private async Task TryRetryWithAlternateProtocolAsync( + SessionProcessRuntime runtime, + RecordSession session, + RecordTask currentTask, + DateTimeOffset observedAt, + IServiceScope scope) + { + try + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var logService = scope.ServiceProvider.GetRequiredService(); + var adapterFactory = scope.ServiceProvider.GetRequiredService(); + var liveRoomStatusService = scope.ServiceProvider.GetRequiredService(); + var adapter = adapterFactory.GetByPlatform(session.LiveRoom!.Platform); + var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId); + await liveRoomStatusService.ApplySnapshotAsync(session.LiveRoom, liveStatus, observedAt); + await dbContext.SaveChangesAsync(); + + if (!liveStatus.IsLive) + { + return false; + } + + var refreshedStream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality); + var alternateProtocol = runtime.SelectedProtocol.Equals("flv", StringComparison.OrdinalIgnoreCase) + ? "hls" + : "flv"; + + var alternateOption = refreshedStream.AvailableQualities + .Where(o => o.Protocol.Equals(alternateProtocol, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(o => o.Rank) + .FirstOrDefault(); + + if (alternateOption is null) + { + await logService.WriteAsync( + SystemLogLevel.Warning, + "FFmpeg", + $"No {alternateProtocol.ToUpperInvariant()} stream option available for alternate protocol fallback.", + liveRoomId: session.LiveRoomId, + recordSessionId: session.Id, + recordTaskId: currentTask.Id); + return false; + } + + await logService.WriteAsync( + SystemLogLevel.Warning, + "FFmpeg", + $"Refreshed stream URL also failed. Trying alternate protocol {alternateProtocol.ToUpperInvariant()} as last fallback.", + runtime.LastStartupFailureLine, + session.LiveRoomId, + session.Id, + currentTask.Id); + + var retryStream = new StreamUrlResult( + alternateOption.QualityKey, + alternateOption.Protocol, + alternateOption.Url, + refreshedStream.InputHeaders, + refreshedStream.AvailableQualities, + refreshedStream.SelectedVideoCodec); + + session.MarkStarting(retryStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt); + session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt); + currentTask.MarkStarting(retryStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt); + await dbContext.SaveChangesAsync(); + + runtime.HasRetriedWithAlternateProtocol = true; + + await StartInternalAsync( + session, + currentTask, + retryStream, + runtime.RecordingSettings, + runtime.InputOptionProfile, + hasRetriedWithCompatibilityProfile: runtime.HasRetriedWithCompatibilityProfile, + hasRetriedWithRefreshedStream: true, + runtime.RetryAttemptCount + 1); + + var retryStartedAt = DateTimeOffset.UtcNow; + session.MarkRunning(retryStartedAt); + currentTask.MarkRunning(retryStartedAt); + await dbContext.SaveChangesAsync(); + + await logService.WriteAsync( + SystemLogLevel.Info, + "FFmpeg", + $"ffmpeg alternate protocol ({alternateProtocol.ToUpperInvariant()}) retry started.", + liveRoomId: session.LiveRoomId, + recordSessionId: session.Id, + recordTaskId: currentTask.Id); + + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Alternate protocol fallback failed for session {RecordSessionId}", runtime.RecordSessionId); + return false; + } + } + private async Task FinalizeExitedSessionAsync( SessionProcessRuntime runtime, Process process, @@ -1399,6 +1547,7 @@ public sealed partial class FfmpegService public FfmpegInputOptionProfile InputOptionProfile { get; } public bool HasRetriedWithCompatibilityProfile { get; } public bool HasRetriedWithRefreshedStream { get; } + public bool HasRetriedWithAlternateProtocol { get; set; } public Process? Process { get; private set; } public int ProcessId => Process?.Id ?? 0; public bool CompletionRequested { get; private set; }