feat: add live room metadata, uploads, proxies and backups

This commit is contained in:
2026-04-26 17:35:51 +08:00
parent 85f24f8a84
commit d2b2f714e0
38 changed files with 2371 additions and 116 deletions
@@ -25,11 +25,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
private static readonly TimeSpan OfflineForcedStopTimeout = TimeSpan.FromSeconds(8);
private static readonly TimeSpan ExceptionEmailCooldown = TimeSpan.FromHours(6);
private static readonly TimeSpan PollDispatchSpacing = TimeSpan.FromMilliseconds(400);
private static readonly TimeSpan MinimumIdleDelay = TimeSpan.FromSeconds(2);
private const int MaxConcurrentLiveRoomPolls = 2;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<LiveRoomPollingBackgroundService> _logger;
private readonly ConcurrentDictionary<string, DateTimeOffset> _exceptionEmailSentAt = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _nextPollDueAt = new();
public LiveRoomPollingBackgroundService(
IServiceScopeFactory serviceScopeFactory,
@@ -43,15 +45,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
{
while (!stoppingToken.IsCancellationRequested)
{
var delaySeconds = 60;
var delay = TimeSpan.FromSeconds(60);
try
{
using var scope = _serviceScopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(stoppingToken);
delaySeconds = settings.PollingIntervalSeconds;
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
var storageGuardService = scope.ServiceProvider.GetRequiredService<IStorageGuardService>();
if (storageGuardService.CheckCanStartOrResume(settings).HasEnoughSpace)
@@ -61,21 +61,64 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
if (!settings.EnableBackgroundPolling)
{
await DelayAsync(delaySeconds, stoppingToken);
await DelayAsync(TimeSpan.FromSeconds(Math.Clamp(settings.PollingIntervalSeconds, 10, 3600)), stoppingToken);
continue;
}
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var liveRoomIds = (await dbContext.LiveRooms
var now = DateTimeOffset.UtcNow;
var candidates = (await dbContext.LiveRooms
.AsNoTracking()
.Where(static item => item.IsEnabled)
.Select(static item => new { item.Id, item.UpdatedAt })
.Select(static item => new
{
item.Id,
item.UpdatedAt,
item.LastCheckedAt,
item.IsPriority,
item.PollingIntervalSecondsOverride
})
.ToListAsync(stoppingToken))
.OrderBy(static item => item.UpdatedAt)
.Select(static item => item.Id)
.ToList();
await PollLiveRoomsAsync(liveRoomIds, settings, stoppingToken);
var pollCandidates = candidates
.Select(item =>
{
var intervalSeconds = GetEffectivePollingIntervalSeconds(settings.PollingIntervalSeconds, item.PollingIntervalSecondsOverride);
var baselineDueAt = item.LastCheckedAt?.AddSeconds(intervalSeconds) ?? DateTimeOffset.MinValue;
var dueAt = _nextPollDueAt.TryGetValue(item.Id, out var scheduledDueAt) && scheduledDueAt > baselineDueAt
? scheduledDueAt
: baselineDueAt;
return new PollCandidate(item.Id, dueAt, item.IsPriority, item.UpdatedAt, intervalSeconds);
})
.ToList();
if (pollCandidates.Count == 0)
{
delay = TimeSpan.FromSeconds(Math.Clamp(settings.PollingIntervalSeconds, 10, 3600));
}
else
{
var dueCandidates = pollCandidates
.Where(item => item.DueAt <= now)
.OrderByDescending(static item => item.IsPriority)
.ThenBy(static item => item.DueAt)
.ThenBy(static item => item.UpdatedAt)
.ToList();
if (dueCandidates.Count == 0)
{
var nextDueAt = pollCandidates.Min(static item => item.DueAt);
delay = nextDueAt <= now
? MinimumIdleDelay
: ClampDelay(nextDueAt - now);
}
else
{
await PollLiveRoomsAsync(dueCandidates, settings, stoppingToken);
delay = MinimumIdleDelay;
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -117,35 +160,52 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
}
await DelayAsync(delaySeconds, stoppingToken);
await DelayAsync(delay, stoppingToken);
}
}
private static Task DelayAsync(int delaySeconds, CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delaySeconds, 10, 3600)), cancellationToken);
private static Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
Task.Delay(ClampDelay(delay), cancellationToken);
private static TimeSpan ClampDelay(TimeSpan delay)
{
if (delay <= TimeSpan.Zero)
{
return MinimumIdleDelay;
}
return delay < MinimumIdleDelay
? MinimumIdleDelay
: delay > TimeSpan.FromHours(1)
? TimeSpan.FromHours(1)
: delay;
}
private static int GetEffectivePollingIntervalSeconds(int globalIntervalSeconds, int? overrideIntervalSeconds) =>
Math.Clamp(overrideIntervalSeconds ?? globalIntervalSeconds, 10, 3600);
private async Task PollLiveRoomsAsync(
IReadOnlyList<Guid> liveRoomIds,
IReadOnlyList<PollCandidate> liveRooms,
SystemSettingsDto settings,
CancellationToken cancellationToken)
{
if (liveRoomIds.Count == 0)
if (liveRooms.Count == 0)
{
return;
}
using var semaphore = new SemaphoreSlim(Math.Min(MaxConcurrentLiveRoomPolls, liveRoomIds.Count));
var tasks = new List<Task>(liveRoomIds.Count);
using var semaphore = new SemaphoreSlim(Math.Min(MaxConcurrentLiveRoomPolls, liveRooms.Count));
var tasks = new List<Task>(liveRooms.Count);
for (var index = 0; index < liveRoomIds.Count; index++)
for (var index = 0; index < liveRooms.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
await semaphore.WaitAsync(cancellationToken);
var liveRoomId = liveRoomIds[index];
tasks.Add(PollLiveRoomWithReleaseAsync(liveRoomId, settings, semaphore, cancellationToken));
var liveRoom = liveRooms[index];
tasks.Add(PollLiveRoomWithReleaseAsync(liveRoom, settings, semaphore, cancellationToken));
if (index < liveRoomIds.Count - 1)
if (index < liveRooms.Count - 1)
{
await Task.Delay(PollDispatchSpacing, cancellationToken);
}
@@ -155,14 +215,14 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
private async Task PollLiveRoomWithReleaseAsync(
Guid liveRoomId,
PollCandidate liveRoom,
SystemSettingsDto settings,
SemaphoreSlim semaphore,
CancellationToken cancellationToken)
{
try
{
await PollLiveRoomAsync(liveRoomId, settings, cancellationToken);
await PollLiveRoomAsync(liveRoom.LiveRoomId, liveRoom.IntervalSeconds, settings, cancellationToken);
}
finally
{
@@ -170,7 +230,11 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
}
private async Task PollLiveRoomAsync(Guid liveRoomId, SystemSettingsDto settings, CancellationToken cancellationToken)
private async Task PollLiveRoomAsync(
Guid liveRoomId,
int intervalSeconds,
SystemSettingsDto settings,
CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
@@ -186,6 +250,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
var liveRoom = await dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
if (liveRoom is null || !liveRoom.IsEnabled)
{
_nextPollDueAt.TryRemove(liveRoomId, out _);
return;
}
@@ -341,6 +406,10 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
cancellationToken: cancellationToken);
}
}
finally
{
_nextPollDueAt[liveRoomId] = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(intervalSeconds, 10, 3600));
}
}
private static async Task CompleteActiveSessionsForOfflineRoomAsync(
@@ -642,4 +711,11 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
var trimmed = value.Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private sealed record PollCandidate(
Guid LiveRoomId,
DateTimeOffset DueAt,
bool IsPriority,
DateTimeOffset UpdatedAt,
int IntervalSeconds);
}