feat: improve polling recovery and session cleanup
This commit is contained in:
@@ -15,4 +15,8 @@ public static class AutoStartDecisionCodes
|
||||
public const string SkippedDebounce = "skipped_debounce";
|
||||
|
||||
public const string FailedStartup = "failed_startup";
|
||||
|
||||
public const string PollFailedTransient = "poll_failed_transient";
|
||||
|
||||
public const string PollFailed = "poll_failed";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace LiveRecorder.Application.Common;
|
||||
|
||||
public static class ChinaTime
|
||||
{
|
||||
private static readonly Lazy<TimeZoneInfo> TimeZoneInfoLazy = new(ResolveTimeZoneInfo);
|
||||
|
||||
public static TimeZoneInfo Zone => TimeZoneInfoLazy.Value;
|
||||
|
||||
public static DateTimeOffset ToBeijingTime(DateTimeOffset value)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTime(value, Zone);
|
||||
return new DateTimeOffset(local.DateTime, Zone.GetUtcOffset(local.DateTime));
|
||||
}
|
||||
|
||||
private static TimeZoneInfo ResolveTimeZoneInfo()
|
||||
{
|
||||
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (InvalidTimeZoneException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return TimeZoneInfo.CreateCustomTimeZone(
|
||||
"UTC+08",
|
||||
TimeSpan.FromHours(8),
|
||||
"UTC+08",
|
||||
"UTC+08");
|
||||
}
|
||||
}
|
||||
@@ -129,3 +129,8 @@ public sealed class DeleteRecordSessionsRequest
|
||||
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeleteMissingFileRecordSessionsRequest
|
||||
{
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ public sealed class SystemSettingsDto
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Title:</strong> {{title}}</li>
|
||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
</div>
|
||||
@@ -199,7 +199,7 @@ public sealed class SystemSettingsDto
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
||||
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||
</ul>
|
||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||
</div>
|
||||
@@ -362,7 +362,7 @@ public sealed class UpdateSystemSettingsRequest
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Title:</strong> {{title}}</li>
|
||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
</div>
|
||||
@@ -380,7 +380,7 @@ public sealed class UpdateSystemSettingsRequest
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
||||
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||
</ul>
|
||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||
</div>
|
||||
@@ -435,7 +435,7 @@ public sealed class SendTestEmailRequest
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Title:</strong> {{title}}</li>
|
||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
</div>
|
||||
@@ -453,7 +453,7 @@ public sealed class SendTestEmailRequest
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
||||
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||
</ul>
|
||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||
</div>
|
||||
|
||||
@@ -774,6 +774,7 @@ public sealed class RecordService
|
||||
RecordSaveMode saveMode,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var localNow = ChinaTime.ToBeijingTime(now);
|
||||
var safeRoomId = SanitizeFileName(roomId, "room");
|
||||
var effectiveFileNameTemplate = EnsureSegmentSuffixTemplate(outputFileNameTemplate, saveMode);
|
||||
var baseFileStem = BuildFileNameStem(
|
||||
@@ -782,7 +783,7 @@ public sealed class RecordService
|
||||
safeRoomId,
|
||||
anchorName,
|
||||
title,
|
||||
now,
|
||||
localNow,
|
||||
segmentSuffix: string.Empty);
|
||||
var directoryPath = BuildDirectoryPath(
|
||||
outputDirectoryTemplate,
|
||||
@@ -790,7 +791,7 @@ public sealed class RecordService
|
||||
safeRoomId,
|
||||
anchorName,
|
||||
title,
|
||||
now,
|
||||
localNow,
|
||||
baseFileStem);
|
||||
var fileNameStem = BuildFileNameStem(
|
||||
effectiveFileNameTemplate,
|
||||
@@ -798,7 +799,7 @@ public sealed class RecordService
|
||||
safeRoomId,
|
||||
anchorName,
|
||||
title,
|
||||
now,
|
||||
localNow,
|
||||
saveMode == RecordSaveMode.Segmented ? "_%05d" : string.Empty);
|
||||
var folder = Path.Combine(outputRoot, directoryPath);
|
||||
var extension = outputFormat == RecordOutputFormat.Ts ? "ts" : "mp4";
|
||||
|
||||
@@ -253,6 +253,36 @@ public sealed class RecordSessionService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DeleteCompletedRecordTasksResultDto> DeleteMissingFilesAsync(
|
||||
DeleteMissingFileRecordSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
|
||||
await ReconcileActiveSessionsAsync(null, cancellationToken);
|
||||
|
||||
var sessions = await _recordSessionRepository.ListAsync(null, cancellationToken);
|
||||
var missingFileSessionIds = sessions
|
||||
.Where(CanDeleteMissingFileSession)
|
||||
.Select(static item => item.Id)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (missingFileSessionIds.Length == 0)
|
||||
{
|
||||
return RecordService.CreateEmptyDeleteResult();
|
||||
}
|
||||
|
||||
return await DeleteAsync(
|
||||
new DeleteRecordSessionsRequest
|
||||
{
|
||||
SessionIds = missingFileSessionIds,
|
||||
DeleteFiles = request.DeleteFiles
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
|
||||
{
|
||||
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
|
||||
@@ -271,6 +301,39 @@ public sealed class RecordSessionService
|
||||
private static bool IsActiveStatus(RecordSessionStatus status) =>
|
||||
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||
|
||||
private static bool CanDeleteMissingFileSession(RecordSession session)
|
||||
{
|
||||
if (IsActiveStatus(session.Status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.RecordTasks.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return session.RecordTasks.All(static task => !HasExistingVideoFile(task));
|
||||
}
|
||||
|
||||
private static bool HasExistingVideoFile(RecordTask task)
|
||||
{
|
||||
var candidatePath = !string.IsNullOrWhiteSpace(task.Result?.FilePath)
|
||||
? task.Result!.FilePath
|
||||
: task.OutputFilePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidatePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var resolvedPath = Path.IsPathRooted(candidatePath)
|
||||
? candidatePath
|
||||
: Path.GetFullPath(candidatePath, AppContext.BaseDirectory);
|
||||
|
||||
return File.Exists(resolvedPath);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SystemLogEntry>> ListRelatedLogsAsync(
|
||||
Guid recordSessionId,
|
||||
IReadOnlyCollection<Guid> taskIds,
|
||||
|
||||
@@ -225,7 +225,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
NotifyOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyOnLiveStartedKey, "true"), out var notifyOnLiveStarted) && notifyOnLiveStarted,
|
||||
NotifyOnException = bool.TryParse(GetValue(lookup, NotifyOnExceptionKey, "true"), out var notifyOnException) && notifyOnException,
|
||||
EmailLiveStartedSubjectTemplate = GetValue(lookup, EmailLiveStartedSubjectTemplateKey, "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"),
|
||||
EmailLiveStartedBodyTemplateHtml = GetValue(
|
||||
EmailLiveStartedBodyTemplateHtml = NormalizeBeijingTimeTemplate(GetValue(
|
||||
lookup,
|
||||
EmailLiveStartedBodyTemplateHtmlKey,
|
||||
"""
|
||||
@@ -237,13 +237,13 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Title:</strong> {{title}}</li>
|
||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
</div>
|
||||
"""),
|
||||
""")),
|
||||
EmailExceptionSubjectTemplate = GetValue(lookup, EmailExceptionSubjectTemplateKey, "[{{appName}}] Exception: {{source}}"),
|
||||
EmailExceptionBodyTemplateHtml = GetValue(
|
||||
EmailExceptionBodyTemplateHtml = NormalizeBeijingTimeTemplate(GetValue(
|
||||
lookup,
|
||||
EmailExceptionBodyTemplateHtmlKey,
|
||||
"""
|
||||
@@ -256,11 +256,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
||||
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
|
||||
</ul>
|
||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||
</div>
|
||||
"""),
|
||||
""")),
|
||||
EnableWebhookNotification = bool.TryParse(GetValue(lookup, EnableWebhookNotificationKey, "false"), out var enableWebhookNotification) && enableWebhookNotification,
|
||||
WebhookUrl = GetValue(lookup, WebhookUrlKey, string.Empty),
|
||||
WebhookHeaders = GetValue(lookup, WebhookHeadersKey, string.Empty),
|
||||
@@ -437,6 +437,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
? EventScriptSourceModes.Inline
|
||||
: EventScriptSourceModes.Path;
|
||||
|
||||
private static string NormalizeBeijingTimeTemplate(string value) =>
|
||||
value
|
||||
.Replace("Detected At (UTC)", "Detected At (Beijing Time)", StringComparison.Ordinal)
|
||||
.Replace("Occurred At (UTC)", "Occurred At (Beijing Time)", StringComparison.Ordinal);
|
||||
|
||||
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user