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
@@ -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);
}
}