diff --git a/frontend/src/components/ui/StorageCapacity.vue b/frontend/src/components/ui/StorageCapacity.vue index 9dab83f..b2305e1 100644 --- a/frontend/src/components/ui/StorageCapacity.vue +++ b/frontend/src/components/ui/StorageCapacity.vue @@ -5,6 +5,7 @@ interface StorageCapacityStatus { isEnabled: boolean; isAvailable: boolean; checkedPath: string; + volumeRoot: string; totalBytes: number; usedBytes: number; availableBytes: number; @@ -83,6 +84,9 @@ function formatBytes(bytes: number) { {{ statusLabel }}

{{ status.checkedPath || "未配置输出路径" }}

+

+ 检测卷:{{ status.volumeRoot || "--" }} +

已使用
{{ status.isAvailable ? formatBytes(status.usedBytes) : "--" }}
@@ -103,6 +107,7 @@ function formatBytes(bytes: number) { .storage-capacity__summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .storage-capacity__summary > strong { min-width: 0; color: var(--text-primary); font-size: 13px; font-variant-numeric: tabular-nums; } .storage-capacity__path { margin: 7px 0 0; overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.storage-capacity__volume { margin: 3px 0 0; overflow: hidden; color: var(--text-muted); font-family: var(--font-mono); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } .storage-capacity__metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 13px 0 0; } .storage-capacity__metrics > div { min-width: 0; padding: 9px; border-radius: var(--radius-sm); background: var(--surface-muted); } .storage-capacity__metrics dt { color: var(--text-muted); font-size: 9px; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bac1006..b273865 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -703,6 +703,7 @@ export interface StorageGuardStatus { isAvailable: boolean; hasEnoughSpace: boolean; checkedPath: string; + volumeRoot: string; totalBytes: number; usedBytes: number; availableBytes: number; @@ -1011,6 +1012,7 @@ export interface DashboardStorageStatus { hasEnoughSpace: boolean; message: string; checkedPath: string; + volumeRoot: string; totalBytes: number; usedBytes: number; availableBytes: number; diff --git a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs index d0d83d8..2180a9b 100644 --- a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs +++ b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs @@ -116,6 +116,8 @@ public interface IRecordResultRepository Task SumPendingUploadBytesAsync(CancellationToken cancellationToken = default); + Task GetPendingUploadMetricsAsync(CancellationToken cancellationToken = default); + Task CountFailedArtifactAsync(CancellationToken cancellationToken = default); Task> ListUploadStatusAsync( @@ -202,6 +204,8 @@ public interface IUserSessionRepository void Update(UserSession session); } +public sealed record PendingUploadMetrics(int Count, long TotalBytes); + public interface IUnitOfWork { Task SaveChangesAsync(CancellationToken cancellationToken = default); diff --git a/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs b/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs index 46becc6..ab18e05 100644 --- a/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs +++ b/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs @@ -37,6 +37,8 @@ public interface IFfmpegService Task RecoverOrphanedTerminalSessionTasksAsync(CancellationToken cancellationToken = default); + Task RecoverStalePendingSessionsAsync(CancellationToken cancellationToken = default); + Task TryRecoverOrphanedTerminalTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default); Task StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default); diff --git a/src/LiveRecorder.Application/Abstractions/Storage/IStorageGuardService.cs b/src/LiveRecorder.Application/Abstractions/Storage/IStorageGuardService.cs index e3db747..da09273 100644 --- a/src/LiveRecorder.Application/Abstractions/Storage/IStorageGuardService.cs +++ b/src/LiveRecorder.Application/Abstractions/Storage/IStorageGuardService.cs @@ -23,6 +23,16 @@ public interface IStorageGuardService StorageGuardResult CheckCanFinalize(SystemSettingsDto settings, long estimatedTemporaryBytes); } +public interface IStorageCapacityProvider +{ + StorageVolumeCapacity GetCapacity(string outputPath); +} + +public sealed record StorageVolumeCapacity( + string VolumeRoot, + long TotalBytes, + long AvailableBytes); + public sealed record StorageGuardResult( bool IsEnabled, bool HasEnoughSpace, @@ -33,6 +43,8 @@ public sealed record StorageGuardResult( { public bool IsAvailable { get; init; } + public string VolumeRoot { get; init; } = string.Empty; + public long TotalBytes { get; init; } public long UsedBytes { get; init; } diff --git a/src/LiveRecorder.Application/Models/Recovery/RecoveryModels.cs b/src/LiveRecorder.Application/Models/Recovery/RecoveryModels.cs index fbcac4b..567e2bd 100644 --- a/src/LiveRecorder.Application/Models/Recovery/RecoveryModels.cs +++ b/src/LiveRecorder.Application/Models/Recovery/RecoveryModels.cs @@ -36,6 +36,8 @@ public sealed class StorageGuardStatusDto public required string CheckedPath { get; init; } + public string VolumeRoot { get; init; } = string.Empty; + public long AvailableBytes { get; init; } public long TotalBytes { get; init; } diff --git a/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs b/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs index 8522db3..2fddd67 100644 --- a/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs +++ b/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs @@ -101,6 +101,7 @@ public sealed class StorageStatusDto public bool HasEnoughSpace { get; init; } public string Message { get; init; } = string.Empty; public string CheckedPath { get; init; } = string.Empty; + public string VolumeRoot { get; init; } = string.Empty; public long TotalBytes { get; init; } public long UsedBytes { get; init; } public long AvailableBytes { get; init; } diff --git a/src/LiveRecorder.Application/Services/DashboardService.cs b/src/LiveRecorder.Application/Services/DashboardService.cs index 7d4269d..1d8bf21 100644 --- a/src/LiveRecorder.Application/Services/DashboardService.cs +++ b/src/LiveRecorder.Application/Services/DashboardService.cs @@ -65,8 +65,7 @@ public sealed class DashboardService var settings = await _systemSettingsService.GetAsync(cancellationToken); var storageCheck = _storageGuardService.CheckCanStartOrResume(settings); var pendingTranscodeCount = await _recordTaskRepository.CountByStatusAsync(RecordTaskStatus.Processing, cancellationToken); - var pendingUploadCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken); - var queuedDataBytes = await _recordResultRepository.SumPendingUploadBytesAsync(cancellationToken); + var pendingUploadMetrics = await _recordResultRepository.GetPendingUploadMetricsAsync(cancellationToken); var operationsMetrics = await _operationsMetricsRepository.GetAsync(cancellationToken); return new DashboardDto @@ -88,6 +87,7 @@ public sealed class DashboardService HasEnoughSpace = storageCheck.HasEnoughSpace, Message = storageCheck.Message ?? string.Empty, CheckedPath = storageCheck.CheckedPath, + VolumeRoot = storageCheck.VolumeRoot, TotalBytes = storageCheck.TotalBytes, UsedBytes = storageCheck.UsedBytes, AvailableBytes = storageCheck.AvailableBytes, @@ -99,8 +99,8 @@ public sealed class DashboardService RedThresholdPercent = storageCheck.RedThresholdPercent }, PendingTranscodeCount = pendingTranscodeCount, - PendingUploadCount = pendingUploadCount, - QueuedDataBytes = queuedDataBytes, + PendingUploadCount = pendingUploadMetrics.Count, + QueuedDataBytes = pendingUploadMetrics.TotalBytes, OldestTranscodeUpdatedAt = operationsMetrics.OldestTranscodeUpdatedAt, OldestUploadProgressAt = operationsMetrics.OldestUploadProgressAt, StalledUploadCount = operationsMetrics.StalledUploadCount, diff --git a/src/LiveRecorder.Application/Services/RecordService.cs b/src/LiveRecorder.Application/Services/RecordService.cs index 26d00fa..640f87b 100644 --- a/src/LiveRecorder.Application/Services/RecordService.cs +++ b/src/LiveRecorder.Application/Services/RecordService.cs @@ -352,8 +352,9 @@ public sealed class RecordService } catch (Exception ex) { - initialTask.MarkFailed(ex.Message, DateTimeOffset.UtcNow); - recordSession.MarkFailed(ex.Message, DateTimeOffset.UtcNow); + var failedAt = DateTimeOffset.UtcNow; + initialTask.MarkFailed(ex.Message, failedAt); + recordSession.MarkFailed(ex.Message, failedAt); if (trackAutoStartDecision) { ApplyAutoStartDecision( @@ -363,7 +364,8 @@ public sealed class RecordService ex.Message); } - await _unitOfWork.SaveChangesAsync(cancellationToken); + using var terminalStateCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _unitOfWork.SaveChangesAsync(terminalStateCts.Token); await _systemLogService.WriteAsync( SystemLogLevel.Error, @@ -373,7 +375,12 @@ public sealed class RecordService liveRoom.Id, recordSession.Id, initialTask.Id, - cancellationToken); + terminalStateCts.Token); + + if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) + { + throw; + } await _emailNotificationService.SendExceptionAsync( "RecordSession", diff --git a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs index a5a4b86..a6a11b5 100644 --- a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs +++ b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs @@ -452,9 +452,9 @@ public sealed class RecordSessionRepository : IRecordSessionRepository public Task CountActiveAsync(CancellationToken cancellationToken = default) => _dbContext.RecordSessions.CountAsync(item => - item.Status == RecordSessionStatus.Pending || item.Status == RecordSessionStatus.Starting || - item.Status == RecordSessionStatus.Running, cancellationToken); + item.Status == RecordSessionStatus.Running || + item.Status == RecordSessionStatus.Stopping, cancellationToken); public async Task> ListRecentAsync(int take, CancellationToken cancellationToken = default) => await _dbContext.RecordSessions @@ -508,21 +508,49 @@ public sealed class RecordResultRepository : IRecordResultRepository return (result?.TotalBytes ?? 0L, result?.TotalDanmaku ?? 0); } - public Task CountPendingUploadAsync(CancellationToken cancellationToken = default) => - _dbContext.RecordResults.CountAsync( - item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded && - item.RecordTask != null && - (item.RecordTask.Status == RecordTaskStatus.Completed || - item.RecordTask.Status == RecordTaskStatus.Stopped), - cancellationToken); + public async Task CountPendingUploadAsync(CancellationToken cancellationToken = default) => + (await GetPendingUploadMetricsAsync(cancellationToken)).Count; - public Task SumPendingUploadBytesAsync(CancellationToken cancellationToken = default) => - _dbContext.RecordResults + public async Task SumPendingUploadBytesAsync(CancellationToken cancellationToken = default) => + (await GetPendingUploadMetricsAsync(cancellationToken)).TotalBytes; + + public async Task GetPendingUploadMetricsAsync(CancellationToken cancellationToken = default) + { + var candidatePaths = await _dbContext.RecordResults + .AsNoTracking() .Where(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded && item.RecordTask != null && + !item.RecordTask.IsHiddenArtifactSource && + item.RecordTask.MergedIntoRecordTaskId == null && (item.RecordTask.Status == RecordTaskStatus.Completed || - item.RecordTask.Status == RecordTaskStatus.Stopped)) - .SumAsync(item => item.FileSizeBytes ?? 0L, cancellationToken); + item.RecordTask.Status == RecordTaskStatus.Stopped) && + item.FilePath != null && + item.FilePath != string.Empty) + .Select(item => item.FilePath) + .ToListAsync(cancellationToken); + + var count = 0; + long totalBytes = 0; + foreach (var path in candidatePaths) + { + try + { + var file = new FileInfo(path); + if (!file.Exists || file.Length <= 0) + { + continue; + } + + count++; + totalBytes += file.Length; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + } + } + + return new PendingUploadMetrics(count, totalBytes); + } public Task CountFailedArtifactAsync(CancellationToken cancellationToken = default) => _dbContext.RecordResults.CountAsync( diff --git a/src/LiveRecorder.Infrastructure/Services/DriveInfoStorageCapacityProvider.cs b/src/LiveRecorder.Infrastructure/Services/DriveInfoStorageCapacityProvider.cs new file mode 100644 index 0000000..abe159a --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/DriveInfoStorageCapacityProvider.cs @@ -0,0 +1,89 @@ +using LiveRecorder.Application.Abstractions.Storage; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed class DriveInfoStorageCapacityProvider : IStorageCapacityProvider +{ + public StorageVolumeCapacity GetCapacity(string outputPath) + { + var volumes = new List(); + foreach (var drive in DriveInfo.GetDrives()) + { + try + { + if (drive.IsReady) + { + volumes.Add(new StorageVolumeCapacity( + drive.RootDirectory.FullName, + drive.TotalSize, + drive.AvailableFreeSpace)); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + var selected = SelectBestVolume(outputPath, volumes); + if (selected is not null) + { + return selected; + } + + var fallbackRoot = Path.GetPathRoot(outputPath); + if (string.IsNullOrWhiteSpace(fallbackRoot)) + { + throw new InvalidOperationException($"No mounted volume contains output path '{outputPath}'."); + } + + var fallback = new DriveInfo(fallbackRoot); + if (!fallback.IsReady) + { + throw new IOException($"Storage volume '{fallbackRoot}' is not ready."); + } + + return new StorageVolumeCapacity( + fallback.RootDirectory.FullName, + fallback.TotalSize, + fallback.AvailableFreeSpace); + } + + internal static StorageVolumeCapacity? SelectBestVolume( + string outputPath, + IReadOnlyCollection volumes) + { + var normalizedPath = NormalizeForMountComparison(outputPath); + return volumes + .Select(volume => new + { + Volume = volume, + NormalizedRoot = NormalizeForMountComparison(volume.VolumeRoot) + }) + .Where(item => IsPathWithinMount(normalizedPath, item.NormalizedRoot)) + .OrderByDescending(item => item.NormalizedRoot.Length) + .Select(item => item.Volume) + .FirstOrDefault(); + } + + private static bool IsPathWithinMount(string path, string mountRoot) + { + if (mountRoot == "/") + { + return path.StartsWith("/", StringComparison.Ordinal); + } + + return string.Equals(path, mountRoot, StringComparison.OrdinalIgnoreCase) || + path.StartsWith(mountRoot + "/", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeForMountComparison(string path) + { + var normalized = path.Trim().Replace((char)92, '/'); + if (normalized.Length > 1) + { + normalized = normalized.TrimEnd('/'); + } + + return normalized; + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Recovery.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Recovery.cs index bc6e3a7..803da18 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Recovery.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Recovery.cs @@ -49,6 +49,21 @@ public sealed partial class FfmpegService !hasTriedCurlFallback && string.Equals(selectedProtocol, "flv", StringComparison.OrdinalIgnoreCase); + internal static FfmpegRecoveryContext PrepareRefreshedStreamRecoveryContext( + FfmpegRecoveryContext current, + string selectedProtocol, + bool hasOverlongHeadersFailure) + { + var useCurlFallback = hasOverlongHeadersFailure && + string.Equals(selectedProtocol, "flv", StringComparison.OrdinalIgnoreCase); + + return current with + { + UseCurlFallback = useCurlFallback, + HasTriedCurlFallback = current.HasTriedCurlFallback || useCurlFallback + }; + } + internal static bool ShouldApplyRuntimeFailureBackoff( RecordSessionStatus status, TimeSpan processRuntime) => diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs index 63667d4..fa63486 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs @@ -37,7 +37,10 @@ public sealed partial class FfmpegService TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(60), - TimeSpan.FromSeconds(120) + TimeSpan.FromSeconds(120), + TimeSpan.FromSeconds(300), + TimeSpan.FromSeconds(600), + TimeSpan.FromSeconds(900) ]; private async Task HandleProcessOutputAsync(SessionProcessRuntime runtime, string? line, bool isError) @@ -85,7 +88,10 @@ public sealed partial class FfmpegService runtime.MarkStartupFailure(StartupFailureKind.StreamHandshake, line); } - if (TryClassifyPersistedFfmpegLine(line, isError, out var level)) + if (ShouldPersistFfmpegLine( + runtime.HasOpenedFirstSegment, + runtime.StartupFailureKind == StartupFailureKind.StreamHandshake) && + TryClassifyPersistedFfmpegLine(line, isError, out var level)) { await PersistFfmpegLineAsync(runtime, line, level); } @@ -728,11 +734,13 @@ public sealed partial class FfmpegService var stream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality, transition.Token); var selected = SelectRetryStreamForCurrentSession(stream, runtime); - var context = runtime.RecoveryContext with + var context = PrepareRefreshedStreamRecoveryContext(runtime.RecoveryContext with { AttemptCount = attempt, HasRetriedWithRefreshedStream = true - }; + }, + selected.SelectedProtocol, + runtime.HasHlsOverlongHeadersFailure || runtime.RecoveryContext.HasTriedCurlFallback); session.MarkStarting(selected.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, DateTimeOffset.UtcNow); currentTask.MarkStarting(selected.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, DateTimeOffset.UtcNow); await dbContext.SaveChangesAsync(transition.Token); @@ -1036,12 +1044,15 @@ public sealed partial class FfmpegService currentTask, streamForRetry, runtime.RecordingSettings, - AdvanceRecoveryContext( - runtime.RecoveryContext, - runtime.InputOptionProfile, - runtime.SelectedProtocol, + PrepareRefreshedStreamRecoveryContext( + AdvanceRecoveryContext( + runtime.RecoveryContext, + runtime.InputOptionProfile, + runtime.SelectedProtocol, + streamForRetry.SelectedProtocol, + refreshedStream: true), streamForRetry.SelectedProtocol, - refreshedStream: true)); + runtime.HasHlsOverlongHeadersFailure || runtime.RecoveryContext.HasTriedCurlFallback)); var refreshedRetryStartedAt = DateTimeOffset.UtcNow; session.MarkRunning(refreshedRetryStartedAt); @@ -1362,6 +1373,11 @@ public sealed partial class FfmpegService internal static TimeSpan GetRuntimeRecoveryDelay(int attempt) => RuntimeRecoveryBackoff[Math.Clamp(attempt - 1, 0, RuntimeRecoveryBackoff.Length - 1)]; + internal static bool ShouldPersistFfmpegLine( + bool hasOpenedFirstSegment, + bool isStreamHandshakeFailure) => + hasOpenedFirstSegment || !isStreamHandshakeFailure; + private async Task TryRetryWithAlternateProtocolAsync( SessionProcessRuntime runtime, RecordSession session, diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs index f8208f1..c1729fb 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs @@ -22,6 +22,9 @@ namespace LiveRecorder.Infrastructure.Services; public sealed partial class FfmpegService : IFfmpegService { private const string ArtifactRepairMarker = "[artifact-repair]"; + private const string StalePendingStartupFailure = + "Recording startup was interrupted before stream initialization."; + private static readonly TimeSpan StalePendingThreshold = TimeSpan.FromMinutes(10); private static readonly Regex SegmentOpeningRegex = new( """Opening '([^']+)' for writing""", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); @@ -644,6 +647,120 @@ public sealed partial class FfmpegService : IFfmpegService } } + public async Task RecoverStalePendingSessionsAsync(CancellationToken cancellationToken = default) + { + if (!await _orphanRecoveryGate.WaitAsync(0, cancellationToken)) + { + return 0; + } + + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var cutoff = DateTimeOffset.UtcNow - StalePendingThreshold; + var candidateIds = await dbContext.RecordSessions + .AsNoTracking() + .Where(session => + session.Status == RecordSessionStatus.Pending && + session.UpdatedAt <= cutoff) + .OrderBy(session => session.UpdatedAt) + .Select(session => session.Id) + .Take(50) + .ToListAsync(cancellationToken); + + var recovered = 0; + foreach (var candidateId in candidateIds) + { + if (IsRunning(candidateId)) + { + continue; + } + + using var candidateScope = _serviceScopeFactory.CreateScope(); + var candidateDbContext = candidateScope.ServiceProvider.GetRequiredService(); + var session = await candidateDbContext.RecordSessions + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == candidateId, cancellationToken); + if (session is null || + session.Status != RecordSessionStatus.Pending || + session.UpdatedAt > cutoff || + IsRunning(candidateId)) + { + continue; + } + + if (HasRecoverablePendingMedia(session)) + { + if (await ReconcileInactiveSessionAsync(candidateId, allowTerminalSession: false, cancellationToken)) + { + recovered++; + } + + continue; + } + + var failedAt = DateTimeOffset.UtcNow; + foreach (var task in session.RecordTasks.Where(task => task.Status == RecordTaskStatus.Pending)) + { + task.MarkFailed(StalePendingStartupFailure, failedAt); + } + + session.MarkFailed(StalePendingStartupFailure, failedAt); + await candidateDbContext.SaveChangesAsync(cancellationToken); + + var logService = candidateScope.ServiceProvider.GetRequiredService(); + await logService.WriteAsync( + SystemLogLevel.Warning, + "RecordSession", + "A stale pending recording session was marked failed.", + StalePendingStartupFailure, + session.LiveRoomId, + session.Id, + session.RecordTasks.OrderBy(task => task.SegmentIndex).FirstOrDefault()?.Id, + cancellationToken); + recovered++; + } + + return recovered; + } + finally + { + _orphanRecoveryGate.Release(); + } + } + + private static bool HasRecoverablePendingMedia(RecordSession session) + { + if (DiscoverRecoverableSegments(session.OutputPathPattern, session.OutputFormat, session.SaveMode).Count > 0) + { + return true; + } + + foreach (var task in session.RecordTasks) + { + var candidates = new[] + { + task.Result?.FilePath, + task.OutputFilePath, + string.IsNullOrWhiteSpace(task.OutputFilePath) + ? null + : GetRecorderOutputPath(task.OutputFilePath, session.OutputFormat, session.SaveMode) + }; + + if (candidates.Any(path => + !string.IsNullOrWhiteSpace(path) && + File.Exists(path) && + new FileInfo(path).Length > 0)) + { + return true; + } + } + + return false; + } + public async Task TryRecoverOrphanedTerminalTaskAsync( Guid recordTaskId, CancellationToken cancellationToken = default) diff --git a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs index 9ed2794..3a7a8b2 100644 --- a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs +++ b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs @@ -76,6 +76,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR var settings = await settingsService.GetAsync(stoppingToken); var ffmpegService = scope.ServiceProvider.GetRequiredService(); await ffmpegService.ResumeRecoveringSessionsAsync(stoppingToken); + var recoveredPendingSessions = await ffmpegService.RecoverStalePendingSessionsAsync(stoppingToken); + if (recoveredPendingSessions > 0) + { + _logger.LogWarning( + "Recovered {SessionCount} stale pending recording sessions.", + recoveredPendingSessions); + } var recoveredOrphanedSessions = await ffmpegService.RecoverOrphanedTerminalSessionTasksAsync(stoppingToken); if (recoveredOrphanedSessions > 0) { diff --git a/src/LiveRecorder.Infrastructure/Services/RecoveryService.cs b/src/LiveRecorder.Infrastructure/Services/RecoveryService.cs index a30d745..5bf4052 100644 --- a/src/LiveRecorder.Infrastructure/Services/RecoveryService.cs +++ b/src/LiveRecorder.Infrastructure/Services/RecoveryService.cs @@ -57,6 +57,7 @@ public sealed class RecoveryService IsAvailable = storage.IsAvailable, HasEnoughSpace = storage.HasEnoughSpace, CheckedPath = storage.CheckedPath, + VolumeRoot = storage.VolumeRoot, TotalBytes = storage.TotalBytes, UsedBytes = storage.UsedBytes, AvailableBytes = storage.AvailableBytes, diff --git a/src/LiveRecorder.Infrastructure/Services/StorageGuardService.cs b/src/LiveRecorder.Infrastructure/Services/StorageGuardService.cs index 1e92d00..fc154f1 100644 --- a/src/LiveRecorder.Infrastructure/Services/StorageGuardService.cs +++ b/src/LiveRecorder.Infrastructure/Services/StorageGuardService.cs @@ -8,10 +8,14 @@ public sealed class StorageGuardService : IStorageGuardService { private const long Megabyte = 1024L * 1024L; private readonly ILogger _logger; + private readonly IStorageCapacityProvider _capacityProvider; - public StorageGuardService(ILogger logger) + public StorageGuardService( + ILogger logger, + IStorageCapacityProvider capacityProvider) { _logger = logger; + _capacityProvider = capacityProvider; } public StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0) => @@ -44,9 +48,9 @@ public sealed class StorageGuardService : IStorageGuardService try { checkedPath = ResolveOutputRoot(settings.OutputRoot ?? string.Empty); - var drive = new DriveInfo(Path.GetPathRoot(checkedPath) ?? checkedPath); - var availableBytes = drive.AvailableFreeSpace; - var totalBytes = drive.TotalSize; + var capacity = _capacityProvider.GetCapacity(checkedPath); + var availableBytes = capacity.AvailableBytes; + var totalBytes = capacity.TotalBytes; var usedBytes = Math.Max(0, totalBytes - availableBytes); var usagePercent = totalBytes > 0 ? (double)usedBytes / totalBytes * 100.0 : 0; var freePercent = totalBytes > 0 ? (double)availableBytes / totalBytes * 100.0 : 0; @@ -81,6 +85,7 @@ public sealed class StorageGuardService : IStorageGuardService return new StorageGuardResult(isEnabled, hasEnoughSpace, checkedPath, availableBytes, requiredBytes, message) { IsAvailable = totalBytes > 0, + VolumeRoot = capacity.VolumeRoot, Tier = tier, TotalBytes = totalBytes, UsedBytes = usedBytes, @@ -108,9 +113,10 @@ public sealed class StorageGuardService : IStorageGuardService private static string ResolveOutputRoot(string outputRoot) { var root = string.IsNullOrWhiteSpace(outputRoot) ? "records" : outputRoot.Trim(); - return Path.IsPathRooted(root) + var resolved = Path.IsPathRooted(root) ? root : Path.GetFullPath(root, AppContext.BaseDirectory); + return Path.GetFullPath(resolved); } private static string FormatBytes(long bytes) diff --git a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs index 2cdce5e..338bfee 100644 --- a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs +++ b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs @@ -57,7 +57,8 @@ public sealed class RecordTasksController : ControllerBase var items = await _recordResultRepository.ListUploadStatusAsync(filter, query, skip, take, cancellationToken); var totalCount = await _recordResultRepository.CountUploadStatusAsync(filter, query, cancellationToken); - var notUploadedCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken); + var pendingUploadMetrics = await _recordResultRepository.GetPendingUploadMetricsAsync(cancellationToken); + var notUploadedCount = pendingUploadMetrics.Count; var failedArtifactCount = await _recordResultRepository.CountFailedArtifactAsync(cancellationToken); var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, null, cancellationToken); var failedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, null, cancellationToken); diff --git a/src/LiveRecorder.WebApi/Program.cs b/src/LiveRecorder.WebApi/Program.cs index 0836354..585caad 100644 --- a/src/LiveRecorder.WebApi/Program.cs +++ b/src/LiveRecorder.WebApi/Program.cs @@ -243,6 +243,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/tests/LiveRecorder.Tests/FfmpegFailureClassificationTests.cs b/tests/LiveRecorder.Tests/FfmpegFailureClassificationTests.cs index 404930d..9edca11 100644 --- a/tests/LiveRecorder.Tests/FfmpegFailureClassificationTests.cs +++ b/tests/LiveRecorder.Tests/FfmpegFailureClassificationTests.cs @@ -272,7 +272,7 @@ public sealed class FfmpegFailureClassificationTests } [Fact] - public void RecoveryContext_LeavesCurlAfterExitWithoutResettingOneShotBudget() + public void RefreshedFlvRecovery_ReEnablesCurlAfterOverlongHeaders() { var curlContext = FfmpegService.InitialRecoveryContext with { @@ -280,18 +280,65 @@ public sealed class FfmpegFailureClassificationTests HasTriedCurlFallback = true }; - var nextContext = FfmpegService.AdvanceRecoveryContext( - curlContext, - curlContext.InputOptionProfile, + var nextContext = FfmpegService.PrepareRefreshedStreamRecoveryContext( + FfmpegService.AdvanceRecoveryContext( + curlContext, + curlContext.InputOptionProfile, + "flv", + "flv", + refreshedStream: true), "flv", - "flv", - refreshedStream: true); + hasOverlongHeadersFailure: true); - Assert.False(nextContext.UseCurlFallback); + Assert.True(nextContext.UseCurlFallback); Assert.True(nextContext.HasTriedCurlFallback); Assert.True(nextContext.HasRetriedWithRefreshedStream); Assert.False(FfmpegService.ShouldFallbackToCurl("flv", true, nextContext.HasTriedCurlFallback)); } + + [Fact] + public void RefreshedNonFlvRecovery_DisablesCurl() + { + var nextContext = FfmpegService.PrepareRefreshedStreamRecoveryContext( + FfmpegService.InitialRecoveryContext with + { + UseCurlFallback = true, + HasTriedCurlFallback = true + }, + "hls", + hasOverlongHeadersFailure: true); + + Assert.False(nextContext.UseCurlFallback); + Assert.True(nextContext.HasTriedCurlFallback); + } + + [Fact] + public void RefreshedFlvRecovery_PreservesCurlRequirementAcrossLaterProcesses() + { + var priorContext = FfmpegService.InitialRecoveryContext with + { + UseCurlFallback = false, + HasTriedCurlFallback = true + }; + + var nextContext = FfmpegService.PrepareRefreshedStreamRecoveryContext( + priorContext, + "flv", + hasOverlongHeadersFailure: priorContext.HasTriedCurlFallback); + + Assert.True(nextContext.UseCurlFallback); + Assert.True(nextContext.HasTriedCurlFallback); + } + + [Theory] + [InlineData(false, true, false)] + [InlineData(false, false, true)] + [InlineData(true, true, true)] + public void FfmpegLinePersistence_SuppressesRecoverableStartupHandshakeNoise( + bool hasOpenedFirstSegment, + bool isStreamHandshakeFailure, + bool expected) => + Assert.Equal(expected, FfmpegService.ShouldPersistFfmpegLine(hasOpenedFirstSegment, isStreamHandshakeFailure)); [Fact] public void RecoveryContext_PreservesBudgetAcrossHlsFallbackAndReachesTimestampTranscode() { diff --git a/tests/LiveRecorder.Tests/OpenListUploadTests.cs b/tests/LiveRecorder.Tests/OpenListUploadTests.cs index 1377a69..81161a4 100644 --- a/tests/LiveRecorder.Tests/OpenListUploadTests.cs +++ b/tests/LiveRecorder.Tests/OpenListUploadTests.cs @@ -615,6 +615,62 @@ public sealed class OpenListUploadTests Assert.Equal(1, await repository.CountFailedArtifactAsync()); } + [Fact] + public async Task PendingUploadMetrics_IncludeValidVisibleArtifactWithActualFileSize() + { + await using var fixture = await QueueFixture.CreateAsync(); + var result = await fixture.Context.RecordResults.AsNoTracking().SingleAsync(); + var repository = new RecordResultRepository(fixture.Context); + + var metrics = await repository.GetPendingUploadMetricsAsync(); + + Assert.Equal(1, metrics.Count); + Assert.Equal(new FileInfo(result.FilePath).Length, metrics.TotalBytes); + } + + [Fact] + public async Task PendingUploadMetrics_ExcludeHiddenMergedSource() + { + await using var fixture = await QueueFixture.CreateAsync(); + var task = await fixture.Context.RecordTasks.SingleAsync(); + task.MarkMergedSource(Guid.NewGuid(), DateTimeOffset.UtcNow); + await fixture.Context.SaveChangesAsync(); + var repository = new RecordResultRepository(fixture.Context); + + var metrics = await repository.GetPendingUploadMetricsAsync(); + + Assert.Equal(0, metrics.Count); + Assert.Equal(0, metrics.TotalBytes); + } + + [Fact] + public async Task PendingUploadMetrics_ExcludeMissingLocalFile() + { + await using var fixture = await QueueFixture.CreateAsync(); + var result = await fixture.Context.RecordResults.AsNoTracking().SingleAsync(); + File.Delete(result.FilePath); + var repository = new RecordResultRepository(fixture.Context); + + var metrics = await repository.GetPendingUploadMetricsAsync(); + + Assert.Equal(0, metrics.Count); + Assert.Equal(0, metrics.TotalBytes); + } + + [Fact] + public async Task PendingUploadMetrics_ExcludeEmptyLocalFile() + { + await using var fixture = await QueueFixture.CreateAsync(); + var result = await fixture.Context.RecordResults.AsNoTracking().SingleAsync(); + File.WriteAllBytes(result.FilePath, []); + var repository = new RecordResultRepository(fixture.Context); + + var metrics = await repository.GetPendingUploadMetricsAsync(); + + Assert.Equal(0, metrics.Count); + Assert.Equal(0, metrics.TotalBytes); + } + private static OpenListClient CreateClient(HttpMessageHandler handler) => new(new StubHttpClientFactory(handler)); diff --git a/tests/LiveRecorder.Tests/RecordingContinuityTests.cs b/tests/LiveRecorder.Tests/RecordingContinuityTests.cs index 04fd48a..51a7110 100644 --- a/tests/LiveRecorder.Tests/RecordingContinuityTests.cs +++ b/tests/LiveRecorder.Tests/RecordingContinuityTests.cs @@ -20,8 +20,11 @@ public sealed class RecordingContinuityTests [InlineData(3, 30)] [InlineData(4, 60)] [InlineData(5, 120)] - [InlineData(50, 120)] - public void RuntimeRecoveryBackoff_IsBoundedAtTwoMinutes(int attempt, int seconds) => + [InlineData(6, 300)] + [InlineData(7, 600)] + [InlineData(8, 900)] + [InlineData(50, 900)] + public void RuntimeRecoveryBackoff_IsBoundedAtFifteenMinutes(int attempt, int seconds) => Assert.Equal(TimeSpan.FromSeconds(seconds), FfmpegService.GetRuntimeRecoveryDelay(attempt)); [Theory] diff --git a/tests/LiveRecorder.Tests/StalePendingRecoveryTests.cs b/tests/LiveRecorder.Tests/StalePendingRecoveryTests.cs new file mode 100644 index 0000000..00c7827 --- /dev/null +++ b/tests/LiveRecorder.Tests/StalePendingRecoveryTests.cs @@ -0,0 +1,120 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Models.Logs; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using LiveRecorder.Infrastructure.Persistence; +using LiveRecorder.Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace LiveRecorder.Tests; + +public sealed class StalePendingRecoveryTests +{ + [Fact] + public async Task StalePendingSessionWithoutMedia_IsFailedAndRetainedForAudit() + { + var services = new ServiceCollection(); + var databaseRoot = new InMemoryDatabaseRoot(); + var databaseName = $"stale-pending-{Guid.NewGuid():N}"; + services.AddDbContext(options => + options.UseInMemoryDatabase(databaseName, databaseRoot)); + var logService = new CapturingSystemLogService(); + services.AddSingleton(logService); + await using var provider = services.BuildServiceProvider(); + + var createdAt = DateTimeOffset.UtcNow.AddMinutes(-11); + Guid sessionId; + Guid taskId; + await using (var scope = provider.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var liveRoom = new LiveRoom( + LivePlatformType.Douyin, + "https://live.example/room", + "room-1", + "https://live.example/room", + createdAt); + var session = new RecordSession( + liveRoom.Id, + "origin", + RecordOutputFormat.Mp4, + RecordSaveMode.Segmented, + createdAt); + var task = new RecordTask( + liveRoom.Id, + session.Id, + 1, + "origin", + RecordOutputFormat.Mp4, + createdAt); + sessionId = session.Id; + taskId = task.Id; + dbContext.AddRange(liveRoom, session, task); + await dbContext.SaveChangesAsync(); + } + + var service = new FfmpegService( + provider.GetRequiredService(), + null!, + null!, + NullLogger.Instance); + + await using (var preconditionScope = provider.CreateAsyncScope()) + { + var preconditionContext = preconditionScope.ServiceProvider.GetRequiredService(); + var persisted = await preconditionContext.RecordSessions.SingleAsync(); + Assert.Equal(RecordSessionStatus.Pending, persisted.Status); + Assert.True( + persisted.UpdatedAt <= DateTimeOffset.UtcNow.AddMinutes(-10), + $"updatedAt={persisted.UpdatedAt:O}"); + } + + var recovered = await service.RecoverStalePendingSessionsAsync(); + + Assert.Equal(1, recovered); + await using var verificationScope = provider.CreateAsyncScope(); + var verificationContext = verificationScope.ServiceProvider.GetRequiredService(); + var persistedSession = await verificationContext.RecordSessions.SingleAsync(item => item.Id == sessionId); + var persistedTask = await verificationContext.RecordTasks + .Include(item => item.Result) + .SingleAsync(item => item.Id == taskId); + Assert.Equal(RecordSessionStatus.Failed, persistedSession.Status); + Assert.Equal(RecordTaskStatus.Failed, persistedTask.Status); + Assert.Contains("interrupted before stream initialization", persistedSession.ErrorMessage); + Assert.Null(persistedTask.Result); + Assert.Single(logService.Entries); + Assert.Equal(SystemLogLevel.Warning, logService.Entries[0].Level); + } + + private sealed class CapturingSystemLogService : ISystemLogService + { + public List<(SystemLogLevel Level, string Message)> Entries { get; } = []; + + public Task WriteAsync( + SystemLogLevel level, + string category, + string message, + string? detail = null, + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + CancellationToken cancellationToken = default) + { + Entries.Add((level, message)); + return Task.CompletedTask; + } + + public Task> ListAsync( + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + SystemLogLevel? level = null, + string? content = null, + int take = 200, + CancellationToken cancellationToken = default) => + Task.FromResult>([]); + } +} diff --git a/tests/LiveRecorder.Tests/StorageGuardServiceTests.cs b/tests/LiveRecorder.Tests/StorageGuardServiceTests.cs index 16aa6ea..8c9b4f7 100644 --- a/tests/LiveRecorder.Tests/StorageGuardServiceTests.cs +++ b/tests/LiveRecorder.Tests/StorageGuardServiceTests.cs @@ -7,7 +7,26 @@ namespace LiveRecorder.Tests; public sealed class StorageGuardServiceTests { - private readonly StorageGuardService _service = new(NullLogger.Instance); + private readonly StorageGuardService _service = new( + NullLogger.Instance, + new DriveInfoStorageCapacityProvider()); + + [Fact] + public void CapacityProvider_SelectsLongestContainingMountRoot() + { + var selected = DriveInfoStorageCapacityProvider.SelectBestVolume( + "/vol00/live-recorder/Record", + [ + new StorageVolumeCapacity("/", 100, 10), + new StorageVolumeCapacity("/vol00", 200, 20), + new StorageVolumeCapacity("/vol00/live-recorder", 300, 30), + new StorageVolumeCapacity("/vol0", 400, 40) + ]); + + Assert.NotNull(selected); + Assert.Equal("/vol00/live-recorder", selected.VolumeRoot); + Assert.Equal(300, selected.TotalBytes); + } [Fact] public void DisabledProtection_StillReportsActualCapacityWithoutBlockingRecording()