feat: add OpenList upload support and upload tasks monitoring page

Backend:
- Add OpenListRecordArtifactUploader implementing AList-compatible API upload
- Add Uploading status to RecordArtifactUploadStatus enum
- Add MarkUploadStarted() to RecordResult entity for upload progress tracking
- Add ListUploadStatus API endpoint with pagination and status filtering
- Add UploadTaskItemDto and UploadTaskListResponse models
- Add upload segment count stats (uploaded/failed/uploading) to session DTO
- Add OpenListUploadSettingsDto and upload target type OpenList

Frontend:
- Add UploadTasksView page with route /upload-tasks
- Add upload status labels and UploadTaskItem types
- Refactor MainLayout navigation and clean up main.css
- Polish DashboardView, MetricCard, StatusBadge, RightDrawer components
- Update SettingsView to support OpenList upload configuration

Build:
- Add frontend/Dockerfile.arm64 for ARM64 frontend image
- Update build-arm64-image.sh script

Other:
- Add segment_completed_openlist.sh trigger script
- Add prototype/ directory with UI mockups
- Add frontend .dockerignore refinements

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:14:50 +08:00
co-authored by Claude Fable 5
parent f8abae0a7a
commit 9df174bb0b
44 changed files with 3962 additions and 1827 deletions
@@ -90,6 +90,16 @@ public interface IRecordResultRepository
Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default);
Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
RecordArtifactUploadStatus? uploadStatusFilter,
int skip,
int take,
CancellationToken cancellationToken = default);
Task<int> CountUploadStatusAsync(
RecordArtifactUploadStatus? uploadStatusFilter,
CancellationToken cancellationToken = default);
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
void Update(RecordResult recordResult);
@@ -42,6 +42,12 @@ public sealed class RecordSessionDto
public int TotalDanmakuMessageCount { get; init; }
public int UploadedSegmentCount { get; init; }
public int FailedUploadSegmentCount { get; init; }
public int UploadingSegmentCount { get; init; }
public required IReadOnlyList<RecordTaskDto> Tasks { get; init; }
}
@@ -85,6 +85,8 @@ public sealed class RecordTaskDto
public double? DurationSeconds { get; init; }
public RecordArtifactUploadStatus? UploadStatus { get; init; }
public string? PostProcessStage { get; init; }
public double? PostProcessProgressPercent { get; init; }
@@ -169,3 +171,57 @@ public sealed class ManualSegmentCompletedTriggerResultDto
public required string Message { get; init; }
}
public sealed class UploadTaskItemDto
{
public Guid RecordTaskId { get; init; }
public Guid RecordSessionId { get; init; }
public Guid LiveRoomId { get; init; }
public required string LiveRoomTitle { get; init; }
public int Platform { get; init; }
public required string RoomId { get; init; }
public int SegmentIndex { get; init; }
public string OutputFormat { get; init; } = string.Empty;
public string? FilePath { get; init; }
public long? FileSizeBytes { get; init; }
public string? DanmakuFilePath { get; init; }
public 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; }
}
public sealed class UploadTaskListResponse
{
public IReadOnlyList<UploadTaskItemDto> Items { get; init; } = [];
public int TotalCount { get; init; }
public int NotUploadedCount { get; init; }
public int SucceededCount { get; init; }
public int FailedCount { get; init; }
}
@@ -82,6 +82,17 @@ public sealed class S3UploadSettingsDto
public bool ForcePathStyle { get; set; }
}
public sealed class OpenListUploadSettingsDto
{
public string BaseUrl { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string BasePath { get; set; } = string.Empty;
}
public sealed class SystemSettingsDto
{
public string FfmpegPath { get; set; } = "ffmpeg";
@@ -155,6 +166,8 @@ public sealed class SystemSettingsDto
public S3UploadSettingsDto S3Upload { get; set; } = new();
public OpenListUploadSettingsDto OpenListUpload { get; set; } = new();
public bool EnableEventScripts { get; set; } = false;
public bool EnableLiveStartedScript { get; set; } = false;
@@ -434,6 +447,8 @@ public sealed class UpdateSystemSettingsRequest
public S3UploadSettingsDto S3Upload { get; set; } = new();
public OpenListUploadSettingsDto OpenListUpload { get; set; } = new();
public bool EnableEventScripts { get; set; } = false;
public bool EnableLiveStartedScript { get; set; } = false;
@@ -27,6 +27,7 @@ internal static class RecordModelMapper
StartedAt = recordTask.StartedAt,
EndedAt = recordTask.EndedAt,
DurationSeconds = recordTask.DurationSeconds,
UploadStatus = recordTask.Result?.UploadStatus,
PostProcessStage = runtimeState?.Stage,
PostProcessProgressPercent = runtimeState?.ProgressPercent,
PostProcessDetail = runtimeState?.Detail
@@ -87,6 +88,9 @@ internal static class RecordModelMapper
EndedAt = recordSession.EndedAt,
TotalFileSizeBytes = totalFileSizeBytes,
TotalDanmakuMessageCount = totalDanmakuMessageCount,
UploadedSegmentCount = orderedTasks.Count(item => item.Result?.UploadStatus == RecordArtifactUploadStatus.Succeeded),
FailedUploadSegmentCount = orderedTasks.Count(item => item.Result?.UploadStatus == RecordArtifactUploadStatus.Failed),
UploadingSegmentCount = orderedTasks.Count(item => item.Result?.UploadStatus == RecordArtifactUploadStatus.Uploading),
Tasks = orderedTasks.Select(item => MapTaskWithFallback(
item,
recordSession,
@@ -122,6 +126,7 @@ internal static class RecordModelMapper
StartedAt = recordTask.StartedAt,
EndedAt = recordTask.EndedAt,
DurationSeconds = recordTask.DurationSeconds,
UploadStatus = recordTask.Result?.UploadStatus,
PostProcessStage = runtimeState?.Stage,
PostProcessProgressPercent = runtimeState?.ProgressPercent,
PostProcessDetail = runtimeState?.Detail
@@ -54,6 +54,10 @@ public sealed class SystemSettingsService : ISystemSettingsService
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 OpenListBaseUrlKey = "upload.openlist.base_url";
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 DouyinProxyEnabledKey = "platform_proxy.douyin.enabled";
private const string DouyinProxyUrlKey = "platform_proxy.douyin.url";
private const string BilibiliProxyEnabledKey = "platform_proxy.bilibili.enabled";
@@ -180,6 +184,13 @@ public sealed class SystemSettingsService : ISystemSettingsService
Prefix = GetValue(lookup, S3PrefixKey, string.Empty),
ForcePathStyle = bool.TryParse(GetValue(lookup, S3ForcePathStyleKey, "false"), out var s3ForcePathStyle) && s3ForcePathStyle
},
OpenListUpload = new OpenListUploadSettingsDto
{
BaseUrl = GetValue(lookup, OpenListBaseUrlKey, string.Empty),
Username = GetValue(lookup, OpenListUsernameKey, string.Empty),
Password = GetValue(lookup, OpenListPasswordKey, string.Empty),
BasePath = GetValue(lookup, OpenListBasePathKey, string.Empty)
},
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
EnableLiveStartedScript = GetEventScriptEnabled(
lookup,
@@ -348,6 +359,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken);
await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken);
await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken);
var openListUpload = request.OpenListUpload ?? new OpenListUploadSettingsDto();
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);
foreach (var platformDefinition in LivePlatformCatalog.All)
{
var platformRequestSettings = request.GetPlatformRequestSettings(platformDefinition.Type);
@@ -85,6 +85,15 @@ public class RecordResult
ErrorMessage = errorMessage;
}
public void MarkUploadStarted(string provider, DateTimeOffset startedAt)
{
UploadStatus = RecordArtifactUploadStatus.Uploading;
LastUploadProvider = NormalizeNullable(provider);
LastUploadedAt = startedAt;
UploadErrorMessage = null;
DeletedLocalFilesAfterUpload = false;
}
public void MarkUploadSucceeded(
string provider,
string? remoteVideoPath,
@@ -4,5 +4,6 @@ public enum RecordArtifactUploadStatus
{
NotUploaded = 0,
Succeeded = 1,
Failed = 2
Failed = 2,
Uploading = 3
}
@@ -4,5 +4,6 @@ public enum UploadTargetType
{
None = 0,
WebDav = 1,
S3 = 2
S3 = 2,
OpenList = 3
}
@@ -277,6 +277,48 @@ public sealed class RecordResultRepository : IRecordResultRepository
.Where(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded)
.SumAsync(item => item.FileSizeBytes ?? 0L, cancellationToken);
public async Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
RecordArtifactUploadStatus? uploadStatusFilter,
int skip,
int take,
CancellationToken cancellationToken = default)
{
var query = _dbContext.RecordResults
.Include(item => item.RecordTask!)
.ThenInclude(task => task.LiveRoom)
.AsQueryable();
if (uploadStatusFilter.HasValue)
{
query = query.Where(item => item.UploadStatus == uploadStatusFilter.Value);
}
var results = await query
.OrderByDescending(item => item.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
return results
.Where(item => item.RecordTask is not null)
.Select(item => (item, item.RecordTask!))
.ToList();
}
public Task<int> CountUploadStatusAsync(
RecordArtifactUploadStatus? uploadStatusFilter,
CancellationToken cancellationToken = default)
{
var query = _dbContext.RecordResults.AsQueryable();
if (uploadStatusFilter.HasValue)
{
query = query.Where(item => item.UploadStatus == uploadStatusFilter.Value);
}
return query.CountAsync(cancellationToken);
}
public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) =>
_dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask();
@@ -1,6 +1,7 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.RecordTasks;
@@ -147,6 +148,8 @@ public sealed class RecordUploadService
}
var uploader = CreateUploader(settings);
recordResult.MarkUploadStarted(uploader.ProviderName, DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
try
{
var remoteVideoPath = recordResult.RemoteVideoPath;
@@ -325,6 +328,7 @@ public sealed class RecordUploadService
{
UploadTargetType.WebDav => new WebDavRecordArtifactUploader(settings.WebDavUpload),
UploadTargetType.S3 => new S3RecordArtifactUploader(settings.S3Upload),
UploadTargetType.OpenList => new OpenListRecordArtifactUploader(settings.OpenListUpload),
_ => throw new InvalidOperationException("No supported upload target is configured.")
};
}
@@ -620,3 +624,137 @@ internal sealed class S3RecordArtifactUploader : IRecordArtifactUploader
private static string ConvertToHex(byte[] bytes) =>
Convert.ToHexString(bytes).ToLowerInvariant();
}
internal sealed class OpenListRecordArtifactUploader : IRecordArtifactUploader
{
private readonly OpenListUploadSettingsDto _settings;
public OpenListRecordArtifactUploader(OpenListUploadSettingsDto settings)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public string ProviderName => "openlist";
public async Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_settings.BaseUrl))
{
throw new InvalidOperationException("OpenList base URL is not configured.");
}
var baseUrl = _settings.BaseUrl.Trim().TrimEnd('/');
var remotePath = BuildRemotePath(_settings.BasePath, relativeRemotePath);
var token = await LoginAsync(baseUrl, cancellationToken);
await EnsureDirectoriesAsync(baseUrl, token, remotePath, cancellationToken);
return await PutFileAsync(baseUrl, token, remotePath, localPath, cancellationToken);
}
private async Task<string> LoginAsync(string baseUrl, CancellationToken cancellationToken)
{
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
var payload = JsonSerializer.Serialize(new { username = _settings.Username, password = _settings.Password });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
using var response = await client.PostAsync($"{baseUrl}/api/auth/login", content, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
var code = root.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1;
if (code != 200)
{
var message = root.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : body;
throw new InvalidOperationException($"OpenList login failed with code {code}: {message}");
}
if (!root.TryGetProperty("data", out var dataElement) ||
!dataElement.TryGetProperty("token", out var tokenElement) ||
string.IsNullOrWhiteSpace(tokenElement.GetString()))
{
throw new InvalidOperationException("OpenList login response did not contain a token.");
}
return tokenElement.GetString()!;
}
private static async Task EnsureDirectoriesAsync(string baseUrl, string token, string remotePath, CancellationToken cancellationToken)
{
var segments = remotePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length <= 1)
{
return;
}
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
var accumulatedPath = string.Empty;
for (var i = 0; i < segments.Length - 1; i++)
{
accumulatedPath += "/" + segments[i];
await MkdirAsync(client, baseUrl, token, accumulatedPath, cancellationToken);
}
}
private static async Task MkdirAsync(HttpClient client, string baseUrl, string token, string path, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new { path });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/api/fs/mkdir")
{
Content = content
};
request.Headers.TryAddWithoutValidation("Authorization", token);
using var response = await client.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(body);
var code = doc.RootElement.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1;
if (code != 200)
{
var message = doc.RootElement.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : body;
// AList returns code 500 with "already exists" or "exist" when directory already exists — that's acceptable
if (message != null && message.Contains("exist", StringComparison.OrdinalIgnoreCase))
{
return;
}
throw new InvalidOperationException($"OpenList mkdir failed with code {code} for path '{path}': {message}");
}
}
private static async Task<string> PutFileAsync(string baseUrl, string token, string remotePath, string localPath, CancellationToken cancellationToken)
{
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
using var request = new HttpRequestMessage(HttpMethod.Put, $"{baseUrl}/api/fs/put");
request.Headers.TryAddWithoutValidation("Authorization", token);
request.Headers.TryAddWithoutValidation("File-Path", Uri.EscapeDataString(remotePath));
request.Content = new StreamContent(File.OpenRead(localPath));
using var response = await client.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(body);
var code = doc.RootElement.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1;
if (code != 200)
{
var message = doc.RootElement.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : body;
throw new InvalidOperationException($"OpenList file upload failed with code {code}: {message}");
}
return $"{baseUrl}{remotePath}";
}
private static string BuildRemotePath(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);
}
}
@@ -1,6 +1,9 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +15,7 @@ public sealed class RecordTasksController : ControllerBase
{
private readonly RecordService _recordService;
private readonly RecordUploadService _recordUploadService;
private readonly IRecordResultRepository _recordResultRepository;
private readonly IRecordMediaService _recordMediaService;
private readonly IDanmakuService _danmakuService;
private readonly LinkGenerator _linkGenerator;
@@ -19,12 +23,14 @@ public sealed class RecordTasksController : ControllerBase
public RecordTasksController(
RecordService recordService,
RecordUploadService recordUploadService,
IRecordResultRepository recordResultRepository,
IRecordMediaService recordMediaService,
IDanmakuService danmakuService,
LinkGenerator linkGenerator)
{
_recordService = recordService;
_recordUploadService = recordUploadService;
_recordResultRepository = recordResultRepository;
_recordMediaService = recordMediaService;
_danmakuService = danmakuService;
_linkGenerator = linkGenerator;
@@ -34,6 +40,62 @@ public sealed class RecordTasksController : ControllerBase
public async Task<ActionResult<IReadOnlyList<RecordTaskDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) =>
Ok(await _recordService.ListAsync(liveRoomId, cancellationToken));
[HttpGet("upload-status")]
public async Task<ActionResult<UploadTaskListResponse>> ListUploadStatus(
[FromQuery] int? uploadStatus,
[FromQuery] int skip = 0,
[FromQuery] int take = 50,
CancellationToken cancellationToken = default)
{
var filter = uploadStatus.HasValue && Enum.IsDefined(typeof(RecordArtifactUploadStatus), uploadStatus.Value)
? (RecordArtifactUploadStatus)uploadStatus.Value
: (RecordArtifactUploadStatus?)null;
var items = await _recordResultRepository.ListUploadStatusAsync(filter, skip, take, cancellationToken);
var totalCount = await _recordResultRepository.CountUploadStatusAsync(filter, cancellationToken);
var notUploadedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.NotUploaded, cancellationToken);
var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, cancellationToken);
var failedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, cancellationToken);
return Ok(new UploadTaskListResponse
{
Items = items.Select(MapUploadTaskItem).ToList(),
TotalCount = totalCount,
NotUploadedCount = notUploadedCount,
SucceededCount = succeededCount,
FailedCount = failedCount
});
}
private static UploadTaskItemDto MapUploadTaskItem((RecordResult Result, RecordTask Task) pair)
{
var (result, task) = pair;
var liveRoom = task.LiveRoom;
return new UploadTaskItemDto
{
RecordTaskId = task.Id,
RecordSessionId = task.RecordSessionId,
LiveRoomId = task.LiveRoomId,
LiveRoomTitle = liveRoom?.Title ?? liveRoom?.AnchorName ?? liveRoom?.RoomId ?? "Unknown Room",
Platform = (int)(liveRoom?.Platform ?? LivePlatformType.Unknown),
RoomId = liveRoom?.RoomId ?? string.Empty,
SegmentIndex = task.SegmentIndex,
OutputFormat = task.OutputFormat.ToString(),
FilePath = result.FilePath,
FileSizeBytes = result.FileSizeBytes,
DanmakuFilePath = result.DanmakuFilePath,
UploadStatus = result.UploadStatus,
LastUploadProvider = result.LastUploadProvider,
RemoteVideoPath = result.RemoteVideoPath,
RemoteDanmakuPath = result.RemoteDanmakuPath,
LastUploadedAt = result.LastUploadedAt,
UploadErrorMessage = result.UploadErrorMessage,
DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload,
CreatedAt = result.CreatedAt
};
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<RecordTaskDetailDto>> Get(Guid id, CancellationToken cancellationToken)
{