feat: add reliable OpenList segment uploads
This commit is contained in:
@@ -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 返回了无效 JSON(HTTP {(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)
|
||||
|
||||
Reference in New Issue
Block a user