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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user