feat: add reliable OpenList segment uploads

This commit is contained in:
2026-08-01 19:27:56 +08:00
parent 12ba62e2a0
commit 9091c1abc7
30 changed files with 3910 additions and 35 deletions
@@ -150,6 +150,14 @@ public sealed class RecordArtifactUploadItemResultDto
public string? RemoteDanmakuPath { get; init; }
public bool DeletedLocalFilesAfterUpload { get; init; }
public RecordArtifactUploadStatus? UploadStatus { get; init; }
public double? ProgressPercent { get; init; }
public int AttemptCount { get; init; }
public DateTimeOffset? NextAttemptAt { get; init; }
}
public sealed class RecordArtifactUploadBatchResultDto
@@ -211,6 +219,16 @@ public sealed class UploadTaskItemDto
public bool DeletedLocalFilesAfterUpload { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public double? UploadProgressPercent { get; init; }
public int UploadAttemptCount { get; init; }
public DateTimeOffset? NextUploadAttemptAt { get; init; }
public string? CurrentUploadArtifact { get; init; }
public string? ExternalUploadTaskId { get; init; }
}
public sealed class UploadTaskListResponse
@@ -224,4 +242,10 @@ public sealed class UploadTaskListResponse
public int SucceededCount { get; init; }
public int FailedCount { get; init; }
public int QueuedCount { get; init; }
public int UploadingCount { get; init; }
public int WaitingRetryCount { get; init; }
}
@@ -91,6 +91,49 @@ public sealed class OpenListUploadSettingsDto
public string Password { get; set; } = string.Empty;
public string BasePath { get; set; } = string.Empty;
public string SourcePath { get; set; } = string.Empty;
public string DestinationPath { get; set; } = string.Empty;
}
public class OpenListConnectionRequest
{
public string BaseUrl { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public sealed class OpenListDirectoryRequest : OpenListConnectionRequest
{
public string Path { get; set; } = "/";
}
public sealed class OpenListDirectoryItemDto
{
public required string Name { get; init; }
public required string Path { get; init; }
}
public sealed class OpenListDirectoryListDto
{
public required string Path { get; init; }
public bool CanWrite { get; init; }
public required IReadOnlyList<OpenListDirectoryItemDto> Directories { get; init; }
}
public sealed class OpenListConnectionTestDto
{
public bool Success { get; init; }
public string? Version { get; init; }
public required string Message { get; init; }
}
public sealed class SystemSettingsDto
@@ -58,6 +58,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
private const string OpenListUsernameKey = "upload.openlist.username";
private const string OpenListPasswordKey = "upload.openlist.password";
private const string OpenListBasePathKey = "upload.openlist.base_path";
private const string OpenListSourcePathKey = "upload.openlist.source_path";
private const string OpenListDestinationPathKey = "upload.openlist.destination_path";
private const string DouyinProxyEnabledKey = "platform_proxy.douyin.enabled";
private const string DouyinProxyUrlKey = "platform_proxy.douyin.url";
private const string BilibiliProxyEnabledKey = "platform_proxy.bilibili.enabled";
@@ -189,7 +191,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
BaseUrl = GetValue(lookup, OpenListBaseUrlKey, string.Empty),
Username = GetValue(lookup, OpenListUsernameKey, string.Empty),
Password = GetValue(lookup, OpenListPasswordKey, string.Empty),
BasePath = GetValue(lookup, OpenListBasePathKey, string.Empty)
BasePath = GetValue(lookup, OpenListBasePathKey, string.Empty),
SourcePath = GetValue(lookup, OpenListSourcePathKey, string.Empty),
DestinationPath = GetValue(
lookup,
OpenListDestinationPathKey,
GetValue(lookup, OpenListBasePathKey, string.Empty))
},
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
EnableLiveStartedScript = GetEventScriptEnabled(
@@ -363,7 +370,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(OpenListBaseUrlKey, openListUpload.BaseUrl.Trim(), now, cancellationToken);
await UpsertAsync(OpenListUsernameKey, openListUpload.Username.Trim(), now, cancellationToken);
await UpsertAsync(OpenListPasswordKey, openListUpload.Password, now, cancellationToken);
await UpsertAsync(OpenListBasePathKey, openListUpload.BasePath.Trim(), now, cancellationToken);
var openListDestinationPath = string.IsNullOrWhiteSpace(openListUpload.DestinationPath)
? openListUpload.BasePath.Trim()
: openListUpload.DestinationPath.Trim();
await UpsertAsync(OpenListBasePathKey, openListDestinationPath, now, cancellationToken);
await UpsertAsync(OpenListSourcePathKey, openListUpload.SourcePath.Trim(), now, cancellationToken);
await UpsertAsync(OpenListDestinationPathKey, openListDestinationPath, now, cancellationToken);
foreach (var platformDefinition in LivePlatformCatalog.All)
{
var platformRequestSettings = request.GetPlatformRequestSettings(platformDefinition.Type);
@@ -94,6 +94,36 @@ public class RecordResult
DeletedLocalFilesAfterUpload = false;
}
public void MarkUploadQueued(string provider, DateTimeOffset requestedAt)
{
UploadStatus = RecordArtifactUploadStatus.Queued;
LastUploadProvider = NormalizeNullable(provider);
LastUploadedAt = requestedAt;
UploadErrorMessage = null;
DeletedLocalFilesAfterUpload = false;
}
public void MarkUploadWaitingRetry(string provider, string? errorMessage, DateTimeOffset updatedAt)
{
UploadStatus = RecordArtifactUploadStatus.WaitingRetry;
LastUploadProvider = NormalizeNullable(provider);
LastUploadedAt = updatedAt;
UploadErrorMessage = NormalizeNullable(errorMessage);
DeletedLocalFilesAfterUpload = false;
}
public void MarkRemoteVideoUploaded(string remoteVideoPath, DateTimeOffset uploadedAt)
{
RemoteVideoPath = NormalizeNullable(remoteVideoPath);
LastUploadedAt = uploadedAt;
}
public void MarkRemoteDanmakuUploaded(string remoteDanmakuPath, DateTimeOffset uploadedAt)
{
RemoteDanmakuPath = NormalizeNullable(remoteDanmakuPath);
LastUploadedAt = uploadedAt;
}
public void MarkUploadSucceeded(
string provider,
string? remoteVideoPath,
@@ -65,6 +65,8 @@ public class RecordTask
public RecordResult? Result { get; private set; }
public RecordUploadJob? UploadJob { get; private set; }
public void AssignToSession(Guid recordSessionId, int segmentIndex, DateTimeOffset updatedAt)
{
RecordSessionId = recordSessionId;
@@ -0,0 +1,286 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Domain.Entities;
public sealed class RecordUploadJob
{
private RecordUploadJob()
{
}
public RecordUploadJob(
Guid recordTaskId,
string providerEndpoint,
string sourceVideoPath,
string targetVideoPath,
long videoSizeBytes,
string? sourceDanmakuPath,
string? targetDanmakuPath,
long? danmakuSizeBytes,
bool deleteLocalFilesAfterUpload,
DateTimeOffset requestedAt)
{
Id = Guid.NewGuid();
RecordTaskId = recordTaskId;
ProviderEndpoint = NormalizeRequired(providerEndpoint);
SourceVideoPath = NormalizeRequired(sourceVideoPath);
TargetVideoPath = NormalizeRequired(targetVideoPath);
VideoSizeBytes = Math.Max(0, videoSizeBytes);
SourceDanmakuPath = NormalizeNullable(sourceDanmakuPath);
TargetDanmakuPath = NormalizeNullable(targetDanmakuPath);
DanmakuSizeBytes = danmakuSizeBytes.HasValue ? Math.Max(0, danmakuSizeBytes.Value) : null;
DeleteLocalFilesAfterUpload = deleteLocalFilesAfterUpload;
Status = RecordArtifactUploadStatus.Queued;
CurrentArtifact = RecordUploadArtifactStage.Video;
RequestedAt = requestedAt;
UpdatedAt = requestedAt;
}
public Guid Id { get; private set; }
public Guid RecordTaskId { get; private set; }
public RecordTask? RecordTask { get; private set; }
public string ProviderEndpoint { get; private set; } = string.Empty;
public string SourceVideoPath { get; private set; } = string.Empty;
public string TargetVideoPath { get; private set; } = string.Empty;
public long VideoSizeBytes { get; private set; }
public string? SourceDanmakuPath { get; private set; }
public string? TargetDanmakuPath { get; private set; }
public long? DanmakuSizeBytes { get; private set; }
public bool DeleteLocalFilesAfterUpload { get; private set; }
public RecordArtifactUploadStatus Status { get; private set; }
public RecordUploadArtifactStage CurrentArtifact { get; private set; }
public int AttemptCount { get; private set; }
public double ProgressPercent { get; private set; }
public string? ExternalTaskId { get; private set; }
public string? ExternalTaskType { get; private set; }
public DateTimeOffset? ExternalTaskStartedAt { get; private set; }
public DateTimeOffset? NextAttemptAt { get; private set; }
public DateTimeOffset? VerificationStartedAt { get; private set; }
public string? ErrorMessage { get; private set; }
public DateTimeOffset RequestedAt { get; private set; }
public DateTimeOffset? StartedAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
public DateTimeOffset? CompletedAt { get; private set; }
public void RefreshRequest(
string providerEndpoint,
string sourceVideoPath,
string targetVideoPath,
long videoSizeBytes,
string? sourceDanmakuPath,
string? targetDanmakuPath,
long? danmakuSizeBytes,
bool deleteLocalFilesAfterUpload,
DateTimeOffset requestedAt)
{
ProviderEndpoint = NormalizeRequired(providerEndpoint);
SourceVideoPath = NormalizeRequired(sourceVideoPath);
TargetVideoPath = NormalizeRequired(targetVideoPath);
VideoSizeBytes = Math.Max(0, videoSizeBytes);
SourceDanmakuPath = NormalizeNullable(sourceDanmakuPath);
TargetDanmakuPath = NormalizeNullable(targetDanmakuPath);
DanmakuSizeBytes = danmakuSizeBytes.HasValue ? Math.Max(0, danmakuSizeBytes.Value) : null;
DeleteLocalFilesAfterUpload = deleteLocalFilesAfterUpload;
Status = RecordArtifactUploadStatus.Queued;
CurrentArtifact = RecordUploadArtifactStage.Video;
AttemptCount = 0;
ProgressPercent = 0;
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
NextAttemptAt = null;
VerificationStartedAt = null;
ErrorMessage = null;
RequestedAt = requestedAt;
StartedAt = null;
CompletedAt = null;
UpdatedAt = requestedAt;
}
public void MarkProcessing(DateTimeOffset updatedAt)
{
Status = RecordArtifactUploadStatus.Uploading;
StartedAt ??= updatedAt;
NextAttemptAt = null;
ErrorMessage = null;
UpdatedAt = updatedAt;
}
public void BeginAttempt(DateTimeOffset updatedAt)
{
MarkProcessing(updatedAt);
AttemptCount++;
}
public void TrackExternalTask(string taskId, string taskType, double progressPercent, DateTimeOffset updatedAt)
{
ExternalTaskId = NormalizeRequired(taskId);
ExternalTaskType = NormalizeRequired(taskType);
ExternalTaskStartedAt ??= updatedAt;
SetProgress(progressPercent, updatedAt);
}
public void SetProgress(double progressPercent, DateTimeOffset updatedAt)
{
ProgressPercent = Math.Clamp(progressPercent, 0, 100);
UpdatedAt = updatedAt;
}
public void StartVerification(DateTimeOffset updatedAt)
{
VerificationStartedAt ??= updatedAt;
UpdatedAt = updatedAt;
}
public void CompleteCurrentArtifact(DateTimeOffset updatedAt)
{
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
VerificationStartedAt = null;
ErrorMessage = null;
if (CurrentArtifact == RecordUploadArtifactStage.Video && !string.IsNullOrWhiteSpace(SourceDanmakuPath))
{
CurrentArtifact = RecordUploadArtifactStage.Danmaku;
ProgressPercent = CalculateCompletedVideoProgress();
}
else
{
CurrentArtifact = RecordUploadArtifactStage.Completed;
ProgressPercent = 100;
}
UpdatedAt = updatedAt;
}
public void ScheduleRetry(string? errorMessage, DateTimeOffset nextAttemptAt, DateTimeOffset updatedAt, bool clearExternalTask)
{
Status = RecordArtifactUploadStatus.WaitingRetry;
ErrorMessage = NormalizeNullable(errorMessage);
NextAttemptAt = nextAttemptAt;
if (clearExternalTask)
{
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
VerificationStartedAt = null;
}
UpdatedAt = updatedAt;
}
public void MarkSucceeded(DateTimeOffset completedAt)
{
Status = RecordArtifactUploadStatus.Succeeded;
CurrentArtifact = RecordUploadArtifactStage.Completed;
ProgressPercent = 100;
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
NextAttemptAt = null;
VerificationStartedAt = null;
ErrorMessage = null;
CompletedAt = completedAt;
UpdatedAt = completedAt;
}
public void MarkFailed(string? errorMessage, DateTimeOffset completedAt)
{
Status = RecordArtifactUploadStatus.Failed;
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
NextAttemptAt = null;
VerificationStartedAt = null;
ErrorMessage = NormalizeNullable(errorMessage);
CompletedAt = completedAt;
UpdatedAt = completedAt;
}
public string GetCurrentSourcePath() => CurrentArtifact switch
{
RecordUploadArtifactStage.Video => SourceVideoPath,
RecordUploadArtifactStage.Danmaku => SourceDanmakuPath
?? throw new InvalidOperationException("Danmaku source path is not configured."),
_ => throw new InvalidOperationException("The upload job has no remaining artifact.")
};
public string GetCurrentTargetPath() => CurrentArtifact switch
{
RecordUploadArtifactStage.Video => TargetVideoPath,
RecordUploadArtifactStage.Danmaku => TargetDanmakuPath
?? throw new InvalidOperationException("Danmaku target path is not configured."),
_ => throw new InvalidOperationException("The upload job has no remaining artifact.")
};
public long GetCurrentSizeBytes() => CurrentArtifact switch
{
RecordUploadArtifactStage.Video => VideoSizeBytes,
RecordUploadArtifactStage.Danmaku => DanmakuSizeBytes ?? 0,
_ => 0
};
public double CalculateOverallProgress(double currentArtifactProgress)
{
var danmakuSize = SourceDanmakuPath is null ? 0 : Math.Max(0, DanmakuSizeBytes ?? 0);
var totalSize = Math.Max(1, VideoSizeBytes + danmakuSize);
var completedSize = CurrentArtifact switch
{
RecordUploadArtifactStage.Video => 0,
RecordUploadArtifactStage.Danmaku => VideoSizeBytes,
_ => totalSize
};
var currentSize = CurrentArtifact switch
{
RecordUploadArtifactStage.Video => VideoSizeBytes,
RecordUploadArtifactStage.Danmaku => danmakuSize,
_ => 0
};
return Math.Clamp((completedSize + currentSize * Math.Clamp(currentArtifactProgress, 0, 100) / 100d) * 100d / totalSize, 0, 100);
}
private double CalculateCompletedVideoProgress()
{
var totalSize = Math.Max(1, VideoSizeBytes + Math.Max(0, DanmakuSizeBytes ?? 0));
return Math.Clamp(VideoSizeBytes * 100d / totalSize, 0, 100);
}
private static string NormalizeRequired(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("A non-empty value is required.", nameof(value));
}
return value.Trim();
}
private static string? NormalizeNullable(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -5,5 +5,7 @@ public enum RecordArtifactUploadStatus
NotUploaded = 0,
Succeeded = 1,
Failed = 2,
Uploading = 3
Uploading = 3,
Queued = 4,
WaitingRetry = 5
}
@@ -0,0 +1,8 @@
namespace LiveRecorder.Domain.Enums;
public enum RecordUploadArtifactStage
{
Video = 0,
Danmaku = 1,
Completed = 2
}
@@ -74,6 +74,12 @@ public sealed class DatabaseInitializer
["upload.s3.secret_key"] = string.Empty,
["upload.s3.prefix"] = string.Empty,
["upload.s3.force_path_style"] = "False",
["upload.openlist.base_url"] = string.Empty,
["upload.openlist.username"] = string.Empty,
["upload.openlist.password"] = string.Empty,
["upload.openlist.base_path"] = string.Empty,
["upload.openlist.source_path"] = string.Empty,
["upload.openlist.destination_path"] = string.Empty,
["platform_proxy.douyin.enabled"] = "False",
["platform_proxy.douyin.url"] = string.Empty,
["platform_proxy.bilibili.enabled"] = "False",
@@ -19,6 +19,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
public DbSet<RecordResult> RecordResults => Set<RecordResult>();
public DbSet<RecordUploadJob> RecordUploadJobs => Set<RecordUploadJob>();
public DbSet<SystemLogEntry> SystemLogEntries => Set<SystemLogEntry>();
public DbSet<CleanupOperation> CleanupOperations => Set<CleanupOperation>();
@@ -119,6 +121,28 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RecordUploadJob>(builder =>
{
builder.ToTable("RecordUploadJobs");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.Status).HasConversion<int>();
builder.Property(static x => x.CurrentArtifact).HasConversion<int>();
builder.Property(static x => x.ProviderEndpoint).HasMaxLength(2048);
builder.Property(static x => x.SourceVideoPath).HasMaxLength(2048);
builder.Property(static x => x.TargetVideoPath).HasMaxLength(2048);
builder.Property(static x => x.SourceDanmakuPath).HasMaxLength(2048);
builder.Property(static x => x.TargetDanmakuPath).HasMaxLength(2048);
builder.Property(static x => x.ExternalTaskId).HasMaxLength(128);
builder.Property(static x => x.ExternalTaskType).HasMaxLength(32);
builder.Property(static x => x.ErrorMessage).HasMaxLength(4096);
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
builder.HasIndex(static x => new { x.Status, x.NextAttemptAt, x.RequestedAt });
builder.HasOne(static x => x.RecordTask)
.WithOne(static x => x.UploadJob)
.HasForeignKey<RecordUploadJob>(static x => x.RecordTaskId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SystemLogEntry>(builder =>
{
builder.ToTable("SystemLogEntries");
@@ -0,0 +1,757 @@
// <auto-generated />
using System;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiveRecorder.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(LiveRecorderDbContext))]
[Migration("20260801100250_AddOpenListUploadJobs")]
partial class AddOpenListUploadJobs
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("LiveRecorder.Domain.Entities.AppSetting", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("AppSettings", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.CleanupOperation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DeleteFiles")
.HasColumnType("boolean");
b.Property<int>("DeletedDanmakuFileCount")
.HasColumnType("integer");
b.Property<int>("DeletedFileCount")
.HasColumnType("integer");
b.Property<int>("DeletedLogCount")
.HasColumnType("integer");
b.Property<int>("DeletedResultCount")
.HasColumnType("integer");
b.Property<int>("DeletedSessionCount")
.HasColumnType("integer");
b.Property<int>("DeletedTaskCount")
.HasColumnType("integer");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("FiltersJson")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<int>("ProcessedSessionCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalSessionCount")
.HasColumnType("integer");
b.Property<string>("WarningsJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("CleanupOperations", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Alias")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AnchorId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AnchorName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("AvailabilityStatus")
.HasColumnType("integer");
b.Property<string>("AvatarUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("CoverUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool?>("DanmakuIncludeNonChatEventsOverride")
.HasColumnType("boolean");
b.Property<int?>("DanmakuMinPollIntervalMillisecondsOverride")
.HasColumnType("integer");
b.Property<int?>("DanmakuRetryDelayMaxSecondsOverride")
.HasColumnType("integer");
b.Property<bool?>("EnableAutoReconnectOverride")
.HasColumnType("boolean");
b.Property<bool?>("EnableDanmakuRecordingOverride")
.HasColumnType("boolean");
b.Property<bool>("HasSentLiveNotificationForCurrentSession")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsPinned")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("IsPriority")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastAutoStartDecisionAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastAutoStartDecisionCode")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LastAutoStartDecisionDetail")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("LastAutoStartDecisionSummary")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("LastCheckedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastStartRecordingTriggeredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int?>("OutputFormatOverride")
.HasColumnType("integer");
b.Property<int>("Platform")
.HasColumnType("integer");
b.Property<int?>("PollingIntervalSecondsOverride")
.HasColumnType("integer");
b.Property<string>("PreferredQualityOverride")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ReadWriteTimeoutMillisecondsOverride")
.HasColumnType("integer");
b.Property<int?>("ReconnectDelayMaxSecondsOverride")
.HasColumnType("integer");
b.Property<int?>("RecordingTemplateOverride")
.HasColumnType("integer");
b.Property<string>("Remark")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RoomId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int?>("SaveModeOverride")
.HasColumnType("integer");
b.Property<int?>("SegmentDurationMinutesOverride")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Platform", "RoomId")
.IsUnique();
b.ToTable("LiveRooms", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DanmakuFilePath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DanmakuMessageCount")
.HasColumnType("integer");
b.Property<bool>("DeletedLocalFilesAfterUpload")
.HasColumnType("boolean");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<long?>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<int>("FinalStatus")
.HasColumnType("integer");
b.Property<string>("LastUploadProvider")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset?>("LastUploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RecordTaskId")
.HasColumnType("uuid");
b.Property<string>("RemoteDanmakuPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("RemoteVideoPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("UploadErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("UploadStatus")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RecordTaskId")
.IsUnique();
b.ToTable("RecordResults", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("ActiveSegmentIndex")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("LiveRoomId")
.HasColumnType("uuid");
b.Property<int>("OutputFormat")
.HasColumnType("integer");
b.Property<string>("OutputPathPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PreferredQuality")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("RecorderProcessId")
.HasColumnType("integer");
b.Property<int>("SaveMode")
.HasColumnType("integer");
b.Property<int>("SegmentCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("StreamUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LiveRoomId");
b.ToTable("RecordSessions", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision");
b.Property<DateTimeOffset?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("LiveRoomId")
.HasColumnType("uuid");
b.Property<string>("OutputFilePath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("OutputFormat")
.HasColumnType("integer");
b.Property<string>("PreferredQuality")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("RecordSessionId")
.HasColumnType("uuid");
b.Property<int?>("RecorderProcessId")
.HasColumnType("integer");
b.Property<int>("SegmentIndex")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("StreamUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LiveRoomId");
b.HasIndex("RecordSessionId", "SegmentIndex");
b.ToTable("RecordTasks", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordUploadJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AttemptCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CurrentArtifact")
.HasColumnType("integer");
b.Property<long?>("DanmakuSizeBytes")
.HasColumnType("bigint");
b.Property<bool>("DeleteLocalFilesAfterUpload")
.HasColumnType("boolean");
b.Property<string>("ErrorMessage")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("ExternalTaskId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset?>("ExternalTaskStartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalTaskType")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset?>("NextAttemptAt")
.HasColumnType("timestamp with time zone");
b.Property<double>("ProgressPercent")
.HasColumnType("double precision");
b.Property<string>("ProviderEndpoint")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("RecordTaskId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("RequestedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceDanmakuPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SourceVideoPath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TargetDanmakuPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("TargetVideoPath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("VerificationStartedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("VideoSizeBytes")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RecordTaskId")
.IsUnique();
b.HasIndex("Status", "NextAttemptAt", "RequestedAt");
b.ToTable("RecordUploadJobs", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.SystemLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasColumnType("text");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<Guid?>("LiveRoomId")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("RecordSessionId")
.HasColumnType("uuid");
b.Property<Guid?>("RecordTaskId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("RecordSessionId");
b.ToTable("SystemLogEntries", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("UserAccounts", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("UserAccountId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserAccountId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
.WithOne("Result")
.HasForeignKey("LiveRecorder.Domain.Entities.RecordResult", "RecordTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RecordTask");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
.WithMany()
.HasForeignKey("LiveRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LiveRoom");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
.WithMany("RecordTasks")
.HasForeignKey("LiveRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiveRecorder.Domain.Entities.RecordSession", "RecordSession")
.WithMany("RecordTasks")
.HasForeignKey("RecordSessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LiveRoom");
b.Navigation("RecordSession");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordUploadJob", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
.WithOne("UploadJob")
.HasForeignKey("LiveRecorder.Domain.Entities.RecordUploadJob", "RecordTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RecordTask");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.UserAccount", "UserAccount")
.WithMany()
.HasForeignKey("UserAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("UserAccount");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
{
b.Navigation("RecordTasks");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.Navigation("RecordTasks");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.Navigation("Result");
b.Navigation("UploadJob");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiveRecorder.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddOpenListUploadJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RecordUploadJobs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
RecordTaskId = table.Column<Guid>(type: "uuid", nullable: false),
ProviderEndpoint = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
SourceVideoPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
TargetVideoPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
VideoSizeBytes = table.Column<long>(type: "bigint", nullable: false),
SourceDanmakuPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
TargetDanmakuPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
DanmakuSizeBytes = table.Column<long>(type: "bigint", nullable: true),
DeleteLocalFilesAfterUpload = table.Column<bool>(type: "boolean", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CurrentArtifact = table.Column<int>(type: "integer", nullable: false),
AttemptCount = table.Column<int>(type: "integer", nullable: false),
ProgressPercent = table.Column<double>(type: "double precision", nullable: false),
ExternalTaskId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
ExternalTaskType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
ExternalTaskStartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
NextAttemptAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
VerificationStartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ErrorMessage = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
RequestedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RecordUploadJobs", x => x.Id);
table.ForeignKey(
name: "FK_RecordUploadJobs_RecordTasks_RecordTaskId",
column: x => x.RecordTaskId,
principalTable: "RecordTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_RecordUploadJobs_RecordTaskId",
table: "RecordUploadJobs",
column: "RecordTaskId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RecordUploadJobs_Status_NextAttemptAt_RequestedAt",
table: "RecordUploadJobs",
columns: new[] { "Status", "NextAttemptAt", "RequestedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RecordUploadJobs");
}
}
}
@@ -462,6 +462,102 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
b.ToTable("RecordTasks", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordUploadJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AttemptCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CurrentArtifact")
.HasColumnType("integer");
b.Property<long?>("DanmakuSizeBytes")
.HasColumnType("bigint");
b.Property<bool>("DeleteLocalFilesAfterUpload")
.HasColumnType("boolean");
b.Property<string>("ErrorMessage")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("ExternalTaskId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset?>("ExternalTaskStartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalTaskType")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset?>("NextAttemptAt")
.HasColumnType("timestamp with time zone");
b.Property<double>("ProgressPercent")
.HasColumnType("double precision");
b.Property<string>("ProviderEndpoint")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("RecordTaskId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("RequestedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceDanmakuPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SourceVideoPath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TargetDanmakuPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("TargetVideoPath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("VerificationStartedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("VideoSizeBytes")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RecordTaskId")
.IsUnique();
b.HasIndex("Status", "NextAttemptAt", "RequestedAt");
b.ToTable("RecordUploadJobs", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.SystemLogEntry", b =>
{
b.Property<Guid>("Id")
@@ -614,6 +710,17 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
b.Navigation("RecordSession");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordUploadJob", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
.WithOne("UploadJob")
.HasForeignKey("LiveRecorder.Domain.Entities.RecordUploadJob", "RecordTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RecordTask");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.UserAccount", "UserAccount")
@@ -638,6 +745,8 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.Navigation("Result");
b.Navigation("UploadJob");
});
#pragma warning restore 612, 618
}
@@ -286,6 +286,8 @@ public sealed class RecordResultRepository : IRecordResultRepository
var query = _dbContext.RecordResults
.Include(item => item.RecordTask!)
.ThenInclude(task => task.LiveRoom)
.Include(item => item.RecordTask!)
.ThenInclude(task => task.UploadJob)
.AsQueryable();
if (uploadStatusFilter.HasValue)
@@ -0,0 +1,588 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LiveRecorder.Application.Models.Settings;
namespace LiveRecorder.Infrastructure.Services;
public interface IOpenListClient
{
Task<OpenListConnectionTestDto> TestConnectionAsync(
OpenListConnectionRequest connection,
CancellationToken cancellationToken = default);
Task<OpenListDirectoryListDto> ListDirectoriesAsync(
OpenListDirectoryRequest request,
CancellationToken cancellationToken = default);
Task EnsureDirectoryAsync(
OpenListConnectionRequest connection,
string path,
CancellationToken cancellationToken = default);
Task<OpenListObjectInfo?> TryGetObjectAsync(
OpenListConnectionRequest connection,
string path,
CancellationToken cancellationToken = default);
Task<OpenListCopyResult> CopyFileAsync(
OpenListConnectionRequest connection,
string sourcePath,
string targetPath,
CancellationToken cancellationToken = default);
Task<OpenListTaskInfo?> TryGetCopyTaskAsync(
OpenListConnectionRequest connection,
string taskId,
CancellationToken cancellationToken = default);
}
public sealed record OpenListObjectInfo(
string Name,
long Size,
bool IsDirectory,
IReadOnlyDictionary<string, string> Hashes);
public sealed record OpenListCopyResult(IReadOnlyList<string> TaskIds);
public sealed record OpenListTaskInfo(
string Id,
int State,
double Progress,
string Status,
string? Error);
public sealed class OpenListClient : IOpenListClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IHttpClientFactory _httpClientFactory;
private readonly ConcurrentDictionary<string, TokenCacheEntry> _tokens = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _loginGate = new(1, 1);
public OpenListClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<OpenListConnectionTestDto> TestConnectionAsync(
OpenListConnectionRequest connection,
CancellationToken cancellationToken = default)
{
var baseUrl = NormalizeBaseUrl(connection.BaseUrl);
await GetTokenAsync(connection, forceRefresh: true, cancellationToken);
using var client = _httpClientFactory.CreateClient("openlist");
using var response = await client.GetAsync($"{baseUrl}/api/public/settings", cancellationToken);
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
EnsureSuccess(envelope, "OpenList connection test");
string? version = null;
if (envelope.Data is { ValueKind: JsonValueKind.Object } data &&
data.TryGetProperty("version", out var versionElement))
{
version = versionElement.GetString();
}
return new OpenListConnectionTestDto
{
Success = true,
Version = version,
Message = string.IsNullOrWhiteSpace(version)
? "OpenList 连接和登录成功。"
: $"OpenList 连接和登录成功:{version}"
};
}
public async Task<OpenListDirectoryListDto> ListDirectoriesAsync(
OpenListDirectoryRequest request,
CancellationToken cancellationToken = default)
{
var path = NormalizePath(request.Path);
var envelope = await SendAuthorizedAsync(
request,
() => CreateJsonRequest(
HttpMethod.Post,
"/api/fs/list",
new { path, password = string.Empty, refresh = false, page = 1, per_page = 0 }),
cancellationToken);
EnsureSuccess(envelope, $"OpenList list '{path}'");
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data)
{
throw new InvalidOperationException("OpenList list response did not contain directory data.");
}
var canWrite = data.TryGetProperty("write", out var writeElement) && writeElement.ValueKind == JsonValueKind.True;
var directories = new List<OpenListDirectoryItemDto>();
if (data.TryGetProperty("content", out var contentElement) && contentElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in contentElement.EnumerateArray())
{
if (!item.TryGetProperty("is_dir", out var isDirectoryElement) || !isDirectoryElement.GetBoolean())
{
continue;
}
var name = item.TryGetProperty("name", out var nameElement)
? nameElement.GetString()
: null;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
directories.Add(new OpenListDirectoryItemDto
{
Name = name,
Path = CombinePath(path, name)
});
}
}
return new OpenListDirectoryListDto
{
Path = path,
CanWrite = canWrite,
Directories = directories.OrderBy(static item => item.Name, StringComparer.OrdinalIgnoreCase).ToArray()
};
}
public async Task EnsureDirectoryAsync(
OpenListConnectionRequest connection,
string path,
CancellationToken cancellationToken = default)
{
path = NormalizePath(path);
if (path == "/")
{
return;
}
var accumulated = string.Empty;
foreach (var segment in SplitPath(path))
{
accumulated = CombinePath(accumulated, segment);
var existing = await TryGetObjectAsync(connection, accumulated, cancellationToken);
if (existing is not null)
{
if (!existing.IsDirectory)
{
throw new InvalidOperationException($"OpenList path '{accumulated}' exists but is not a directory.");
}
continue;
}
var currentPath = accumulated;
var envelope = await SendAuthorizedAsync(
connection,
() => CreateJsonRequest(HttpMethod.Post, "/api/fs/mkdir", new { path = currentPath }),
cancellationToken);
if (envelope.Code == 200)
{
continue;
}
if (ContainsAny(envelope.Message, "exist", "already"))
{
var racedObject = await TryGetObjectAsync(connection, currentPath, cancellationToken);
if (racedObject?.IsDirectory == true)
{
continue;
}
}
EnsureSuccess(envelope, $"OpenList mkdir '{currentPath}'");
}
}
public async Task<OpenListObjectInfo?> TryGetObjectAsync(
OpenListConnectionRequest connection,
string path,
CancellationToken cancellationToken = default)
{
path = NormalizePath(path);
var result = await TryGetObjectCoreAsync(connection, path, cancellationToken);
if (result is not null || path == "/")
{
return result;
}
// Cloud drivers can complete a server-side copy without invalidating
// OpenList's directory cache. Refreshing the parent also makes files
// written directly into a local mount visible before they are copied.
await RefreshDirectoryAsync(connection, GetDirectoryName(path), cancellationToken);
return await TryGetObjectCoreAsync(connection, path, cancellationToken);
}
private async Task<OpenListObjectInfo?> TryGetObjectCoreAsync(
OpenListConnectionRequest connection,
string path,
CancellationToken cancellationToken)
{
var envelope = await SendAuthorizedAsync(
connection,
() => CreateJsonRequest(
HttpMethod.Post,
"/api/fs/get",
new { path, password = string.Empty }),
cancellationToken);
if (envelope.Code != 200)
{
if (envelope.Code == 404 || ContainsAny(envelope.Message, "not found", "object not found", "no such file"))
{
return null;
}
EnsureSuccess(envelope, $"OpenList get '{path}'");
}
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data)
{
return null;
}
var name = data.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? string.Empty : string.Empty;
var size = data.TryGetProperty("size", out var sizeElement) && sizeElement.TryGetInt64(out var parsedSize)
? parsedSize
: 0;
var isDirectory = data.TryGetProperty("is_dir", out var isDirectoryElement) && isDirectoryElement.GetBoolean();
var hashes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (data.TryGetProperty("hash_info", out var hashElement) && hashElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in hashElement.EnumerateObject())
{
var value = property.Value.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
hashes[property.Name] = value;
}
}
}
return new OpenListObjectInfo(name, size, isDirectory, hashes);
}
private async Task RefreshDirectoryAsync(
OpenListConnectionRequest connection,
string path,
CancellationToken cancellationToken)
{
var envelope = await SendAuthorizedAsync(
connection,
() => CreateJsonRequest(
HttpMethod.Post,
"/api/fs/list",
new { path, password = string.Empty, refresh = true, page = 1, per_page = 0 }),
cancellationToken);
EnsureSuccess(envelope, $"OpenList refresh '{path}'");
}
public async Task<OpenListCopyResult> CopyFileAsync(
OpenListConnectionRequest connection,
string sourcePath,
string targetPath,
CancellationToken cancellationToken = default)
{
sourcePath = NormalizePath(sourcePath);
targetPath = NormalizePath(targetPath);
var sourceDirectory = GetDirectoryName(sourcePath);
var targetDirectory = GetDirectoryName(targetPath);
var sourceName = GetFileName(sourcePath);
var targetName = GetFileName(targetPath);
if (!string.Equals(sourceName, targetName, StringComparison.Ordinal))
{
throw new InvalidOperationException("OpenList server-side copy requires source and target file names to match.");
}
var envelope = await SendAuthorizedAsync(
connection,
() => CreateJsonRequest(
HttpMethod.Post,
"/api/fs/copy",
new
{
src_dir = sourceDirectory,
dst_dir = targetDirectory,
names = new[] { sourceName },
overwrite = false,
skip_existing = false,
merge = false
}),
cancellationToken);
EnsureSuccess(envelope, $"OpenList copy '{sourcePath}' to '{targetPath}'");
var taskIds = new List<string>();
if (envelope.Data is { ValueKind: JsonValueKind.Object } data &&
data.TryGetProperty("tasks", out var tasksElement) &&
tasksElement.ValueKind == JsonValueKind.Array)
{
foreach (var taskElement in tasksElement.EnumerateArray())
{
if (!taskElement.TryGetProperty("id", out var idElement))
{
continue;
}
var taskId = idElement.GetString();
if (!string.IsNullOrWhiteSpace(taskId))
{
taskIds.Add(taskId);
}
}
}
return new OpenListCopyResult(taskIds);
}
public async Task<OpenListTaskInfo?> TryGetCopyTaskAsync(
OpenListConnectionRequest connection,
string taskId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(taskId))
{
return null;
}
var encodedTaskId = Uri.EscapeDataString(taskId.Trim());
var envelope = await SendAuthorizedAsync(
connection,
() => new HttpRequestMessage(HttpMethod.Post, $"/api/task/copy/info?tid={encodedTaskId}"),
cancellationToken);
if (envelope.Code != 200)
{
if (envelope.Code == 404 || ContainsAny(envelope.Message, "task not found", "not found"))
{
return null;
}
EnsureSuccess(envelope, $"OpenList copy task '{taskId}'");
}
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data)
{
return null;
}
var id = data.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? taskId : taskId;
var state = data.TryGetProperty("state", out var stateElement) && stateElement.TryGetInt32(out var parsedState)
? parsedState
: -1;
var progress = data.TryGetProperty("progress", out var progressElement) && progressElement.TryGetDouble(out var parsedProgress)
? parsedProgress
: 0;
var status = data.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? string.Empty : string.Empty;
var error = data.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null;
return new OpenListTaskInfo(id, state, progress, status, error);
}
public static string NormalizeBaseUrl(string baseUrl)
{
if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("OpenList 地址必须是有效的 HTTP 或 HTTPS URL。");
}
var path = uri.AbsolutePath.TrimEnd('/');
var davIndex = path.IndexOf("/dav", StringComparison.OrdinalIgnoreCase);
if (davIndex >= 0 && (davIndex + 4 == path.Length || path[davIndex + 4] == '/'))
{
path = path[..davIndex];
}
return new UriBuilder(uri)
{
Path = path,
Query = string.Empty,
Fragment = string.Empty
}.Uri.ToString().TrimEnd('/');
}
public static string NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path) || path.Trim() == "/")
{
return "/";
}
var segments = path
.Replace('\\', '/')
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Any(static segment => segment is "." or ".."))
{
throw new InvalidOperationException("OpenList 路径不能包含 '.' 或 '..' 段。");
}
return "/" + string.Join('/', segments);
}
public static string CombinePath(params string?[] parts)
{
var segments = parts
.Where(static part => !string.IsNullOrWhiteSpace(part))
.SelectMany(static part => part!.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToArray();
return NormalizePath("/" + string.Join('/', segments));
}
private async Task<ApiEnvelope> SendAuthorizedAsync(
OpenListConnectionRequest connection,
Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 2; attempt++)
{
var forceRefresh = attempt > 0;
var token = await GetTokenAsync(connection, forceRefresh, cancellationToken);
var baseUrl = NormalizeBaseUrl(connection.BaseUrl);
using var client = _httpClientFactory.CreateClient("openlist");
using var request = requestFactory();
request.RequestUri = new Uri(baseUrl + request.RequestUri, UriKind.Absolute);
request.Headers.TryAddWithoutValidation("Authorization", token);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
if (response.StatusCode != HttpStatusCode.Unauthorized && envelope.Code != 401)
{
return envelope;
}
InvalidateToken(connection);
}
throw new InvalidOperationException("OpenList 登录状态无效,请检查账号或密码。");
}
private async Task<string> GetTokenAsync(
OpenListConnectionRequest connection,
bool forceRefresh,
CancellationToken cancellationToken)
{
var cacheKey = BuildCacheKey(connection);
if (!forceRefresh && _tokens.TryGetValue(cacheKey, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
{
return cached.Token;
}
await _loginGate.WaitAsync(cancellationToken);
try
{
if (!forceRefresh && _tokens.TryGetValue(cacheKey, out cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
{
return cached.Token;
}
var baseUrl = NormalizeBaseUrl(connection.BaseUrl);
using var client = _httpClientFactory.CreateClient("openlist");
using var response = await client.PostAsJsonAsync(
$"{baseUrl}/api/auth/login",
new { username = connection.Username?.Trim() ?? string.Empty, password = connection.Password ?? string.Empty },
JsonOptions,
cancellationToken);
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
EnsureSuccess(envelope, "OpenList login");
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data ||
!data.TryGetProperty("token", out var tokenElement) ||
string.IsNullOrWhiteSpace(tokenElement.GetString()))
{
throw new InvalidOperationException("OpenList 登录响应未包含 token。");
}
var token = tokenElement.GetString()!;
_tokens[cacheKey] = new TokenCacheEntry(token, DateTimeOffset.UtcNow.AddMinutes(20));
return token;
}
finally
{
_loginGate.Release();
}
}
private void InvalidateToken(OpenListConnectionRequest connection) =>
_tokens.TryRemove(BuildCacheKey(connection), out _);
private static string BuildCacheKey(OpenListConnectionRequest connection)
{
var raw = $"{NormalizeBaseUrl(connection.BaseUrl)}\n{connection.Username}\n{connection.Password}";
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)));
}
private static HttpRequestMessage CreateJsonRequest(HttpMethod method, string path, object payload) =>
new(method, path)
{
Content = JsonContent.Create(payload, options: JsonOptions)
};
private static async Task<ApiEnvelope> ReadEnvelopeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
{
return new ApiEnvelope((int)response.StatusCode, response.ReasonPhrase ?? "Empty response", null);
}
try
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
var code = root.TryGetProperty("code", out var codeElement) && codeElement.TryGetInt32(out var parsedCode)
? parsedCode
: (int)response.StatusCode;
var message = root.TryGetProperty("message", out var messageElement)
? messageElement.GetString() ?? body
: body;
JsonElement? data = root.TryGetProperty("data", out var dataElement)
? dataElement.Clone()
: null;
return new ApiEnvelope(code, message, data);
}
catch (JsonException ex)
{
throw new InvalidOperationException($"OpenList 返回了无效 JSONHTTP {(int)response.StatusCode}):{body}", ex);
}
}
private static void EnsureSuccess(ApiEnvelope envelope, string operation)
{
if (envelope.Code != 200)
{
throw new InvalidOperationException($"{operation} failed with code {envelope.Code}: {envelope.Message}");
}
}
private static bool ContainsAny(string? value, params string[] candidates) =>
!string.IsNullOrWhiteSpace(value) &&
candidates.Any(candidate => value.Contains(candidate, StringComparison.OrdinalIgnoreCase));
private static string[] SplitPath(string path) =>
NormalizePath(path).Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static string GetDirectoryName(string path)
{
var normalized = NormalizePath(path);
var index = normalized.LastIndexOf('/');
return index <= 0 ? "/" : normalized[..index];
}
private static string GetFileName(string path)
{
var normalized = NormalizePath(path);
var index = normalized.LastIndexOf('/');
var name = normalized[(index + 1)..];
if (string.IsNullOrWhiteSpace(name))
{
throw new InvalidOperationException($"OpenList path '{path}' does not contain a file name.");
}
return name;
}
private sealed record TokenCacheEntry(string Token, DateTimeOffset ExpiresAt);
private sealed record ApiEnvelope(int Code, string Message, JsonElement? Data);
}
@@ -0,0 +1,784 @@
using System.Security.Cryptography;
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;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class OpenListUploadQueueService
{
private const int MaxAttempts = 6;
private static readonly TimeSpan VerificationTimeout = TimeSpan.FromMinutes(2);
private static readonly TimeSpan ExternalTaskTimeout = TimeSpan.FromHours(24);
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(15),
TimeSpan.FromHours(1),
TimeSpan.FromHours(6)
];
private readonly LiveRecorderDbContext _dbContext;
private readonly ISystemSettingsService _settingsService;
private readonly IOpenListClient _openListClient;
private readonly ISystemLogService _systemLogService;
public OpenListUploadQueueService(
LiveRecorderDbContext dbContext,
ISystemSettingsService settingsService,
IOpenListClient openListClient,
ISystemLogService systemLogService)
{
_dbContext = dbContext;
_settingsService = settingsService;
_openListClient = openListClient;
_systemLogService = systemLogService;
}
public async Task<RecordArtifactUploadItemResultDto?> TryEnqueueAutomaticAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
if (!settings.EnableFileUpload ||
!settings.EnableAutoUpload ||
settings.UploadTarget != UploadTargetType.OpenList)
{
return null;
}
return await EnqueueInternalAsync(recordTaskId, settings, cancellationToken);
}
public async Task<RecordArtifactUploadItemResultDto> EnqueueAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
if (!settings.EnableFileUpload || settings.UploadTarget != UploadTargetType.OpenList)
{
return Failure(recordTaskId, "OpenList 上传未启用。", "openlist");
}
return await EnqueueInternalAsync(recordTaskId, settings, cancellationToken);
}
public async Task<RecordArtifactUploadBatchResultDto> EnqueueSessionAsync(
Guid recordSessionId,
CancellationToken cancellationToken = default)
{
var taskIds = await _dbContext.RecordTasks
.AsNoTracking()
.Where(item => item.RecordSessionId == recordSessionId)
.OrderBy(static item => item.SegmentIndex)
.ThenBy(static item => item.CreatedAt)
.Select(static item => item.Id)
.ToArrayAsync(cancellationToken);
if (taskIds.Length == 0)
{
return new RecordArtifactUploadBatchResultDto
{
RequestedCount = 0,
SuccessCount = 0,
FailedCount = 1,
Items = [Failure(Guid.Empty, "录制会话不存在或没有可上传分片。", "openlist")]
};
}
var items = new List<RecordArtifactUploadItemResultDto>(taskIds.Length);
foreach (var taskId in taskIds)
{
items.Add(await EnqueueAsync(taskId, cancellationToken));
}
return new RecordArtifactUploadBatchResultDto
{
RequestedCount = taskIds.Length,
SuccessCount = items.Count(static item => item.Success),
FailedCount = items.Count(static item => !item.Success),
Items = items
};
}
public async Task<bool> ProcessNextAsync(CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var jobs = _dbContext.RecordUploadJobs
.Include(static item => item.RecordTask)
.ThenInclude(static item => item!.Result)
.Include(static item => item.RecordTask)
.ThenInclude(static item => item!.LiveRoom);
var job = await jobs
.Where(item =>
item.Status == RecordArtifactUploadStatus.Uploading ||
item.Status == RecordArtifactUploadStatus.Queued)
.OrderByDescending(static item => item.Status == RecordArtifactUploadStatus.Uploading)
.ThenBy(static item => item.RequestedAt)
.FirstOrDefaultAsync(cancellationToken);
if (job is null)
{
var waitingJobs = await jobs
.Where(static item => item.Status == RecordArtifactUploadStatus.WaitingRetry)
.ToListAsync(cancellationToken);
job = waitingJobs
.Where(item => !item.NextAttemptAt.HasValue || item.NextAttemptAt <= now)
.OrderBy(item => item.NextAttemptAt ?? DateTimeOffset.MinValue)
.ThenBy(static item => item.RequestedAt)
.FirstOrDefault();
}
if (job?.RecordTask?.Result is null)
{
return false;
}
var result = job.RecordTask.Result;
if (job.Status != RecordArtifactUploadStatus.Uploading)
{
job.BeginAttempt(now);
result.MarkUploadStarted("openlist", now);
await _dbContext.SaveChangesAsync(cancellationToken);
}
try
{
var settings = await _settingsService.GetAsync(cancellationToken);
var connection = new OpenListConnectionRequest
{
BaseUrl = job.ProviderEndpoint,
Username = settings.OpenListUpload.Username,
Password = settings.OpenListUpload.Password
};
await ProcessJobStepAsync(job, result, connection, cancellationToken);
}
catch (OpenListUploadConflictException ex)
{
await MarkFailedAsync(job, result, ex.Message, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
await ScheduleRetryOrFailAsync(job, result, ex.Message, clearExternalTask: false, cancellationToken);
}
return true;
}
private async Task<RecordArtifactUploadItemResultDto> EnqueueInternalAsync(
Guid recordTaskId,
SystemSettingsDto settings,
CancellationToken cancellationToken)
{
ValidateSettings(settings.OpenListUpload);
var recordTask = await _dbContext.RecordTasks
.Include(static item => item.Result)
.Include(static item => item.UploadJob)
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
if (recordTask?.Result is null)
{
return Failure(recordTaskId, "录制结果尚未生成,不能上传。", "openlist");
}
var result = recordTask.Result;
var localVideoPath = NormalizeAbsolutePath(result.FilePath);
if (string.IsNullOrWhiteSpace(localVideoPath) || !File.Exists(localVideoPath))
{
if (result.UploadStatus == RecordArtifactUploadStatus.Succeeded)
{
return SuccessFromExisting(recordTaskId, result);
}
return Failure(recordTaskId, "本地视频文件不存在,不能加入上传队列。", "openlist");
}
var outputRoot = Path.GetFullPath(settings.OutputRoot, AppContext.BaseDirectory);
var videoRelativePath = GetSafeRelativePath(outputRoot, localVideoPath);
var sourceVideoPath = OpenListClient.CombinePath(settings.OpenListUpload.SourcePath, videoRelativePath);
var targetVideoPath = OpenListClient.CombinePath(settings.OpenListUpload.DestinationPath, videoRelativePath);
string? sourceDanmakuPath = null;
string? targetDanmakuPath = null;
long? danmakuSizeBytes = null;
var localDanmakuPath = NormalizeNullableAbsolutePath(result.DanmakuFilePath);
if (!string.IsNullOrWhiteSpace(localDanmakuPath) && File.Exists(localDanmakuPath))
{
var danmakuRelativePath = GetSafeRelativePath(outputRoot, localDanmakuPath);
sourceDanmakuPath = OpenListClient.CombinePath(settings.OpenListUpload.SourcePath, danmakuRelativePath);
targetDanmakuPath = OpenListClient.CombinePath(settings.OpenListUpload.DestinationPath, danmakuRelativePath);
danmakuSizeBytes = new FileInfo(localDanmakuPath).Length;
}
if (string.Equals(sourceVideoPath, targetVideoPath, StringComparison.Ordinal))
{
return Failure(recordTaskId, "OpenList 源路径和目标路径不能相同。", "openlist");
}
var now = DateTimeOffset.UtcNow;
var endpoint = OpenListClient.NormalizeBaseUrl(settings.OpenListUpload.BaseUrl);
var videoSizeBytes = new FileInfo(localVideoPath).Length;
var job = recordTask.UploadJob;
if (job is null)
{
job = new RecordUploadJob(
recordTask.Id,
endpoint,
sourceVideoPath,
targetVideoPath,
videoSizeBytes,
sourceDanmakuPath,
targetDanmakuPath,
danmakuSizeBytes,
settings.DeleteLocalFilesAfterUpload,
now);
await _dbContext.RecordUploadJobs.AddAsync(job, cancellationToken);
}
else if (job.Status == RecordArtifactUploadStatus.Succeeded)
{
return SuccessFromExisting(recordTaskId, result);
}
else if (job.Status is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.Uploading or RecordArtifactUploadStatus.WaitingRetry)
{
return QueuedResult(recordTaskId, result, job, "该分片已在 OpenList 上传队列中。");
}
else
{
job.RefreshRequest(
endpoint,
sourceVideoPath,
targetVideoPath,
videoSizeBytes,
sourceDanmakuPath,
targetDanmakuPath,
danmakuSizeBytes,
settings.DeleteLocalFilesAfterUpload,
now);
}
result.MarkUploadQueued("openlist", now);
await _dbContext.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"Upload",
"OpenList upload job queued.",
$"video={targetVideoPath}; danmaku={targetDanmakuPath ?? "none"}",
liveRoomId: recordTask.LiveRoomId,
recordSessionId: recordTask.RecordSessionId,
recordTaskId: recordTask.Id,
cancellationToken: cancellationToken);
return QueuedResult(recordTaskId, result, job, "已加入 OpenList 上传队列。");
}
private async Task ProcessJobStepAsync(
RecordUploadJob job,
RecordResult result,
OpenListConnectionRequest connection,
CancellationToken cancellationToken)
{
if (job.CurrentArtifact == RecordUploadArtifactStage.Completed)
{
await CompleteJobAsync(job, result, cancellationToken);
return;
}
var now = DateTimeOffset.UtcNow;
var targetPath = job.GetCurrentTargetPath();
var expectedSize = job.GetCurrentSizeBytes();
var localPath = GetCurrentLocalPath(job, result);
if (job.VerificationStartedAt.HasValue)
{
var verification = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken);
if (verification == TargetVerification.Match)
{
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
return;
}
if (verification == TargetVerification.Conflict)
{
throw new OpenListUploadConflictException($"目标文件 '{targetPath}' 已存在但内容不一致。");
}
if (now - job.VerificationStartedAt.Value < VerificationTimeout)
{
return;
}
await ScheduleRetryOrFailAsync(job, result, $"OpenList 任务结束后两分钟内仍未发现目标文件 '{targetPath}'。", true, cancellationToken);
return;
}
if (!string.IsNullOrWhiteSpace(job.ExternalTaskId))
{
if (job.ExternalTaskStartedAt.HasValue && now - job.ExternalTaskStartedAt.Value > ExternalTaskTimeout)
{
await ScheduleRetryOrFailAsync(job, result, $"OpenList 复制任务运行超过 24 小时:{job.ExternalTaskId}", true, cancellationToken);
return;
}
var task = await _openListClient.TryGetCopyTaskAsync(connection, job.ExternalTaskId, cancellationToken);
if (task is null)
{
var missingTaskVerification = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken);
if (missingTaskVerification == TargetVerification.Match)
{
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
}
else if (missingTaskVerification == TargetVerification.Conflict)
{
throw new OpenListUploadConflictException($"目标文件 '{targetPath}' 已存在但内容不一致。");
}
else
{
await ScheduleRetryOrFailAsync(job, result, $"OpenList 复制任务不存在:{job.ExternalTaskId}", true, cancellationToken);
}
return;
}
job.SetProgress(job.CalculateOverallProgress(task.Progress), now);
if (task.State == 2)
{
job.StartVerification(now);
await _dbContext.SaveChangesAsync(cancellationToken);
await ProcessJobStepAsync(job, result, connection, cancellationToken);
return;
}
if (task.State is 4 or 7)
{
var error = string.IsNullOrWhiteSpace(task.Error)
? $"OpenList 复制任务以状态 {task.State} 结束。"
: task.Error;
await ScheduleRetryOrFailAsync(job, result, error, true, cancellationToken);
return;
}
await _dbContext.SaveChangesAsync(cancellationToken);
return;
}
var existingTarget = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken);
if (existingTarget == TargetVerification.Match)
{
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
return;
}
if (existingTarget == TargetVerification.Conflict)
{
throw new OpenListUploadConflictException($"目标文件 '{targetPath}' 已存在但内容不一致。");
}
result.MarkUploadStarted("openlist", now);
await _dbContext.SaveChangesAsync(cancellationToken);
var sourcePath = job.GetCurrentSourcePath();
var sourceObject = await _openListClient.TryGetObjectAsync(connection, sourcePath, cancellationToken);
if (sourceObject is null || sourceObject.IsDirectory)
{
throw new InvalidOperationException($"OpenList 源文件不存在:{sourcePath}");
}
if (sourceObject.Size != expectedSize)
{
throw new InvalidOperationException($"OpenList 源文件大小不一致:期望 {expectedSize},实际 {sourceObject.Size},路径 {sourcePath}");
}
await _openListClient.EnsureDirectoryAsync(connection, GetDirectoryName(targetPath), cancellationToken);
var copyResult = await _openListClient.CopyFileAsync(connection, sourcePath, targetPath, cancellationToken);
if (copyResult.TaskIds.Count == 0)
{
job.StartVerification(DateTimeOffset.UtcNow);
}
else
{
job.TrackExternalTask(copyResult.TaskIds[0], "copy", job.ProgressPercent, DateTimeOffset.UtcNow);
}
await _dbContext.SaveChangesAsync(cancellationToken);
}
private async Task CompleteCurrentArtifactAsync(
RecordUploadJob job,
RecordResult result,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
if (job.CurrentArtifact == RecordUploadArtifactStage.Video)
{
result.MarkRemoteVideoUploaded(job.TargetVideoPath, now);
}
else if (job.CurrentArtifact == RecordUploadArtifactStage.Danmaku && job.TargetDanmakuPath is not null)
{
result.MarkRemoteDanmakuUploaded(job.TargetDanmakuPath, now);
}
job.CompleteCurrentArtifact(now);
await _dbContext.SaveChangesAsync(cancellationToken);
if (job.CurrentArtifact == RecordUploadArtifactStage.Completed)
{
await CompleteJobAsync(job, result, cancellationToken);
}
}
private async Task CompleteJobAsync(
RecordUploadJob job,
RecordResult result,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var deletedLocalFiles = false;
string? cleanupWarning = null;
if (job.DeleteLocalFilesAfterUpload)
{
try
{
deletedLocalFiles = DeleteLocalArtifacts(job, result);
}
catch (Exception ex)
{
cleanupWarning = ex.Message;
}
}
job.MarkSucceeded(now);
result.MarkUploadSucceeded(
"openlist",
job.TargetVideoPath,
job.TargetDanmakuPath,
deletedLocalFiles,
now);
await _dbContext.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
cleanupWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning,
"Upload",
"OpenList artifact upload completed.",
$"video={job.TargetVideoPath}; danmaku={job.TargetDanmakuPath ?? "none"}; deletedLocalFiles={deletedLocalFiles}" +
(cleanupWarning is null ? string.Empty : $"; cleanupWarning={cleanupWarning}"),
liveRoomId: job.RecordTask?.LiveRoomId,
recordSessionId: job.RecordTask?.RecordSessionId,
recordTaskId: job.RecordTaskId,
cancellationToken: cancellationToken);
}
private async Task ScheduleRetryOrFailAsync(
RecordUploadJob job,
RecordResult result,
string error,
bool clearExternalTask,
CancellationToken cancellationToken)
{
if (job.AttemptCount >= MaxAttempts)
{
await MarkFailedAsync(job, result, error, cancellationToken);
return;
}
var now = DateTimeOffset.UtcNow;
var delayIndex = Math.Clamp(Math.Max(1, job.AttemptCount) - 1, 0, RetryDelays.Length - 1);
var nextAttemptAt = now.Add(RetryDelays[delayIndex]);
job.ScheduleRetry(error, nextAttemptAt, now, clearExternalTask);
result.MarkUploadWaitingRetry("openlist", error, now);
await _dbContext.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Upload",
"OpenList upload will retry.",
$"attempt={job.AttemptCount}/{MaxAttempts}; nextAttemptAt={nextAttemptAt:O}; error={error}",
liveRoomId: job.RecordTask?.LiveRoomId,
recordSessionId: job.RecordTask?.RecordSessionId,
recordTaskId: job.RecordTaskId,
cancellationToken: cancellationToken);
}
private async Task MarkFailedAsync(
RecordUploadJob job,
RecordResult result,
string error,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
job.MarkFailed(error, now);
result.MarkUploadFailed("openlist", error, now);
await _dbContext.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Error,
"Upload",
"OpenList upload failed permanently.",
error,
liveRoomId: job.RecordTask?.LiveRoomId,
recordSessionId: job.RecordTask?.RecordSessionId,
recordTaskId: job.RecordTaskId,
cancellationToken: cancellationToken);
}
private async Task<TargetVerification> VerifyTargetAsync(
OpenListConnectionRequest connection,
string targetPath,
string localPath,
long expectedSize,
CancellationToken cancellationToken)
{
var remote = await _openListClient.TryGetObjectAsync(connection, targetPath, cancellationToken);
if (remote is null)
{
return TargetVerification.Missing;
}
if (remote.IsDirectory || remote.Size != expectedSize)
{
return TargetVerification.Conflict;
}
var comparableHash = remote.Hashes
.FirstOrDefault(pair => pair.Key.Equals("sha256", StringComparison.OrdinalIgnoreCase) ||
pair.Key.Equals("sha1", StringComparison.OrdinalIgnoreCase) ||
pair.Key.Equals("md5", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(comparableHash.Key) || string.IsNullOrWhiteSpace(comparableHash.Value))
{
return TargetVerification.Match;
}
var localHash = await ComputeFileHashAsync(localPath, comparableHash.Key, cancellationToken);
return string.Equals(localHash, comparableHash.Value, StringComparison.OrdinalIgnoreCase)
? TargetVerification.Match
: TargetVerification.Conflict;
}
private static async Task<string> ComputeFileHashAsync(
string localPath,
string hashName,
CancellationToken cancellationToken)
{
using HashAlgorithm algorithm = hashName.ToLowerInvariant() switch
{
"md5" => MD5.Create(),
"sha1" => SHA1.Create(),
"sha256" => SHA256.Create(),
_ => throw new InvalidOperationException($"不支持的哈希类型:{hashName}")
};
await using var stream = new FileStream(
localPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 1024 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
var hash = await algorithm.ComputeHashAsync(stream, cancellationToken);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string GetCurrentLocalPath(RecordUploadJob job, RecordResult result) =>
job.CurrentArtifact switch
{
RecordUploadArtifactStage.Video => NormalizeAbsolutePath(result.FilePath),
RecordUploadArtifactStage.Danmaku => NormalizeNullableAbsolutePath(result.DanmakuFilePath)
?? throw new InvalidOperationException("本地弹幕文件路径不存在。"),
_ => throw new InvalidOperationException("上传作业没有待处理产物。")
};
private static bool DeleteLocalArtifacts(RecordUploadJob job, RecordResult result)
{
var paths = new List<string> { NormalizeAbsolutePath(result.FilePath) };
if (!string.IsNullOrWhiteSpace(job.SourceDanmakuPath))
{
var danmakuPath = NormalizeNullableAbsolutePath(result.DanmakuFilePath);
if (!string.IsNullOrWhiteSpace(danmakuPath))
{
paths.Add(danmakuPath);
}
}
var artifactPaths = paths.Where(static path => !string.IsNullOrWhiteSpace(path)).ToArray();
foreach (var path in artifactPaths)
{
if (File.Exists(path))
{
File.Delete(path);
}
}
return artifactPaths.Length > 0 && artifactPaths.All(static path => !File.Exists(path));
}
private static void ValidateSettings(OpenListUploadSettingsDto settings)
{
_ = OpenListClient.NormalizeBaseUrl(settings.BaseUrl);
if (string.IsNullOrWhiteSpace(settings.Username) || string.IsNullOrWhiteSpace(settings.Password))
{
throw new InvalidOperationException("OpenList 用户名或密码未配置。");
}
if (string.IsNullOrWhiteSpace(settings.SourcePath) || string.IsNullOrWhiteSpace(settings.DestinationPath))
{
throw new InvalidOperationException("请选择 OpenList 源挂载根目录和目标归档根目录。");
}
_ = OpenListClient.NormalizePath(settings.SourcePath);
_ = OpenListClient.NormalizePath(settings.DestinationPath);
}
private static string GetSafeRelativePath(string outputRoot, string absolutePath)
{
var relativePath = Path.GetRelativePath(outputRoot, absolutePath);
if (relativePath == ".." ||
relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
Path.IsPathRooted(relativePath))
{
throw new InvalidOperationException($"录制文件不在输出根目录内:{absolutePath}");
}
return relativePath.Replace('\\', '/');
}
private static string NormalizeAbsolutePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
return Path.IsPathRooted(path)
? Path.GetFullPath(path)
: Path.GetFullPath(path, AppContext.BaseDirectory);
}
private static string? NormalizeNullableAbsolutePath(string? path) =>
string.IsNullOrWhiteSpace(path) ? null : NormalizeAbsolutePath(path);
private static string GetDirectoryName(string path)
{
var normalized = OpenListClient.NormalizePath(path);
var index = normalized.LastIndexOf('/');
return index <= 0 ? "/" : normalized[..index];
}
private static RecordArtifactUploadItemResultDto Failure(Guid recordTaskId, string message, string provider) => new()
{
RecordTaskId = recordTaskId,
Success = false,
Message = message,
Provider = provider
};
private static RecordArtifactUploadItemResultDto SuccessFromExisting(Guid recordTaskId, RecordResult result) => new()
{
RecordTaskId = recordTaskId,
Success = true,
Message = "该分片已上传。",
Provider = result.LastUploadProvider,
RemoteVideoPath = result.RemoteVideoPath,
RemoteDanmakuPath = result.RemoteDanmakuPath,
DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload,
UploadStatus = result.UploadStatus,
ProgressPercent = 100
};
private static RecordArtifactUploadItemResultDto QueuedResult(
Guid recordTaskId,
RecordResult result,
RecordUploadJob job,
string message) => new()
{
RecordTaskId = recordTaskId,
Success = true,
Message = message,
Provider = "openlist",
RemoteVideoPath = result.RemoteVideoPath,
RemoteDanmakuPath = result.RemoteDanmakuPath,
DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload,
UploadStatus = job.Status,
ProgressPercent = job.ProgressPercent,
AttemptCount = job.AttemptCount,
NextAttemptAt = job.NextAttemptAt
};
private enum TargetVerification
{
Missing,
Match,
Conflict
}
private sealed class OpenListUploadConflictException : Exception
{
public OpenListUploadConflictException(string message)
: base(message)
{
}
}
}
public sealed class OpenListUploadBackgroundService : BackgroundService
{
private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(2);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OpenListUploadBackgroundService> _logger;
public OpenListUploadBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<OpenListUploadBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var queue = scope.ServiceProvider.GetRequiredService<OpenListUploadQueueService>();
var processed = await queue.ProcessNextAsync(stoppingToken);
if (!processed)
{
await Task.Delay(IdleDelay, stoppingToken);
}
else
{
await Task.Delay(IdleDelay, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "OpenList upload background worker failed");
try
{
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}
}
@@ -18,15 +18,18 @@ public sealed class RecordUploadService
private readonly LiveRecorderDbContext _dbContext;
private readonly ISystemSettingsService _systemSettingsService;
private readonly ISystemLogService _systemLogService;
private readonly OpenListUploadQueueService _openListUploadQueue;
public RecordUploadService(
LiveRecorderDbContext dbContext,
ISystemSettingsService systemSettingsService,
ISystemLogService systemLogService)
ISystemLogService systemLogService,
OpenListUploadQueueService openListUploadQueue)
{
_dbContext = dbContext;
_systemSettingsService = systemSettingsService;
_systemLogService = systemLogService;
_openListUploadQueue = openListUploadQueue;
}
public async Task<RecordArtifactUploadItemResultDto?> TryAutoUploadTaskAsync(
@@ -39,6 +42,11 @@ public sealed class RecordUploadService
return null;
}
if (settings.UploadTarget == UploadTargetType.OpenList)
{
return await _openListUploadQueue.TryEnqueueAutomaticAsync(recordTaskId, cancellationToken);
}
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: true, cancellationToken);
}
@@ -47,6 +55,11 @@ public sealed class RecordUploadService
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (settings.UploadTarget == UploadTargetType.OpenList)
{
return await _openListUploadQueue.EnqueueAsync(recordTaskId, cancellationToken);
}
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: false, cancellationToken);
}
@@ -55,6 +68,11 @@ public sealed class RecordUploadService
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (settings.UploadTarget == UploadTargetType.OpenList)
{
return await _openListUploadQueue.EnqueueSessionAsync(recordSessionId, cancellationToken);
}
var session = await _dbContext.RecordSessions
.AsNoTracking()
.Include(item => item.RecordTasks)
@@ -104,8 +104,14 @@ public sealed class RecordSessionsController : ControllerBase
Ok(await _cleanupOperationCoordinator.EnqueueEmptyAsync(request, cancellationToken));
[HttpPost("{id:guid}/upload")]
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken)
{
var result = await _recordUploadService.UploadSessionAsync(id, cancellationToken);
return result.Items.Any(static item =>
item.UploadStatus is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.WaitingRetry)
? Accepted(result)
: Ok(result);
}
[HttpGet("{id:guid}/danmaku")]
public async Task<ActionResult<SessionDanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
@@ -56,6 +56,9 @@ public sealed class RecordTasksController : ControllerBase
var notUploadedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.NotUploaded, cancellationToken);
var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, cancellationToken);
var failedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, cancellationToken);
var queuedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Queued, cancellationToken);
var uploadingCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Uploading, cancellationToken);
var waitingRetryCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.WaitingRetry, cancellationToken);
return Ok(new UploadTaskListResponse
{
@@ -63,7 +66,10 @@ public sealed class RecordTasksController : ControllerBase
TotalCount = totalCount,
NotUploadedCount = notUploadedCount,
SucceededCount = succeededCount,
FailedCount = failedCount
FailedCount = failedCount,
QueuedCount = queuedCount,
UploadingCount = uploadingCount,
WaitingRetryCount = waitingRetryCount
});
}
@@ -71,6 +77,7 @@ public sealed class RecordTasksController : ControllerBase
{
var (result, task) = pair;
var liveRoom = task.LiveRoom;
var uploadJob = task.UploadJob;
return new UploadTaskItemDto
{
@@ -92,7 +99,12 @@ public sealed class RecordTasksController : ControllerBase
LastUploadedAt = result.LastUploadedAt,
UploadErrorMessage = result.UploadErrorMessage,
DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload,
CreatedAt = result.CreatedAt
CreatedAt = result.CreatedAt,
UploadProgressPercent = uploadJob?.ProgressPercent,
UploadAttemptCount = uploadJob?.AttemptCount ?? 0,
NextUploadAttemptAt = uploadJob?.NextAttemptAt,
CurrentUploadArtifact = uploadJob?.CurrentArtifact.ToString(),
ExternalUploadTaskId = uploadJob?.ExternalTaskId
};
}
@@ -158,8 +170,13 @@ public sealed class RecordTasksController : ControllerBase
Ok(await _recordService.TriggerSegmentCompletedEventAsync(id, cancellationToken));
[HttpPost("{id:guid}/upload")]
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordUploadService.UploadTaskAsync(id, cancellationToken));
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken)
{
var result = await _recordUploadService.UploadTaskAsync(id, cancellationToken);
return result.UploadStatus is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.WaitingRetry
? Accepted(result)
: Ok(result);
}
[HttpGet("{id:guid}/danmaku")]
public async Task<ActionResult<DanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
@@ -21,19 +21,22 @@ public sealed class SettingsController : ControllerBase
private readonly IEventScriptService _eventScriptService;
private readonly IWebhookNotificationService _webhookNotificationService;
private readonly RetentionCleanupService _retentionCleanupService;
private readonly IOpenListClient _openListClient;
public SettingsController(
ISystemSettingsService systemSettingsService,
IEmailNotificationService emailNotificationService,
IEventScriptService eventScriptService,
IWebhookNotificationService webhookNotificationService,
RetentionCleanupService retentionCleanupService)
RetentionCleanupService retentionCleanupService,
IOpenListClient openListClient)
{
_systemSettingsService = systemSettingsService;
_emailNotificationService = emailNotificationService;
_eventScriptService = eventScriptService;
_webhookNotificationService = webhookNotificationService;
_retentionCleanupService = retentionCleanupService;
_openListClient = openListClient;
}
[HttpGet]
@@ -102,6 +105,18 @@ public sealed class SettingsController : ControllerBase
return Ok(operation);
}
[HttpPost("openlist/test")]
public async Task<ActionResult<OpenListConnectionTestDto>> TestOpenList(
[FromBody] OpenListConnectionRequest request,
CancellationToken cancellationToken) =>
Ok(await _openListClient.TestConnectionAsync(request, cancellationToken));
[HttpPost("openlist/directories")]
public async Task<ActionResult<OpenListDirectoryListDto>> ListOpenListDirectories(
[FromBody] OpenListDirectoryRequest request,
CancellationToken cancellationToken) =>
Ok(await _openListClient.ListDirectoriesAsync(request, cancellationToken));
private static void ApplyLegacyPlatformSettings(JsonElement root, UpdateSystemSettingsRequest request)
{
if (root.ValueKind != JsonValueKind.Object)
+19
View File
@@ -123,6 +123,22 @@ builder.Services.AddHttpClient("bilibili", client =>
}
});
builder.Services.AddHttpClient("openlist", client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestVersion = HttpVersion.Version11;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
})
.ConfigurePrimaryHttpMessageHandler(static () => new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 4,
ConnectTimeout = TimeSpan.FromSeconds(10),
UseCookies = false
});
var defaultConnection = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required.");
@@ -191,6 +207,8 @@ builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<PlatformHttpClientFactory>();
builder.Services.AddScoped<PlatformHttpRequestService>();
builder.Services.AddScoped<RecordUploadService>();
builder.Services.AddScoped<OpenListUploadQueueService>();
builder.Services.AddSingleton<IOpenListClient, OpenListClient>();
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
builder.Services.AddScoped<IVideoMetadataService, FfmpegVideoMetadataService>();
builder.Services.AddScoped<BandwidthStatisticsService>();
@@ -226,6 +244,7 @@ builder.Services.AddSingleton<ILiveRoomPollingSignal>(provider => provider.GetRe
builder.Services.AddHostedService(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
builder.Services.AddHostedService<CleanupOperationBackgroundService>();
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
builder.Services.AddHostedService<OpenListUploadBackgroundService>();
var app = builder.Build();