feat: add live room metadata, uploads, proxies and backups
This commit is contained in:
@@ -55,6 +55,8 @@ public interface IRecordSessionRepository
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<Guid>> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -12,5 +12,7 @@ public static class AutoStartDecisionCodes
|
||||
|
||||
public const string SkippedOffline = "skipped_offline";
|
||||
|
||||
public const string SkippedDebounce = "skipped_debounce";
|
||||
|
||||
public const string FailedStartup = "failed_startup";
|
||||
}
|
||||
|
||||
@@ -108,6 +108,18 @@ public sealed class LiveRoomDto
|
||||
|
||||
public string? CoverUrl { get; init; }
|
||||
|
||||
public string? Remark { get; init; }
|
||||
|
||||
public bool IsPinned { get; init; }
|
||||
|
||||
public string? Alias { get; init; }
|
||||
|
||||
public bool IsPriority { get; init; }
|
||||
|
||||
public int? PollingIntervalSecondsOverride { get; init; }
|
||||
|
||||
public required string OriginalLiveRoomUrl { get; init; }
|
||||
|
||||
public required LiveRoomSettingsOverridesDto Overrides { get; init; }
|
||||
|
||||
public required LiveRoomEffectiveSettingsDto EffectiveSettings { get; init; }
|
||||
@@ -116,6 +128,8 @@ public sealed class LiveRoomDto
|
||||
|
||||
public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; }
|
||||
|
||||
public required LiveRoomCurrentRecordingState CurrentRecordingState { get; init; }
|
||||
|
||||
public string? LastAutoStartDecisionCode { get; init; }
|
||||
|
||||
public string? LastAutoStartDecisionSummary { get; init; }
|
||||
@@ -131,6 +145,19 @@ public sealed class LiveRoomDto
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UpdateLiveRoomMetadataRequest
|
||||
{
|
||||
public string? Remark { get; set; }
|
||||
|
||||
public bool IsPinned { get; set; }
|
||||
|
||||
public string? Alias { get; set; }
|
||||
|
||||
public bool IsPriority { get; set; }
|
||||
|
||||
public int? PollingIntervalSecondsOverride { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SetLiveRoomEnabledRequest
|
||||
{
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
@@ -30,6 +30,20 @@ public sealed class RecordResultDto
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public required RecordArtifactUploadStatus UploadStatus { get; init; }
|
||||
|
||||
public string? LastUploadProvider { get; init; }
|
||||
|
||||
public string? RemoteVideoPath { get; init; }
|
||||
|
||||
public string? RemoteDanmakuPath { get; init; }
|
||||
|
||||
public DateTimeOffset? LastUploadedAt { get; init; }
|
||||
|
||||
public string? UploadErrorMessage { get; init; }
|
||||
|
||||
public bool DeletedLocalFilesAfterUpload { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -113,3 +127,31 @@ public sealed class RecordPreviewTicketDto
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecordArtifactUploadItemResultDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
|
||||
public bool Success { get; init; }
|
||||
|
||||
public required string Message { get; init; }
|
||||
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? RemoteVideoPath { get; init; }
|
||||
|
||||
public string? RemoteDanmakuPath { get; init; }
|
||||
|
||||
public bool DeletedLocalFilesAfterUpload { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecordArtifactUploadBatchResultDto
|
||||
{
|
||||
public int RequestedCount { get; init; }
|
||||
|
||||
public int SuccessCount { get; init; }
|
||||
|
||||
public int FailedCount { get; init; }
|
||||
|
||||
public required IReadOnlyList<RecordArtifactUploadItemResultDto> Items { get; init; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,41 @@ public static class EventScriptSourceModes
|
||||
public const string Inline = "inline";
|
||||
}
|
||||
|
||||
public sealed class PlatformProxySettingsDto
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string ProxyUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class WebDavUploadSettingsDto
|
||||
{
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
public string BasePath { get; set; } = string.Empty;
|
||||
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class S3UploadSettingsDto
|
||||
{
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
public string Bucket { get; set; } = string.Empty;
|
||||
|
||||
public string Region { get; set; } = string.Empty;
|
||||
|
||||
public string AccessKey { get; set; } = string.Empty;
|
||||
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
public string Prefix { get; set; } = string.Empty;
|
||||
|
||||
public bool ForcePathStyle { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SystemSettingsDto
|
||||
{
|
||||
public string FfmpegPath { get; set; } = "ffmpeg";
|
||||
@@ -59,6 +94,26 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public int PollingIntervalSeconds { get; set; } = 60;
|
||||
|
||||
public bool UseAliasForStorage { get; set; }
|
||||
|
||||
public bool EnableFileUpload { get; set; }
|
||||
|
||||
public bool EnableAutoUpload { get; set; }
|
||||
|
||||
public bool DeleteLocalFilesAfterUpload { get; set; }
|
||||
|
||||
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
|
||||
|
||||
public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
|
||||
|
||||
public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
|
||||
|
||||
public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
|
||||
|
||||
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
|
||||
|
||||
public S3UploadSettingsDto S3Upload { get; set; } = new();
|
||||
|
||||
public bool EnableEventScripts { get; set; } = false;
|
||||
|
||||
public string LiveStartedScriptMode { get; set; } = EventScriptSourceModes.Path;
|
||||
@@ -214,6 +269,26 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public int PollingIntervalSeconds { get; set; } = 60;
|
||||
|
||||
public bool UseAliasForStorage { get; set; }
|
||||
|
||||
public bool EnableFileUpload { get; set; }
|
||||
|
||||
public bool EnableAutoUpload { get; set; }
|
||||
|
||||
public bool DeleteLocalFilesAfterUpload { get; set; }
|
||||
|
||||
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
|
||||
|
||||
public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
|
||||
|
||||
public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
|
||||
|
||||
public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
|
||||
|
||||
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
|
||||
|
||||
public S3UploadSettingsDto S3Upload { get; set; } = new();
|
||||
|
||||
public bool EnableEventScripts { get; set; } = false;
|
||||
|
||||
public string LiveStartedScriptMode { get; set; } = EventScriptSourceModes.Path;
|
||||
@@ -431,3 +506,8 @@ public sealed class RetentionCleanupResultDto
|
||||
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ImportSystemSettingsRequest
|
||||
{
|
||||
public SystemSettingsDto? Settings { get; set; }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace LiveRecorder.Application.Services;
|
||||
public sealed class LiveRoomService
|
||||
{
|
||||
private readonly ILiveRoomRepository _liveRoomRepository;
|
||||
private readonly IRecordSessionRepository _recordSessionRepository;
|
||||
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
|
||||
private readonly LiveRoomStatusService _liveRoomStatusService;
|
||||
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
|
||||
@@ -25,6 +26,7 @@ public sealed class LiveRoomService
|
||||
|
||||
public LiveRoomService(
|
||||
ILiveRoomRepository liveRoomRepository,
|
||||
IRecordSessionRepository recordSessionRepository,
|
||||
ILivePlatformAdapterFactory livePlatformAdapterFactory,
|
||||
LiveRoomStatusService liveRoomStatusService,
|
||||
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
|
||||
@@ -35,6 +37,7 @@ public sealed class LiveRoomService
|
||||
ISystemLogService systemLogService)
|
||||
{
|
||||
_liveRoomRepository = liveRoomRepository;
|
||||
_recordSessionRepository = recordSessionRepository;
|
||||
_livePlatformAdapterFactory = livePlatformAdapterFactory;
|
||||
_liveRoomStatusService = liveRoomStatusService;
|
||||
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
|
||||
@@ -49,9 +52,13 @@ public sealed class LiveRoomService
|
||||
{
|
||||
var rooms = await _liveRoomRepository.ListAsync(cancellationToken);
|
||||
var effectiveSettings = await BuildEffectiveSettingsLookupAsync(rooms, cancellationToken);
|
||||
var activeLiveRoomIds = await _recordSessionRepository.ListActiveLiveRoomIdsAsync(cancellationToken);
|
||||
return rooms
|
||||
.OrderByDescending(static item => item.UpdatedAt)
|
||||
.Select(item => Map(item, effectiveSettings[item.Id]))
|
||||
.OrderByDescending(static item => item.IsPinned)
|
||||
.ThenByDescending(static item => item.IsPriority)
|
||||
.ThenByDescending(item => GetCurrentRecordingState(item, activeLiveRoomIds.Contains(item.Id)))
|
||||
.ThenByDescending(static item => item.UpdatedAt)
|
||||
.Select(item => Map(item, effectiveSettings[item.Id], activeLiveRoomIds.Contains(item.Id)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -64,7 +71,8 @@ public sealed class LiveRoomService
|
||||
}
|
||||
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
|
||||
return Map(room, effectiveSettings);
|
||||
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
|
||||
return Map(room, effectiveSettings, hasActiveSession);
|
||||
}
|
||||
|
||||
public async Task<LiveRoomDto> CreateAsync(CreateLiveRoomRequest request, CancellationToken cancellationToken = default)
|
||||
@@ -77,7 +85,8 @@ public sealed class LiveRoomService
|
||||
request.AnchorName,
|
||||
cancellationToken);
|
||||
|
||||
return Map(liveRoom, effectiveSettings);
|
||||
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken) is not null;
|
||||
return Map(liveRoom, effectiveSettings, hasActiveSession);
|
||||
}
|
||||
|
||||
public async Task<ImportLiveRoomsResultDto> ImportAsync(
|
||||
@@ -130,7 +139,10 @@ public sealed class LiveRoomService
|
||||
AnchorName = anchorName,
|
||||
Success = true,
|
||||
Created = created,
|
||||
LiveRoom = Map(liveRoom, effectiveSettings)
|
||||
LiveRoom = Map(
|
||||
liveRoom,
|
||||
effectiveSettings,
|
||||
await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken) is not null)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -247,7 +259,8 @@ public sealed class LiveRoomService
|
||||
await TryAutoStartRecordingAsync(room, liveStatus, cancellationToken);
|
||||
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
|
||||
return Map(room, effectiveSettings);
|
||||
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
|
||||
return Map(room, effectiveSettings, hasActiveSession);
|
||||
}
|
||||
|
||||
public async Task<LiveRoomDto> SetEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default)
|
||||
@@ -268,7 +281,8 @@ public sealed class LiveRoomService
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
|
||||
return Map(room, effectiveSettings);
|
||||
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
|
||||
return Map(room, effectiveSettings, hasActiveSession);
|
||||
}
|
||||
|
||||
public async Task<BatchLiveRoomsResultDto> SetEnabledBatchAsync(
|
||||
@@ -340,7 +354,40 @@ public sealed class LiveRoomService
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
|
||||
return Map(room, effectiveSettings);
|
||||
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
|
||||
return Map(room, effectiveSettings, hasActiveSession);
|
||||
}
|
||||
|
||||
public async Task<LiveRoomDto> UpdateMetadataAsync(
|
||||
Guid id,
|
||||
UpdateLiveRoomMetadataRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken)
|
||||
?? throw new KeyNotFoundException("Live room was not found.");
|
||||
|
||||
room.UpdateManagementMetadata(
|
||||
request.Remark,
|
||||
request.IsPinned,
|
||||
request.Alias,
|
||||
request.IsPriority,
|
||||
ClampNullable(request.PollingIntervalSecondsOverride, 10, 3600),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"LiveRoom",
|
||||
$"Live room metadata updated for room {room.RoomId}.",
|
||||
liveRoomId: room.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
|
||||
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
|
||||
return Map(room, effectiveSettings, hasActiveSession);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -470,7 +517,7 @@ public sealed class LiveRoomService
|
||||
}
|
||||
}
|
||||
|
||||
private LiveRoomDto Map(LiveRoom room, RecordingExecutionSettings effectiveSettings) => new()
|
||||
private LiveRoomDto Map(LiveRoom room, RecordingExecutionSettings effectiveSettings, bool hasActiveSession) => new()
|
||||
{
|
||||
Id = room.Id,
|
||||
Platform = room.Platform,
|
||||
@@ -483,10 +530,17 @@ public sealed class LiveRoomService
|
||||
AnchorId = room.AnchorId,
|
||||
AvatarUrl = room.AvatarUrl,
|
||||
CoverUrl = room.CoverUrl,
|
||||
Remark = room.Remark,
|
||||
IsPinned = room.IsPinned,
|
||||
Alias = room.Alias,
|
||||
IsPriority = room.IsPriority,
|
||||
PollingIntervalSecondsOverride = room.PollingIntervalSecondsOverride,
|
||||
OriginalLiveRoomUrl = string.IsNullOrWhiteSpace(room.NormalizedUrl) ? room.SourceUrl : room.NormalizedUrl,
|
||||
Overrides = _liveRoomRecordingSettingsResolver.BuildOverridesDto(room),
|
||||
EffectiveSettings = _liveRoomRecordingSettingsResolver.BuildEffectiveDto(effectiveSettings),
|
||||
IsEnabled = room.IsEnabled,
|
||||
AvailabilityStatus = room.AvailabilityStatus,
|
||||
CurrentRecordingState = GetCurrentRecordingState(room, hasActiveSession),
|
||||
LastAutoStartDecisionCode = room.LastAutoStartDecisionCode,
|
||||
LastAutoStartDecisionSummary = room.LastAutoStartDecisionSummary,
|
||||
LastAutoStartDecisionDetail = room.LastAutoStartDecisionDetail,
|
||||
@@ -496,6 +550,18 @@ public sealed class LiveRoomService
|
||||
UpdatedAt = room.UpdatedAt
|
||||
};
|
||||
|
||||
private static LiveRoomCurrentRecordingState GetCurrentRecordingState(LiveRoom room, bool hasActiveSession)
|
||||
{
|
||||
if (room.AvailabilityStatus != LiveRoomAvailabilityStatus.Live)
|
||||
{
|
||||
return LiveRoomCurrentRecordingState.Offline;
|
||||
}
|
||||
|
||||
return hasActiveSession
|
||||
? LiveRoomCurrentRecordingState.Recording
|
||||
: LiveRoomCurrentRecordingState.Live;
|
||||
}
|
||||
|
||||
private static int? ClampNullable(int? value, int min, int max) =>
|
||||
value.HasValue ? Math.Clamp(value.Value, min, max) : null;
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ internal static class RecordModelMapper
|
||||
DanmakuMessageCount = recordResult.DanmakuMessageCount,
|
||||
FinalStatus = recordResult.FinalStatus,
|
||||
ErrorMessage = recordResult.ErrorMessage,
|
||||
UploadStatus = recordResult.UploadStatus,
|
||||
LastUploadProvider = recordResult.LastUploadProvider,
|
||||
RemoteVideoPath = recordResult.RemoteVideoPath,
|
||||
RemoteDanmakuPath = recordResult.RemoteDanmakuPath,
|
||||
LastUploadedAt = recordResult.LastUploadedAt,
|
||||
UploadErrorMessage = recordResult.UploadErrorMessage,
|
||||
DeletedLocalFilesAfterUpload = recordResult.DeletedLocalFilesAfterUpload,
|
||||
CreatedAt = recordResult.CreatedAt
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ namespace LiveRecorder.Application.Services;
|
||||
|
||||
public sealed class RecordService
|
||||
{
|
||||
private static readonly TimeSpan StartRecordingDebounceWindow = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly ILiveRoomRepository _liveRoomRepository;
|
||||
private readonly IRecordSessionRepository _recordSessionRepository;
|
||||
private readonly IRecordTaskRepository _recordTaskRepository;
|
||||
@@ -146,6 +148,34 @@ public sealed class RecordService
|
||||
throw new InvalidOperationException("The live room is disabled. Enable it before starting a recording.");
|
||||
}
|
||||
|
||||
var debounceTriggeredAt = DateTimeOffset.UtcNow;
|
||||
if (liveRoom.LastStartRecordingTriggeredAt.HasValue &&
|
||||
debounceTriggeredAt - liveRoom.LastStartRecordingTriggeredAt.Value < StartRecordingDebounceWindow)
|
||||
{
|
||||
if (trackAutoStartDecision)
|
||||
{
|
||||
await UpdateAutoStartDecisionAsync(
|
||||
liveRoom,
|
||||
AutoStartDecisionCodes.SkippedDebounce,
|
||||
"Auto-start skipped because start recording debounce is active.",
|
||||
$"lastTriggeredAt={liveRoom.LastStartRecordingTriggeredAt:O}",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"RecordSession",
|
||||
"Recording start skipped because the debounce window is still active.",
|
||||
$"windowSeconds={(int)StartRecordingDebounceWindow.TotalSeconds}",
|
||||
liveRoomId: liveRoom.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
throw new InvalidOperationException("Recording start was triggered too recently. Please wait a few seconds and try again.");
|
||||
}
|
||||
|
||||
liveRoom.MarkStartRecordingTriggered(debounceTriggeredAt);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await ReconcileActiveSessionsAsync(liveRoom.Id, cancellationToken);
|
||||
var activeSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken);
|
||||
if (activeSession is not null)
|
||||
@@ -204,6 +234,7 @@ public sealed class RecordService
|
||||
var outputFormat = request.OutputFormat ?? effectiveSettings.OutputFormat;
|
||||
var saveMode = effectiveSettings.SaveMode;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var storageAnchorName = GetStorageAnchorName(liveRoom, settings);
|
||||
|
||||
var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
|
||||
await _recordSessionRepository.AddAsync(recordSession, cancellationToken);
|
||||
@@ -251,7 +282,7 @@ public sealed class RecordService
|
||||
settings.OutputFileNameTemplate,
|
||||
liveRoom.Platform,
|
||||
liveRoom.RoomId,
|
||||
liveRoom.AnchorName,
|
||||
storageAnchorName,
|
||||
liveRoom.Title,
|
||||
outputFormat,
|
||||
saveMode,
|
||||
@@ -774,6 +805,18 @@ public sealed class RecordService
|
||||
return Path.Combine(folder, $"{fileNameStem}.{extension}");
|
||||
}
|
||||
|
||||
private static string? GetStorageAnchorName(LiveRoom liveRoom, Application.Models.Settings.SystemSettingsDto settings)
|
||||
{
|
||||
if (!settings.UseAliasForStorage)
|
||||
{
|
||||
return liveRoom.AnchorName;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(liveRoom.Alias)
|
||||
? liveRoom.AnchorName
|
||||
: liveRoom.Alias;
|
||||
}
|
||||
|
||||
internal static string ResolveSegmentOutputPath(
|
||||
string outputPathPattern,
|
||||
RecordOutputFormat outputFormat,
|
||||
|
||||
@@ -32,6 +32,28 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
private const string EnableBackgroundPollingKey = "scheduler.enable_background_polling";
|
||||
private const string AutoStartRecordingOnLiveKey = "scheduler.auto_start_recording_on_live";
|
||||
private const string PollingIntervalSecondsKey = "scheduler.polling_interval_seconds";
|
||||
private const string UseAliasForStorageKey = "recording.use_alias_for_storage";
|
||||
private const string EnableFileUploadKey = "upload.enabled";
|
||||
private const string EnableAutoUploadKey = "upload.auto_upload";
|
||||
private const string DeleteLocalFilesAfterUploadKey = "upload.delete_local_files_after_upload";
|
||||
private const string UploadTargetKey = "upload.target";
|
||||
private const string WebDavEndpointKey = "upload.webdav.endpoint";
|
||||
private const string WebDavBasePathKey = "upload.webdav.base_path";
|
||||
private const string WebDavUsernameKey = "upload.webdav.username";
|
||||
private const string WebDavPasswordKey = "upload.webdav.password";
|
||||
private const string S3EndpointKey = "upload.s3.endpoint";
|
||||
private const string S3BucketKey = "upload.s3.bucket";
|
||||
private const string S3RegionKey = "upload.s3.region";
|
||||
private const string S3AccessKeyKey = "upload.s3.access_key";
|
||||
private const string S3SecretKeyKey = "upload.s3.secret_key";
|
||||
private const string S3PrefixKey = "upload.s3.prefix";
|
||||
private const string S3ForcePathStyleKey = "upload.s3.force_path_style";
|
||||
private const string DouyinProxyEnabledKey = "platform_proxy.douyin.enabled";
|
||||
private const string DouyinProxyUrlKey = "platform_proxy.douyin.url";
|
||||
private const string BilibiliProxyEnabledKey = "platform_proxy.bilibili.enabled";
|
||||
private const string BilibiliProxyUrlKey = "platform_proxy.bilibili.url";
|
||||
private const string HuyaProxyEnabledKey = "platform_proxy.huya.enabled";
|
||||
private const string HuyaProxyUrlKey = "platform_proxy.huya.url";
|
||||
private const string EnableEventScriptsKey = "event_scripts.enabled";
|
||||
private const string LiveStartedScriptModeKey = "event_scripts.live_started.mode";
|
||||
private const string LiveStartedScriptPathKey = "event_scripts.live_started.path";
|
||||
@@ -117,6 +139,45 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
EnableBackgroundPolling = bool.TryParse(GetValue(lookup, EnableBackgroundPollingKey, "true"), out var enableBackgroundPolling) && enableBackgroundPolling,
|
||||
AutoStartRecordingOnLive = bool.TryParse(GetValue(lookup, AutoStartRecordingOnLiveKey, "true"), out var autoStartRecordingOnLive) && autoStartRecordingOnLive,
|
||||
PollingIntervalSeconds = GetIntValue(lookup, PollingIntervalSecondsKey, 60, 10, 3600),
|
||||
UseAliasForStorage = bool.TryParse(GetValue(lookup, UseAliasForStorageKey, "false"), out var useAliasForStorage) && useAliasForStorage,
|
||||
EnableFileUpload = bool.TryParse(GetValue(lookup, EnableFileUploadKey, "false"), out var enableFileUpload) && enableFileUpload,
|
||||
EnableAutoUpload = bool.TryParse(GetValue(lookup, EnableAutoUploadKey, "false"), out var enableAutoUpload) && enableAutoUpload,
|
||||
DeleteLocalFilesAfterUpload = bool.TryParse(GetValue(lookup, DeleteLocalFilesAfterUploadKey, "false"), out var deleteLocalFilesAfterUpload) && deleteLocalFilesAfterUpload,
|
||||
UploadTarget = Enum.TryParse(GetValue(lookup, UploadTargetKey, "None"), true, out UploadTargetType uploadTarget)
|
||||
? uploadTarget
|
||||
: UploadTargetType.None,
|
||||
DouyinProxy = new PlatformProxySettingsDto
|
||||
{
|
||||
Enabled = bool.TryParse(GetValue(lookup, DouyinProxyEnabledKey, "false"), out var douyinProxyEnabled) && douyinProxyEnabled,
|
||||
ProxyUrl = GetValue(lookup, DouyinProxyUrlKey, string.Empty)
|
||||
},
|
||||
BilibiliProxy = new PlatformProxySettingsDto
|
||||
{
|
||||
Enabled = bool.TryParse(GetValue(lookup, BilibiliProxyEnabledKey, "false"), out var bilibiliProxyEnabled) && bilibiliProxyEnabled,
|
||||
ProxyUrl = GetValue(lookup, BilibiliProxyUrlKey, string.Empty)
|
||||
},
|
||||
HuyaProxy = new PlatformProxySettingsDto
|
||||
{
|
||||
Enabled = bool.TryParse(GetValue(lookup, HuyaProxyEnabledKey, "false"), out var huyaProxyEnabled) && huyaProxyEnabled,
|
||||
ProxyUrl = GetValue(lookup, HuyaProxyUrlKey, string.Empty)
|
||||
},
|
||||
WebDavUpload = new WebDavUploadSettingsDto
|
||||
{
|
||||
Endpoint = GetValue(lookup, WebDavEndpointKey, string.Empty),
|
||||
BasePath = GetValue(lookup, WebDavBasePathKey, string.Empty),
|
||||
Username = GetValue(lookup, WebDavUsernameKey, string.Empty),
|
||||
Password = GetValue(lookup, WebDavPasswordKey, string.Empty)
|
||||
},
|
||||
S3Upload = new S3UploadSettingsDto
|
||||
{
|
||||
Endpoint = GetValue(lookup, S3EndpointKey, string.Empty),
|
||||
Bucket = GetValue(lookup, S3BucketKey, string.Empty),
|
||||
Region = GetValue(lookup, S3RegionKey, string.Empty),
|
||||
AccessKey = GetValue(lookup, S3AccessKeyKey, string.Empty),
|
||||
SecretKey = GetValue(lookup, S3SecretKeyKey, string.Empty),
|
||||
Prefix = GetValue(lookup, S3PrefixKey, string.Empty),
|
||||
ForcePathStyle = bool.TryParse(GetValue(lookup, S3ForcePathStyleKey, "false"), out var s3ForcePathStyle) && s3ForcePathStyle
|
||||
},
|
||||
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
|
||||
LiveStartedScriptMode = GetEventScriptMode(lookup, LiveStartedScriptModeKey, LiveStartedScriptPathKey, LiveStartedScriptContentKey),
|
||||
LiveStartedScriptPath = GetValue(lookup, LiveStartedScriptPathKey, string.Empty),
|
||||
@@ -199,6 +260,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var douyinProxy = request.DouyinProxy ?? new PlatformProxySettingsDto();
|
||||
var bilibiliProxy = request.BilibiliProxy ?? new PlatformProxySettingsDto();
|
||||
var huyaProxy = request.HuyaProxy ?? new PlatformProxySettingsDto();
|
||||
var webDavUpload = request.WebDavUpload ?? new WebDavUploadSettingsDto();
|
||||
var s3Upload = request.S3Upload ?? new S3UploadSettingsDto();
|
||||
|
||||
await UpsertAsync(FfmpegPathKey, request.FfmpegPath.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(OutputRootKey, request.OutputRoot.Trim(), now, cancellationToken);
|
||||
@@ -240,6 +306,28 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(EnableBackgroundPollingKey, request.EnableBackgroundPolling.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(AutoStartRecordingOnLiveKey, request.AutoStartRecordingOnLive.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(PollingIntervalSecondsKey, request.PollingIntervalSeconds.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(UseAliasForStorageKey, request.UseAliasForStorage.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EnableFileUploadKey, request.EnableFileUpload.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EnableAutoUploadKey, request.EnableAutoUpload.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(DeleteLocalFilesAfterUploadKey, request.DeleteLocalFilesAfterUpload.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(UploadTargetKey, request.UploadTarget.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(WebDavEndpointKey, webDavUpload.Endpoint.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(WebDavBasePathKey, webDavUpload.BasePath.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(WebDavUsernameKey, webDavUpload.Username.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(WebDavPasswordKey, webDavUpload.Password, now, cancellationToken);
|
||||
await UpsertAsync(S3EndpointKey, s3Upload.Endpoint.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(S3BucketKey, s3Upload.Bucket.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(S3RegionKey, s3Upload.Region.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(S3AccessKeyKey, s3Upload.AccessKey.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken);
|
||||
await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(DouyinProxyEnabledKey, douyinProxy.Enabled.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(DouyinProxyUrlKey, douyinProxy.ProxyUrl.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(BilibiliProxyEnabledKey, bilibiliProxy.Enabled.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(BilibiliProxyUrlKey, bilibiliProxy.ProxyUrl.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(HuyaProxyEnabledKey, huyaProxy.Enabled.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(HuyaProxyUrlKey, huyaProxy.ProxyUrl.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(EnableEventScriptsKey, request.EnableEventScripts.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(LiveStartedScriptModeKey, NormalizeEventScriptMode(request.LiveStartedScriptMode), now, cancellationToken);
|
||||
await UpsertAsync(LiveStartedScriptPathKey, request.LiveStartedScriptPath.Trim(), now, cancellationToken);
|
||||
|
||||
@@ -46,6 +46,16 @@ public class LiveRoom
|
||||
|
||||
public string? CoverUrl { get; private set; }
|
||||
|
||||
public string? Remark { get; private set; }
|
||||
|
||||
public bool IsPinned { get; private set; }
|
||||
|
||||
public string? Alias { get; private set; }
|
||||
|
||||
public bool IsPriority { get; private set; }
|
||||
|
||||
public int? PollingIntervalSecondsOverride { get; private set; }
|
||||
|
||||
public string? PreferredQualityOverride { get; private set; }
|
||||
|
||||
public RecordOutputFormat? OutputFormatOverride { get; private set; }
|
||||
@@ -90,6 +100,8 @@ public class LiveRoom
|
||||
|
||||
public DateTimeOffset? LastCheckedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset? LastStartRecordingTriggeredAt { get; private set; }
|
||||
|
||||
public ICollection<RecordTask> RecordTasks { get; private set; } = new List<RecordTask>();
|
||||
|
||||
public void UpdateSource(string sourceUrl, string normalizedUrl, DateTimeOffset updatedAt)
|
||||
@@ -112,6 +124,7 @@ public class LiveRoom
|
||||
AnchorId = PreferIncomingValue(anchorId, AnchorId);
|
||||
AvatarUrl = PreferIncomingValue(avatarUrl, AvatarUrl);
|
||||
CoverUrl = PreferIncomingValue(coverUrl, CoverUrl);
|
||||
Alias ??= NormalizeNullable(anchorName);
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
@@ -151,6 +164,28 @@ public class LiveRoom
|
||||
LastAutoStartDecisionAt = decidedAt;
|
||||
}
|
||||
|
||||
public void UpdateManagementMetadata(
|
||||
string? remark,
|
||||
bool isPinned,
|
||||
string? alias,
|
||||
bool isPriority,
|
||||
int? pollingIntervalSecondsOverride,
|
||||
DateTimeOffset updatedAt)
|
||||
{
|
||||
Remark = NormalizeNullable(remark);
|
||||
IsPinned = isPinned;
|
||||
Alias = NormalizeNullable(alias) ?? NormalizeNullable(AnchorName);
|
||||
IsPriority = isPriority;
|
||||
PollingIntervalSecondsOverride = pollingIntervalSecondsOverride;
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public void MarkStartRecordingTriggered(DateTimeOffset triggeredAt)
|
||||
{
|
||||
LastStartRecordingTriggeredAt = triggeredAt;
|
||||
UpdatedAt = triggeredAt;
|
||||
}
|
||||
|
||||
public void UpdateRecordingSettingsOverrides(
|
||||
string? preferredQualityOverride,
|
||||
RecordOutputFormat? outputFormatOverride,
|
||||
|
||||
@@ -51,6 +51,20 @@ public class RecordResult
|
||||
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
public RecordArtifactUploadStatus UploadStatus { get; private set; }
|
||||
|
||||
public string? LastUploadProvider { get; private set; }
|
||||
|
||||
public string? RemoteVideoPath { get; private set; }
|
||||
|
||||
public string? RemoteDanmakuPath { get; private set; }
|
||||
|
||||
public DateTimeOffset? LastUploadedAt { get; private set; }
|
||||
|
||||
public string? UploadErrorMessage { get; private set; }
|
||||
|
||||
public bool DeletedLocalFilesAfterUpload { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
public void Update(
|
||||
@@ -70,4 +84,32 @@ public class RecordResult
|
||||
FinalStatus = finalStatus;
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public void MarkUploadSucceeded(
|
||||
string provider,
|
||||
string? remoteVideoPath,
|
||||
string? remoteDanmakuPath,
|
||||
bool deletedLocalFilesAfterUpload,
|
||||
DateTimeOffset uploadedAt)
|
||||
{
|
||||
UploadStatus = RecordArtifactUploadStatus.Succeeded;
|
||||
LastUploadProvider = NormalizeNullable(provider);
|
||||
RemoteVideoPath = NormalizeNullable(remoteVideoPath);
|
||||
RemoteDanmakuPath = NormalizeNullable(remoteDanmakuPath);
|
||||
LastUploadedAt = uploadedAt;
|
||||
UploadErrorMessage = null;
|
||||
DeletedLocalFilesAfterUpload = deletedLocalFilesAfterUpload;
|
||||
}
|
||||
|
||||
public void MarkUploadFailed(string provider, string? errorMessage, DateTimeOffset uploadedAt)
|
||||
{
|
||||
UploadStatus = RecordArtifactUploadStatus.Failed;
|
||||
LastUploadProvider = NormalizeNullable(provider);
|
||||
LastUploadedAt = uploadedAt;
|
||||
UploadErrorMessage = NormalizeNullable(errorMessage);
|
||||
DeletedLocalFilesAfterUpload = false;
|
||||
}
|
||||
|
||||
private static string? NormalizeNullable(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LiveRecorder.Domain.Enums;
|
||||
|
||||
public enum LiveRoomCurrentRecordingState
|
||||
{
|
||||
Offline = 0,
|
||||
Live = 1,
|
||||
Recording = 2
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LiveRecorder.Domain.Enums;
|
||||
|
||||
public enum RecordArtifactUploadStatus
|
||||
{
|
||||
NotUploaded = 0,
|
||||
Succeeded = 1,
|
||||
Failed = 2
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LiveRecorder.Domain.Enums;
|
||||
|
||||
public enum UploadTargetType
|
||||
{
|
||||
None = 0,
|
||||
WebDav = 1,
|
||||
S3 = 2
|
||||
}
|
||||
@@ -56,6 +56,28 @@ public sealed class DatabaseInitializer
|
||||
["scheduler.enable_background_polling"] = "True",
|
||||
["scheduler.auto_start_recording_on_live"] = "True",
|
||||
["scheduler.polling_interval_seconds"] = "60",
|
||||
["recording.use_alias_for_storage"] = "False",
|
||||
["upload.enabled"] = "False",
|
||||
["upload.auto_upload"] = "False",
|
||||
["upload.delete_local_files_after_upload"] = "False",
|
||||
["upload.target"] = "None",
|
||||
["upload.webdav.endpoint"] = string.Empty,
|
||||
["upload.webdav.base_path"] = string.Empty,
|
||||
["upload.webdav.username"] = string.Empty,
|
||||
["upload.webdav.password"] = string.Empty,
|
||||
["upload.s3.endpoint"] = string.Empty,
|
||||
["upload.s3.bucket"] = string.Empty,
|
||||
["upload.s3.region"] = string.Empty,
|
||||
["upload.s3.access_key"] = string.Empty,
|
||||
["upload.s3.secret_key"] = string.Empty,
|
||||
["upload.s3.prefix"] = string.Empty,
|
||||
["upload.s3.force_path_style"] = "False",
|
||||
["platform_proxy.douyin.enabled"] = "False",
|
||||
["platform_proxy.douyin.url"] = string.Empty,
|
||||
["platform_proxy.bilibili.enabled"] = "False",
|
||||
["platform_proxy.bilibili.url"] = string.Empty,
|
||||
["platform_proxy.huya.enabled"] = "False",
|
||||
["platform_proxy.huya.url"] = string.Empty,
|
||||
["event_scripts.enabled"] = "False",
|
||||
["event_scripts.live_started.path"] = string.Empty,
|
||||
["event_scripts.live_ended.path"] = string.Empty,
|
||||
@@ -171,6 +193,12 @@ public sealed class DatabaseInitializer
|
||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionSummary TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionDetail TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionAt TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN Remark TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN IsPinned INTEGER NOT NULL DEFAULT 0;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN Alias TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN IsPriority INTEGER NOT NULL DEFAULT 0;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN PollingIntervalSecondsOverride INTEGER NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastStartRecordingTriggeredAt TEXT NULL;", cancellationToken);
|
||||
|
||||
await _dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
@@ -199,6 +227,13 @@ public sealed class DatabaseInitializer
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN SegmentIndex INTEGER NOT NULL DEFAULT 1;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuFilePath TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuMessageCount INTEGER NOT NULL DEFAULT 0;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN UploadStatus INTEGER NOT NULL DEFAULT 0;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN LastUploadProvider TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN RemoteVideoPath TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN RemoteDanmakuPath TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN LastUploadedAt TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN UploadErrorMessage TEXT NULL;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DeletedLocalFilesAfterUpload INTEGER NOT NULL DEFAULT 0;", cancellationToken);
|
||||
await ExecuteAddColumnAsync("ALTER TABLE SystemLogEntries ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken);
|
||||
|
||||
await _dbContext.Database.ExecuteSqlRawAsync(
|
||||
|
||||
@@ -44,6 +44,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
||||
builder.Property(static x => x.AnchorId).HasMaxLength(128);
|
||||
builder.Property(static x => x.AvatarUrl).HasMaxLength(512);
|
||||
builder.Property(static x => x.CoverUrl).HasMaxLength(512);
|
||||
builder.Property(static x => x.Remark).HasMaxLength(512);
|
||||
builder.Property(static x => x.Alias).HasMaxLength(128);
|
||||
builder.Property(static x => x.PreferredQualityOverride).HasMaxLength(64);
|
||||
builder.Property(static x => x.OutputFormatOverride).HasConversion<int?>();
|
||||
builder.Property(static x => x.SaveModeOverride).HasConversion<int?>();
|
||||
@@ -53,6 +55,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
||||
builder.Property(static x => x.LastAutoStartDecisionDetail).HasMaxLength(2048);
|
||||
builder.Property(static x => x.IsEnabled).HasDefaultValue(true);
|
||||
builder.Property(static x => x.HasSentLiveNotificationForCurrentSession).HasDefaultValue(false);
|
||||
builder.Property(static x => x.IsPinned).HasDefaultValue(false);
|
||||
builder.Property(static x => x.IsPriority).HasDefaultValue(false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RecordTask>(builder =>
|
||||
@@ -98,9 +102,14 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
||||
builder.ToTable("RecordResults");
|
||||
builder.HasKey(static x => x.Id);
|
||||
builder.Property(static x => x.FinalStatus).HasConversion<int>();
|
||||
builder.Property(static x => x.UploadStatus).HasConversion<int>();
|
||||
builder.Property(static x => x.FilePath).HasMaxLength(2048);
|
||||
builder.Property(static x => x.DanmakuFilePath).HasMaxLength(2048);
|
||||
builder.Property(static x => x.ErrorMessage).HasMaxLength(2048);
|
||||
builder.Property(static x => x.LastUploadProvider).HasMaxLength(32);
|
||||
builder.Property(static x => x.RemoteVideoPath).HasMaxLength(2048);
|
||||
builder.Property(static x => x.RemoteDanmakuPath).HasMaxLength(2048);
|
||||
builder.Property(static x => x.UploadErrorMessage).HasMaxLength(2048);
|
||||
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
|
||||
builder.HasOne(static x => x.RecordTask)
|
||||
.WithOne(static x => x.Result)
|
||||
|
||||
@@ -173,6 +173,16 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<Guid>> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default) =>
|
||||
await _dbContext.RecordSessions
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Status == RecordSessionStatus.Starting ||
|
||||
item.Status == RecordSessionStatus.Running ||
|
||||
item.Status == RecordSessionStatus.Stopping)
|
||||
.Select(item => item.LiveRoomId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordSessions
|
||||
.Include(item => item.LiveRoom)
|
||||
|
||||
@@ -2,6 +2,8 @@ using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -13,17 +15,17 @@ public sealed class BilibiliHttpClient
|
||||
"""^(?:(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?)?(?<id>\d+)\/?(?:[#\?].*)?$""",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly PlatformHttpClientFactory _platformHttpClientFactory;
|
||||
private readonly BilibiliWbiSigner _wbiSigner;
|
||||
private readonly ILogger<BilibiliHttpClient> _logger;
|
||||
private readonly string _buvid3 = BilibiliRequestDefaults.GenerateBuvid3();
|
||||
|
||||
public BilibiliHttpClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
PlatformHttpClientFactory platformHttpClientFactory,
|
||||
BilibiliWbiSigner wbiSigner,
|
||||
ILogger<BilibiliHttpClient> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_platformHttpClientFactory = platformHttpClientFactory;
|
||||
_wbiSigner = wbiSigner;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -350,9 +352,11 @@ public sealed class BilibiliHttpClient
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClientFactory
|
||||
.CreateClient(BilibiliRequestDefaults.ClientName)
|
||||
.SendAsync(requestFactory(), completionOption, cancellationToken);
|
||||
using var client = await _platformHttpClientFactory.CreateAsync(
|
||||
LivePlatformType.Bilibili,
|
||||
forceDirectConnection: false,
|
||||
cancellationToken);
|
||||
var response = await client.SendAsync(requestFactory(), completionOption, cancellationToken);
|
||||
|
||||
if (attempt < 3 && IsTransientStatusCode(response.StatusCode))
|
||||
{
|
||||
@@ -362,7 +366,7 @@ public sealed class BilibiliHttpClient
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return response;
|
||||
return await BufferResponseAsync(response, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (attempt < 3 && IsTransientException(ex, cancellationToken))
|
||||
{
|
||||
@@ -375,6 +379,38 @@ public sealed class BilibiliHttpClient
|
||||
throw lastException ?? new InvalidOperationException("Bilibili request failed.");
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> BufferResponseAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var buffer = new MemoryStream();
|
||||
await responseStream.CopyToAsync(buffer, cancellationToken);
|
||||
|
||||
var clone = new HttpResponseMessage(response.StatusCode)
|
||||
{
|
||||
ReasonPhrase = response.ReasonPhrase,
|
||||
Version = response.Version,
|
||||
RequestMessage = response.RequestMessage is null
|
||||
? null
|
||||
: new HttpRequestMessage(response.RequestMessage.Method, response.RequestMessage.RequestUri),
|
||||
Content = new ByteArrayContent(buffer.ToArray())
|
||||
};
|
||||
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
foreach (var header in response.Content.Headers)
|
||||
{
|
||||
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
response.Dispose();
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static string? ExtractRoomReference(string input)
|
||||
{
|
||||
var match = RoomIdRegex.Match(input);
|
||||
|
||||
@@ -5,8 +5,10 @@ using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
|
||||
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,8 +17,6 @@ namespace LiveRecorder.Infrastructure.Platforms.Douyin;
|
||||
|
||||
public sealed class DouyinHttpClient
|
||||
{
|
||||
private const string DefaultClientName = "douyin";
|
||||
private const string DirectClientName = "douyin-direct";
|
||||
private const int MaxAttempts = 3;
|
||||
private const int MsTokenLength = 184;
|
||||
private static readonly Regex PaceStateRegex = new(
|
||||
@@ -61,20 +61,20 @@ public sealed class DouyinHttpClient
|
||||
"\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"anchor\":{[\\s\\S]*?\"nickname\":\"(?<nickname>[\\s\\S]*?)\"",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly PlatformHttpClientFactory _platformHttpClientFactory;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly DouyinXBogusSigner _xBogusSigner;
|
||||
private readonly DouyinLiveWsSignatureSigner _liveWsSignatureSigner;
|
||||
private readonly ILogger<DouyinHttpClient> _logger;
|
||||
|
||||
public DouyinHttpClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
PlatformHttpClientFactory platformHttpClientFactory,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
DouyinXBogusSigner xBogusSigner,
|
||||
DouyinLiveWsSignatureSigner liveWsSignatureSigner,
|
||||
ILogger<DouyinHttpClient> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_platformHttpClientFactory = platformHttpClientFactory;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_xBogusSigner = xBogusSigner;
|
||||
_liveWsSignatureSigner = liveWsSignatureSigner;
|
||||
@@ -523,7 +523,10 @@ BootstrapResolved:
|
||||
{
|
||||
using var request = requestFactory();
|
||||
var useDirectConnection = preferDirectConnection;
|
||||
var client = _httpClientFactory.CreateClient(useDirectConnection ? DirectClientName : DefaultClientName);
|
||||
using var client = await _platformHttpClientFactory.CreateAsync(
|
||||
LivePlatformType.Douyin,
|
||||
forceDirectConnection: useDirectConnection,
|
||||
cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -537,7 +540,7 @@ BootstrapResolved:
|
||||
attempt);
|
||||
}
|
||||
|
||||
return response;
|
||||
return await BufferResponseAsync(response, cancellationToken);
|
||||
}
|
||||
|
||||
if (attempt < MaxAttempts && IsTransientStatusCode(response.StatusCode))
|
||||
@@ -548,7 +551,7 @@ BootstrapResolved:
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return response;
|
||||
return await BufferResponseAsync(response, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (attempt < MaxAttempts && IsTransientTransportException(ex, cancellationToken))
|
||||
{
|
||||
@@ -573,8 +576,44 @@ BootstrapResolved:
|
||||
}
|
||||
|
||||
using var lastRequest = requestFactory();
|
||||
var finalClient = _httpClientFactory.CreateClient(preferDirectConnection ? DirectClientName : DefaultClientName);
|
||||
return await finalClient.SendAsync(lastRequest, completionOption, cancellationToken);
|
||||
using var finalClient = await _platformHttpClientFactory.CreateAsync(
|
||||
LivePlatformType.Douyin,
|
||||
forceDirectConnection: preferDirectConnection,
|
||||
cancellationToken);
|
||||
var finalResponse = await finalClient.SendAsync(lastRequest, completionOption, cancellationToken);
|
||||
return await BufferResponseAsync(finalResponse, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> BufferResponseAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var buffer = new MemoryStream();
|
||||
await responseStream.CopyToAsync(buffer, cancellationToken);
|
||||
|
||||
var clone = new HttpResponseMessage(response.StatusCode)
|
||||
{
|
||||
ReasonPhrase = response.ReasonPhrase,
|
||||
Version = response.Version,
|
||||
RequestMessage = response.RequestMessage is null
|
||||
? null
|
||||
: new HttpRequestMessage(response.RequestMessage.Method, response.RequestMessage.RequestUri),
|
||||
Content = new ByteArrayContent(buffer.ToArray())
|
||||
};
|
||||
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
foreach (var header in response.Content.Headers)
|
||||
{
|
||||
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
response.Dispose();
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static void ApplyDefaultHeaders(HttpRequestMessage request, SystemSettingsDto settings, string? refererRoomId)
|
||||
|
||||
@@ -418,6 +418,8 @@ public sealed partial class FfmpegService
|
||||
previousResult,
|
||||
previousEffectiveOutputPath,
|
||||
now);
|
||||
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
|
||||
await recordUploadService.TryAutoUploadTaskAsync(previousTask.Id, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -761,6 +763,8 @@ public sealed partial class FfmpegService
|
||||
recordResult,
|
||||
effectiveOutputPath,
|
||||
endedAt);
|
||||
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
|
||||
await recordUploadService.TryAutoUploadTaskAsync(currentTask.Id, CancellationToken.None);
|
||||
}
|
||||
|
||||
await logService.WriteAsync(
|
||||
@@ -894,6 +898,8 @@ public sealed partial class FfmpegService
|
||||
recordResult,
|
||||
effectiveOutputPath,
|
||||
endedAt);
|
||||
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
|
||||
await recordUploadService.TryAutoUploadTaskAsync(recordTask.Id, CancellationToken.None);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(finalizationError))
|
||||
@@ -1283,6 +1289,8 @@ public sealed partial class FfmpegService
|
||||
recordResult,
|
||||
effectiveOutputPath,
|
||||
endedAt);
|
||||
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
|
||||
await recordUploadService.TryAutoUploadTaskAsync(recordTask.Id, CancellationToken.None);
|
||||
}
|
||||
|
||||
await logService.WriteAsync(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class PlatformHttpClientFactory
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
|
||||
public PlatformHttpClientFactory(ISystemSettingsService systemSettingsService)
|
||||
{
|
||||
_systemSettingsService = systemSettingsService;
|
||||
}
|
||||
|
||||
public async Task<HttpClient> CreateAsync(
|
||||
LivePlatformType platform,
|
||||
bool forceDirectConnection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var proxy = forceDirectConnection ? null : BuildProxy(platform, settings);
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
|
||||
PooledConnectionLifetime = platform == LivePlatformType.Douyin
|
||||
? TimeSpan.FromMinutes(2)
|
||||
: TimeSpan.FromMinutes(5),
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
|
||||
MaxConnectionsPerServer = 8,
|
||||
ConnectTimeout = DefaultConnectTimeout,
|
||||
UseCookies = false,
|
||||
UseProxy = proxy is not null,
|
||||
Proxy = proxy,
|
||||
SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
|
||||
}
|
||||
};
|
||||
|
||||
return new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
Timeout = DefaultTimeout,
|
||||
DefaultRequestVersion = HttpVersion.Version11,
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
|
||||
};
|
||||
}
|
||||
|
||||
private static IWebProxy? BuildProxy(LivePlatformType platform, SystemSettingsDto settings)
|
||||
{
|
||||
var proxySettings = platform switch
|
||||
{
|
||||
LivePlatformType.Douyin => settings.DouyinProxy,
|
||||
LivePlatformType.Bilibili => settings.BilibiliProxy,
|
||||
LivePlatformType.Huya => settings.HuyaProxy,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (proxySettings is null || !proxySettings.Enabled || string.IsNullOrWhiteSpace(proxySettings.ProxyUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(proxySettings.ProxyUrl.Trim(), UriKind.Absolute, out var proxyUri) ||
|
||||
(proxyUri.Scheme != Uri.UriSchemeHttp && proxyUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException($"The configured proxy URL for {platform} is invalid: {proxySettings.ProxyUrl}");
|
||||
}
|
||||
|
||||
return new WebProxy(proxyUri);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class RecordUploadService
|
||||
{
|
||||
private readonly LiveRecorderDbContext _dbContext;
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
|
||||
public RecordUploadService(
|
||||
LiveRecorderDbContext dbContext,
|
||||
ISystemSettingsService systemSettingsService,
|
||||
ISystemLogService systemLogService)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_systemLogService = systemLogService;
|
||||
}
|
||||
|
||||
public async Task<RecordArtifactUploadItemResultDto?> TryAutoUploadTaskAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableFileUpload || !settings.EnableAutoUpload || settings.UploadTarget == UploadTargetType.None)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordArtifactUploadItemResultDto> UploadTaskAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordArtifactUploadBatchResultDto> UploadSessionAsync(
|
||||
Guid recordSessionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var session = await _dbContext.RecordSessions
|
||||
.AsNoTracking()
|
||||
.Include(item => item.RecordTasks)
|
||||
.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
return new RecordArtifactUploadBatchResultDto
|
||||
{
|
||||
RequestedCount = 0,
|
||||
SuccessCount = 0,
|
||||
FailedCount = 1,
|
||||
Items =
|
||||
[
|
||||
new RecordArtifactUploadItemResultDto
|
||||
{
|
||||
RecordTaskId = Guid.Empty,
|
||||
Success = false,
|
||||
Message = "Recording session was not found."
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
var taskIds = session.RecordTasks
|
||||
.OrderBy(static item => item.SegmentIndex)
|
||||
.ThenBy(static item => item.CreatedAt)
|
||||
.Select(static item => item.Id)
|
||||
.ToArray();
|
||||
|
||||
var items = new List<RecordArtifactUploadItemResultDto>(taskIds.Length);
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
items.Add(await UploadTaskInternalAsync(taskId, settings, automatic: false, cancellationToken));
|
||||
}
|
||||
|
||||
return new RecordArtifactUploadBatchResultDto
|
||||
{
|
||||
RequestedCount = taskIds.Length,
|
||||
SuccessCount = items.Count(static item => item.Success),
|
||||
FailedCount = items.Count(static item => !item.Success),
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<RecordArtifactUploadItemResultDto> UploadTaskInternalAsync(
|
||||
Guid recordTaskId,
|
||||
SystemSettingsDto settings,
|
||||
bool automatic,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var recordTask = await _dbContext.RecordTasks
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordSession)
|
||||
.Include(item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
|
||||
|
||||
if (recordTask?.LiveRoom is null || recordTask.RecordSession is null || recordTask.Result is null)
|
||||
{
|
||||
return CreateFailureResult(recordTaskId, "Recording result is not ready for upload.");
|
||||
}
|
||||
|
||||
if (!settings.EnableFileUpload || settings.UploadTarget == UploadTargetType.None)
|
||||
{
|
||||
return CreateFailureResult(recordTaskId, "File upload is disabled or no upload target is configured.");
|
||||
}
|
||||
|
||||
var recordResult = recordTask.Result;
|
||||
var absoluteVideoPath = NormalizeAbsolutePath(recordResult.FilePath);
|
||||
var absoluteDanmakuPath = NormalizeNullablePath(recordResult.DanmakuFilePath);
|
||||
var hasVideoArtifact = !string.IsNullOrWhiteSpace(absoluteVideoPath) && File.Exists(absoluteVideoPath);
|
||||
var hasDanmakuArtifact = !string.IsNullOrWhiteSpace(absoluteDanmakuPath) && File.Exists(absoluteDanmakuPath);
|
||||
|
||||
if (!hasVideoArtifact && !hasDanmakuArtifact)
|
||||
{
|
||||
if (recordResult.UploadStatus == RecordArtifactUploadStatus.Succeeded)
|
||||
{
|
||||
return new RecordArtifactUploadItemResultDto
|
||||
{
|
||||
RecordTaskId = recordTaskId,
|
||||
Success = true,
|
||||
Message = "Artifacts were uploaded previously and local files are no longer available.",
|
||||
Provider = recordResult.LastUploadProvider,
|
||||
RemoteVideoPath = recordResult.RemoteVideoPath,
|
||||
RemoteDanmakuPath = recordResult.RemoteDanmakuPath,
|
||||
DeletedLocalFilesAfterUpload = recordResult.DeletedLocalFilesAfterUpload
|
||||
};
|
||||
}
|
||||
|
||||
return CreateFailureResult(recordTaskId, "No local recording artifacts are available for upload.");
|
||||
}
|
||||
|
||||
var uploader = CreateUploader(settings);
|
||||
try
|
||||
{
|
||||
var remoteVideoPath = recordResult.RemoteVideoPath;
|
||||
var remoteDanmakuPath = recordResult.RemoteDanmakuPath;
|
||||
|
||||
if (hasVideoArtifact)
|
||||
{
|
||||
var relativeVideoPath = BuildRelativeRemotePath(settings.OutputRoot, absoluteVideoPath!);
|
||||
remoteVideoPath = await uploader.UploadFileAsync(absoluteVideoPath!, relativeVideoPath, cancellationToken);
|
||||
}
|
||||
|
||||
if (hasDanmakuArtifact)
|
||||
{
|
||||
var relativeDanmakuPath = BuildRelativeRemotePath(settings.OutputRoot, absoluteDanmakuPath!);
|
||||
remoteDanmakuPath = await uploader.UploadFileAsync(absoluteDanmakuPath!, relativeDanmakuPath, cancellationToken);
|
||||
}
|
||||
|
||||
var deletedLocalFiles = false;
|
||||
string? deletionWarning = null;
|
||||
if (settings.DeleteLocalFilesAfterUpload)
|
||||
{
|
||||
try
|
||||
{
|
||||
deletedLocalFiles = TryDeleteUploadedArtifacts(absoluteVideoPath, hasVideoArtifact, absoluteDanmakuPath, hasDanmakuArtifact);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
deletionWarning = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
recordResult.MarkUploadSucceeded(
|
||||
uploader.ProviderName,
|
||||
remoteVideoPath,
|
||||
remoteDanmakuPath,
|
||||
deletedLocalFiles,
|
||||
DateTimeOffset.UtcNow);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
deletionWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning,
|
||||
"Upload",
|
||||
automatic ? "Automatic artifact upload completed." : "Artifact upload completed.",
|
||||
BuildUploadLogDetail(uploader.ProviderName, remoteVideoPath, remoteDanmakuPath, deletedLocalFiles, deletionWarning),
|
||||
liveRoomId: recordTask.LiveRoomId,
|
||||
recordSessionId: recordTask.RecordSessionId,
|
||||
recordTaskId: recordTask.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return new RecordArtifactUploadItemResultDto
|
||||
{
|
||||
RecordTaskId = recordTask.Id,
|
||||
Success = true,
|
||||
Message = deletionWarning is null
|
||||
? "Upload completed successfully."
|
||||
: $"Upload completed, but local cleanup was not fully successful: {deletionWarning}",
|
||||
Provider = uploader.ProviderName,
|
||||
RemoteVideoPath = remoteVideoPath,
|
||||
RemoteDanmakuPath = remoteDanmakuPath,
|
||||
DeletedLocalFilesAfterUpload = deletedLocalFiles
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
recordResult.MarkUploadFailed(uploader.ProviderName, ex.Message, DateTimeOffset.UtcNow);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
automatic ? SystemLogLevel.Warning : SystemLogLevel.Error,
|
||||
"Upload",
|
||||
automatic ? "Automatic artifact upload failed." : "Artifact upload failed.",
|
||||
ex.ToString(),
|
||||
liveRoomId: recordTask.LiveRoomId,
|
||||
recordSessionId: recordTask.RecordSessionId,
|
||||
recordTaskId: recordTask.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return CreateFailureResult(recordTask.Id, ex.Message, uploader.ProviderName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildUploadLogDetail(
|
||||
string provider,
|
||||
string? remoteVideoPath,
|
||||
string? remoteDanmakuPath,
|
||||
bool deletedLocalFiles,
|
||||
string? deletionWarning)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("provider=").Append(provider);
|
||||
if (!string.IsNullOrWhiteSpace(remoteVideoPath))
|
||||
{
|
||||
builder.Append("; video=").Append(remoteVideoPath);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(remoteDanmakuPath))
|
||||
{
|
||||
builder.Append("; danmaku=").Append(remoteDanmakuPath);
|
||||
}
|
||||
|
||||
builder.Append("; deletedLocalFiles=").Append(deletedLocalFiles);
|
||||
if (!string.IsNullOrWhiteSpace(deletionWarning))
|
||||
{
|
||||
builder.Append("; cleanupWarning=").Append(deletionWarning);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool TryDeleteUploadedArtifacts(
|
||||
string? absoluteVideoPath,
|
||||
bool hasVideoArtifact,
|
||||
string? absoluteDanmakuPath,
|
||||
bool hasDanmakuArtifact)
|
||||
{
|
||||
var deletedAny = false;
|
||||
|
||||
if (hasVideoArtifact && !string.IsNullOrWhiteSpace(absoluteVideoPath) && File.Exists(absoluteVideoPath))
|
||||
{
|
||||
File.Delete(absoluteVideoPath);
|
||||
deletedAny = true;
|
||||
}
|
||||
|
||||
if (hasDanmakuArtifact && !string.IsNullOrWhiteSpace(absoluteDanmakuPath) && File.Exists(absoluteDanmakuPath))
|
||||
{
|
||||
File.Delete(absoluteDanmakuPath);
|
||||
deletedAny = true;
|
||||
}
|
||||
|
||||
return deletedAny;
|
||||
}
|
||||
|
||||
private static string BuildRelativeRemotePath(string outputRoot, string absolutePath)
|
||||
{
|
||||
var absoluteOutputRoot = Path.GetFullPath(outputRoot, AppContext.BaseDirectory);
|
||||
var relativePath = Path.GetRelativePath(absoluteOutputRoot, absolutePath);
|
||||
if (relativePath.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
relativePath = Path.GetFileName(absolutePath);
|
||||
}
|
||||
|
||||
return relativePath
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('/');
|
||||
}
|
||||
|
||||
private static string NormalizeAbsolutePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Path.IsPathRooted(path)
|
||||
? path
|
||||
: Path.GetFullPath(path, AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
private static string? NormalizeNullablePath(string? path) =>
|
||||
string.IsNullOrWhiteSpace(path) ? null : NormalizeAbsolutePath(path);
|
||||
|
||||
private static RecordArtifactUploadItemResultDto CreateFailureResult(
|
||||
Guid recordTaskId,
|
||||
string message,
|
||||
string? provider = null) =>
|
||||
new()
|
||||
{
|
||||
RecordTaskId = recordTaskId,
|
||||
Success = false,
|
||||
Message = message,
|
||||
Provider = provider
|
||||
};
|
||||
|
||||
private static IRecordArtifactUploader CreateUploader(SystemSettingsDto settings) =>
|
||||
settings.UploadTarget switch
|
||||
{
|
||||
UploadTargetType.WebDav => new WebDavRecordArtifactUploader(settings.WebDavUpload),
|
||||
UploadTargetType.S3 => new S3RecordArtifactUploader(settings.S3Upload),
|
||||
_ => throw new InvalidOperationException("No supported upload target is configured.")
|
||||
};
|
||||
}
|
||||
|
||||
internal interface IRecordArtifactUploader
|
||||
{
|
||||
string ProviderName { get; }
|
||||
|
||||
Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class WebDavRecordArtifactUploader : IRecordArtifactUploader
|
||||
{
|
||||
private readonly WebDavUploadSettingsDto _settings;
|
||||
|
||||
public WebDavRecordArtifactUploader(WebDavUploadSettingsDto settings)
|
||||
{
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
}
|
||||
|
||||
public string ProviderName => "webdav";
|
||||
|
||||
public async Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("WebDAV endpoint is not configured.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(EnsureTrailingSlash(_settings.Endpoint.Trim()), UriKind.Absolute, out var endpointUri))
|
||||
{
|
||||
throw new InvalidOperationException("WebDAV endpoint is invalid.");
|
||||
}
|
||||
|
||||
var remotePath = CombineRemotePath(_settings.BasePath, relativeRemotePath);
|
||||
var fileUri = BuildWebDavUri(endpointUri, remotePath);
|
||||
|
||||
using var client = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(10)
|
||||
};
|
||||
|
||||
var authHeader = BuildBasicAuthorization(_settings.Username, _settings.Password);
|
||||
await EnsureCollectionsAsync(client, endpointUri, remotePath, authHeader, cancellationToken);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, fileUri);
|
||||
if (!string.IsNullOrWhiteSpace(authHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", authHeader);
|
||||
}
|
||||
|
||||
request.Content = new StreamContent(File.OpenRead(localPath));
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
if (response.StatusCode is not HttpStatusCode.Created and not HttpStatusCode.NoContent and not HttpStatusCode.OK)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new InvalidOperationException($"WebDAV upload failed with status {(int)response.StatusCode}: {errorBody}");
|
||||
}
|
||||
|
||||
return fileUri.ToString();
|
||||
}
|
||||
|
||||
private static async Task EnsureCollectionsAsync(
|
||||
HttpClient client,
|
||||
Uri endpointUri,
|
||||
string remotePath,
|
||||
string? authorization,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var segments = remotePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new List<string>(segments.Length);
|
||||
for (var index = 0; index < segments.Length - 1; index++)
|
||||
{
|
||||
builder.Add(segments[index]);
|
||||
var collectionUri = BuildWebDavUri(endpointUri, string.Join('/', builder) + "/");
|
||||
using var request = new HttpRequestMessage(new HttpMethod("MKCOL"), collectionUri);
|
||||
if (!string.IsNullOrWhiteSpace(authorization))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", authorization);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
if (response.StatusCode is HttpStatusCode.Created or HttpStatusCode.MethodNotAllowed or HttpStatusCode.Conflict or HttpStatusCode.OK or HttpStatusCode.NoContent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new InvalidOperationException($"WebDAV MKCOL failed with status {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri BuildWebDavUri(Uri endpointUri, string remotePath)
|
||||
{
|
||||
var encodedPath = string.Join(
|
||||
'/',
|
||||
remotePath
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(Uri.EscapeDataString));
|
||||
return new Uri(endpointUri, encodedPath);
|
||||
}
|
||||
|
||||
private static string CombineRemotePath(string? basePath, string relativeRemotePath)
|
||||
{
|
||||
var parts = new[]
|
||||
{
|
||||
basePath?.Trim(),
|
||||
relativeRemotePath.Trim()
|
||||
}
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!.Trim('/'))
|
||||
.ToArray();
|
||||
|
||||
return string.Join('/', parts);
|
||||
}
|
||||
|
||||
private static string? BuildBasicAuthorization(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) && string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var raw = $"{username}:{password}";
|
||||
return $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))}";
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSlash(string value) =>
|
||||
value.EndsWith("/", StringComparison.Ordinal) ? value : value + "/";
|
||||
}
|
||||
|
||||
internal sealed class S3RecordArtifactUploader : IRecordArtifactUploader
|
||||
{
|
||||
private readonly S3UploadSettingsDto _settings;
|
||||
|
||||
public S3RecordArtifactUploader(S3UploadSettingsDto settings)
|
||||
{
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
}
|
||||
|
||||
public string ProviderName => "s3";
|
||||
|
||||
public async Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateSettings();
|
||||
|
||||
var objectKey = BuildObjectKey(_settings.Prefix, relativeRemotePath);
|
||||
var requestUri = BuildRequestUri(_settings, objectKey);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var amzDate = now.ToString("yyyyMMdd'T'HHmmss'Z'");
|
||||
var dateStamp = now.ToString("yyyyMMdd");
|
||||
var region = string.IsNullOrWhiteSpace(_settings.Region) ? "us-east-1" : _settings.Region.Trim();
|
||||
var payloadHash = await ComputeFileHashAsync(localPath, cancellationToken);
|
||||
var canonicalUri = BuildCanonicalUri(_settings, objectKey);
|
||||
var hostHeader = requestUri.IsDefaultPort ? requestUri.Host : $"{requestUri.Host}:{requestUri.Port}";
|
||||
const string signedHeaders = "host;x-amz-content-sha256;x-amz-date";
|
||||
var canonicalHeaders = $"host:{hostHeader}\n" +
|
||||
$"x-amz-content-sha256:{payloadHash}\n" +
|
||||
$"x-amz-date:{amzDate}\n";
|
||||
var canonicalRequest = $"PUT\n{canonicalUri}\n\n{canonicalHeaders}\n{signedHeaders}\n{payloadHash}";
|
||||
var credentialScope = $"{dateStamp}/{region}/s3/aws4_request";
|
||||
var stringToSign = "AWS4-HMAC-SHA256\n" +
|
||||
$"{amzDate}\n" +
|
||||
$"{credentialScope}\n" +
|
||||
$"{ComputeSha256Hex(canonicalRequest)}";
|
||||
var signature = ComputeAwsSignature(_settings.SecretKey, dateStamp, region, stringToSign);
|
||||
var authorization = $"AWS4-HMAC-SHA256 Credential={_settings.AccessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}";
|
||||
|
||||
using var client = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(10)
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
|
||||
request.Headers.TryAddWithoutValidation("x-amz-content-sha256", payloadHash);
|
||||
request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
|
||||
request.Headers.TryAddWithoutValidation("Authorization", authorization);
|
||||
request.Content = new StreamContent(File.OpenRead(localPath));
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
if (response.StatusCode is not HttpStatusCode.OK and not HttpStatusCode.Created and not HttpStatusCode.NoContent)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new InvalidOperationException($"S3 upload failed with status {(int)response.StatusCode}: {errorBody}");
|
||||
}
|
||||
|
||||
return requestUri.ToString();
|
||||
}
|
||||
|
||||
private void ValidateSettings()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("S3 endpoint is not configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.Bucket))
|
||||
{
|
||||
throw new InvalidOperationException("S3 bucket is not configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.AccessKey) || string.IsNullOrWhiteSpace(_settings.SecretKey))
|
||||
{
|
||||
throw new InvalidOperationException("S3 access key or secret key is not configured.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri BuildRequestUri(S3UploadSettingsDto settings, string objectKey)
|
||||
{
|
||||
if (!Uri.TryCreate(settings.Endpoint.Trim(), UriKind.Absolute, out var endpointUri))
|
||||
{
|
||||
throw new InvalidOperationException("S3 endpoint is invalid.");
|
||||
}
|
||||
|
||||
var encodedKey = string.Join('/', objectKey.Split('/').Select(Uri.EscapeDataString));
|
||||
if (settings.ForcePathStyle)
|
||||
{
|
||||
var basePath = endpointUri.AbsolutePath.TrimEnd('/');
|
||||
var combinedPath = $"{basePath}/{Uri.EscapeDataString(settings.Bucket.Trim())}/{encodedKey}".Replace("//", "/", StringComparison.Ordinal);
|
||||
return new UriBuilder(endpointUri)
|
||||
{
|
||||
Path = combinedPath
|
||||
}.Uri;
|
||||
}
|
||||
|
||||
return new UriBuilder(endpointUri)
|
||||
{
|
||||
Host = $"{settings.Bucket.Trim()}.{endpointUri.Host}",
|
||||
Path = encodedKey
|
||||
}.Uri;
|
||||
}
|
||||
|
||||
private static string BuildCanonicalUri(S3UploadSettingsDto settings, string objectKey)
|
||||
{
|
||||
var encodedKey = string.Join('/', objectKey.Split('/').Select(Uri.EscapeDataString));
|
||||
if (settings.ForcePathStyle)
|
||||
{
|
||||
return "/" + Uri.EscapeDataString(settings.Bucket.Trim()) + "/" + encodedKey;
|
||||
}
|
||||
|
||||
return "/" + encodedKey;
|
||||
}
|
||||
|
||||
private static string BuildObjectKey(string? prefix, string relativeRemotePath)
|
||||
{
|
||||
var parts = new[]
|
||||
{
|
||||
prefix?.Trim(),
|
||||
relativeRemotePath.Trim()
|
||||
}
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!.Trim('/'))
|
||||
.ToArray();
|
||||
|
||||
return string.Join('/', parts);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string localPath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var stream = File.OpenRead(localPath);
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
|
||||
return ConvertToHex(hash);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string content)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
return ConvertToHex(sha256.ComputeHash(Encoding.UTF8.GetBytes(content)));
|
||||
}
|
||||
|
||||
private static string ComputeAwsSignature(string secretKey, string dateStamp, string region, string stringToSign)
|
||||
{
|
||||
var secret = Encoding.UTF8.GetBytes("AWS4" + secretKey);
|
||||
var dateKey = ComputeHmac(secret, dateStamp);
|
||||
var regionKey = ComputeHmac(dateKey, region);
|
||||
var serviceKey = ComputeHmac(regionKey, "s3");
|
||||
var signingKey = ComputeHmac(serviceKey, "aws4_request");
|
||||
return ConvertToHex(ComputeHmac(signingKey, stringToSign));
|
||||
}
|
||||
|
||||
private static byte[] ComputeHmac(byte[] key, string value)
|
||||
{
|
||||
using var hmac = new HMACSHA256(key);
|
||||
return hmac.ComputeHash(Encoding.UTF8.GetBytes(value));
|
||||
}
|
||||
|
||||
private static string ConvertToHex(byte[] bytes) =>
|
||||
Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
@@ -78,6 +78,13 @@ public sealed class LiveRoomsController : ControllerBase
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _liveRoomService.UpdateSettingsAsync(id, request, cancellationToken));
|
||||
|
||||
[HttpPut("{id:guid}/metadata")]
|
||||
public async Task<ActionResult<LiveRoomDto>> UpdateMetadata(
|
||||
Guid id,
|
||||
[FromBody] UpdateLiveRoomMetadataRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _liveRoomService.UpdateMetadataAsync(id, request, cancellationToken));
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -12,10 +13,14 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
private static readonly TimeSpan StreamInterval = TimeSpan.FromSeconds(2);
|
||||
private static readonly JsonSerializerOptions StreamJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly RecordSessionService _recordSessionService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
|
||||
public RecordSessionsController(RecordSessionService recordSessionService)
|
||||
public RecordSessionsController(
|
||||
RecordSessionService recordSessionService,
|
||||
RecordUploadService recordUploadService)
|
||||
{
|
||||
_recordSessionService = recordSessionService;
|
||||
_recordUploadService = recordUploadService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -75,4 +80,8 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
[FromBody] DeleteRecordSessionsRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _recordSessionService.DeleteAsync(request, cancellationToken));
|
||||
|
||||
[HttpPost("{id:guid}/upload")]
|
||||
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LiveRecorder.WebApi.Controllers;
|
||||
@@ -9,11 +10,16 @@ namespace LiveRecorder.WebApi.Controllers;
|
||||
public sealed class RecordTasksController : ControllerBase
|
||||
{
|
||||
private readonly RecordService _recordService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
|
||||
public RecordTasksController(RecordService recordService, LinkGenerator linkGenerator)
|
||||
public RecordTasksController(
|
||||
RecordService recordService,
|
||||
RecordUploadService recordUploadService,
|
||||
LinkGenerator linkGenerator)
|
||||
{
|
||||
_recordService = recordService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_linkGenerator = linkGenerator;
|
||||
}
|
||||
|
||||
@@ -65,4 +71,8 @@ public sealed class RecordTasksController : ControllerBase
|
||||
[HttpPost("{id:guid}/transcode")]
|
||||
public async Task<ActionResult<RecordTaskDetailDto>> StartManualTranscode(Guid id, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordService.StartManualTranscodeAsync(id, cancellationToken));
|
||||
|
||||
[HttpPost("{id:guid}/upload")]
|
||||
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordUploadService.UploadTaskAsync(id, cancellationToken));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using LiveRecorder.Application.Abstractions.Scripting;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LiveRecorder.WebApi.Controllers;
|
||||
|
||||
@@ -41,6 +43,32 @@ public sealed class SettingsController : ControllerBase
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _systemSettingsService.UpdateAsync(request, cancellationToken));
|
||||
|
||||
[HttpGet("export")]
|
||||
public async Task<FileContentResult> Export(CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var payload = JsonSerializer.Serialize(settings, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
var fileName = $"live-recorder-settings-{DateTimeOffset.Now:yyyyMMddHHmmss}.json";
|
||||
return File(
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetBytes(payload),
|
||||
"application/json; charset=utf-8",
|
||||
fileName);
|
||||
}
|
||||
|
||||
[HttpPost("import")]
|
||||
public async Task<ActionResult<SystemSettingsDto>> Import(
|
||||
[FromBody] SystemSettingsDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(request);
|
||||
var updateRequest = JsonSerializer.Deserialize<UpdateSystemSettingsRequest>(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web))
|
||||
?? throw new InvalidOperationException("Unable to deserialize imported settings.");
|
||||
return Ok(await _systemSettingsService.UpdateAsync(updateRequest, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("test-email")]
|
||||
public async Task<IActionResult> SendTestEmail(
|
||||
[FromBody] SendTestEmailRequest request,
|
||||
|
||||
@@ -147,6 +147,8 @@ builder.Services.AddScoped<SessionAnalyticsService>();
|
||||
builder.Services.AddScoped<RecoveryService>();
|
||||
builder.Services.AddScoped<RetentionCleanupService>();
|
||||
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
||||
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
||||
builder.Services.AddScoped<RecordUploadService>();
|
||||
builder.Services.AddScoped<DatabaseInitializer>();
|
||||
|
||||
builder.Services.AddSingleton<BilibiliWbiSigner>();
|
||||
|
||||
Reference in New Issue
Block a user