feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
using dy.net.model.dto;
|
||||
using dy.net.model.entity;
|
||||
using dy.net.service;
|
||||
|
||||
namespace dy.net.storage
|
||||
{
|
||||
public sealed class OpenListMediaStorage : IMediaStorage, ICanonicalMediaStorage
|
||||
{
|
||||
private readonly OpenListSettingsService _settingsService;
|
||||
private readonly OpenListClient _client;
|
||||
private readonly OpenListTransferService _transfers;
|
||||
|
||||
public OpenListMediaStorage(
|
||||
OpenListSettingsService settingsService,
|
||||
OpenListClient client,
|
||||
OpenListTransferService transfers)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_client = client;
|
||||
_transfers = transfers;
|
||||
}
|
||||
|
||||
public StorageType StorageType => StorageType.OpenList;
|
||||
|
||||
public async Task<string> CanonicalizePathAsync(
|
||||
string path,
|
||||
bool createParentDirectories = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _settingsService.GetAsync();
|
||||
var password = await RequirePasswordAsync(settings);
|
||||
var actual = await _client.ResolveCanonicalObjectPathAsync(settings, password,
|
||||
OpenListTransferService.ToActualPath(settings, path), createParentDirectories, cancellationToken);
|
||||
return OpenListTransferService.ToLogicalPath(settings, actual);
|
||||
}
|
||||
|
||||
public async Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _settingsService.GetAsync();
|
||||
var password = await RequirePasswordAsync(settings);
|
||||
await _client.EnsureDirectoryAsync(settings, password,
|
||||
OpenListTransferService.ToActualPath(settings, path), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return false;
|
||||
var settings = await _settingsService.GetAsync();
|
||||
var password = await RequirePasswordAsync(settings);
|
||||
var info = await _client.TryGetObjectAsync(settings, password,
|
||||
OpenListTransferService.ToActualPath(settings, path), cancellationToken);
|
||||
return info is { IsDirectory: false };
|
||||
}
|
||||
|
||||
public async Task<long?> GetLengthAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
var settings = await _settingsService.GetAsync();
|
||||
var password = await RequirePasswordAsync(settings);
|
||||
var info = await _client.TryGetObjectAsync(settings, password,
|
||||
OpenListTransferService.ToActualPath(settings, path), cancellationToken);
|
||||
return info is { IsDirectory: false } ? info.Size : null;
|
||||
}
|
||||
|
||||
public Task WriteAsync(
|
||||
string path,
|
||||
Stream source,
|
||||
long? contentLength = null,
|
||||
string contentType = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_transfers.TransferAsync(path, source, contentLength, cancellationToken);
|
||||
|
||||
public async Task<StorageReadResult> OpenReadAsync(
|
||||
string path,
|
||||
long? from = null,
|
||||
long? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _settingsService.GetAsync();
|
||||
var password = await RequirePasswordAsync(settings);
|
||||
return await _client.OpenReadAsync(settings, password,
|
||||
OpenListTransferService.ToActualPath(settings, path), from, to, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
var settings = await _settingsService.GetAsync();
|
||||
var password = await RequirePasswordAsync(settings);
|
||||
await _client.DeleteObjectAsync(settings, password,
|
||||
OpenListTransferService.ToActualPath(settings, path), cancellationToken);
|
||||
}
|
||||
|
||||
public IMediaStorage Bind(OpenListSettings settings)
|
||||
{
|
||||
OpenListTransferService.ValidateSettings(settings);
|
||||
return new BoundOpenListMediaStorage(this, settings);
|
||||
}
|
||||
|
||||
public async Task<OpenListDirectoryListDto> ListDirectoriesAsync(
|
||||
OpenListSettings settings,
|
||||
string path,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
OpenListTransferService.ValidateSettings(settings);
|
||||
var password = await RequirePasswordAsync(settings);
|
||||
return await _client.ListDirectoriesAsync(settings, password, path, true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证登录、本地目录与 OpenList 源挂载映射、服务端复制、Range 读取和删除。
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string Message)> ProbeAsync(
|
||||
OpenListSettings settings,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string localDirectory = null;
|
||||
string targetDirectory = null;
|
||||
string password = null;
|
||||
try
|
||||
{
|
||||
OpenListTransferService.ValidateSettings(settings);
|
||||
password = await RequirePasswordAsync(settings);
|
||||
var connectionMessage = await _client.ProbeAsync(settings, password, cancellationToken);
|
||||
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("dysync-openlist-copy-probe");
|
||||
localDirectory = Path.Combine(Path.GetFullPath(settings.LocalStagingPath), id);
|
||||
Directory.CreateDirectory(localDirectory);
|
||||
var localPath = Path.Combine(localDirectory, "probe.txt");
|
||||
await File.WriteAllBytesAsync(localPath, bytes, cancellationToken);
|
||||
|
||||
var sourcePath = StoragePath.CombineRemote(settings.SourcePath, id, "probe.txt");
|
||||
targetDirectory = StoragePath.CombineRemote(settings.BasePath, $".dysync-probe-{id}");
|
||||
var targetPath = StoragePath.CombineRemote(targetDirectory, "probe.txt");
|
||||
|
||||
OpenListObjectInfo source = null;
|
||||
foreach (var delay in new[] { 0, 500, 1000, 2000, 4000 })
|
||||
{
|
||||
if (delay > 0) await Task.Delay(delay, cancellationToken);
|
||||
source = await _client.TryGetObjectAsync(settings, password, sourcePath, cancellationToken);
|
||||
if (source is { IsDirectory: false } && source.Size == bytes.Length) break;
|
||||
}
|
||||
if (source is not { IsDirectory: false } || source.Size != bytes.Length)
|
||||
throw new InvalidOperationException(
|
||||
$"OpenList 无法从源挂载目录看到测试文件。请确认本地目录“{settings.LocalStagingPath}”映射到 OpenList“{settings.SourcePath}”。");
|
||||
|
||||
await _client.EnsureDirectoryAsync(settings, password, targetDirectory, cancellationToken);
|
||||
var copy = await _client.CopyFileAsync(settings, password, sourcePath, targetPath, cancellationToken);
|
||||
var taskId = copy.TaskIds.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(taskId))
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMinutes(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
var task = await _client.TryGetCopyTaskAsync(settings, password, taskId, cancellationToken);
|
||||
if (task?.State == 2) break;
|
||||
if (task?.State is 4 or 7)
|
||||
throw new IOException(task.Error ?? $"OpenList 复制任务失败:state={task.State}");
|
||||
}
|
||||
}
|
||||
|
||||
var target = await _client.TryGetObjectAsync(settings, password, targetPath, cancellationToken);
|
||||
if (target is not { IsDirectory: false } || target.Size != bytes.Length)
|
||||
throw new IOException($"OpenList 服务端复制校验失败:期望 {bytes.Length},实际 {target?.Size.ToString() ?? "不存在"}。");
|
||||
await using (var read = await _client.OpenReadAsync(settings, password, targetPath, 0, 0, cancellationToken))
|
||||
{
|
||||
var oneByte = new byte[1];
|
||||
if (await read.Stream.ReadAsync(oneByte.AsMemory(0, 1), cancellationToken) != 1)
|
||||
throw new IOException("OpenList Range 读取未返回数据。");
|
||||
}
|
||||
|
||||
await _client.DeleteObjectAsync(settings, password, targetDirectory, cancellationToken);
|
||||
return (true, $"{connectionMessage} 服务端复制、Range 与删除能力正常。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Warning(ex, "OpenList 完整能力探测失败");
|
||||
return (false, ex.GetBaseException().Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(targetDirectory) && !string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
try { await _client.DeleteObjectAsync(settings, password, targetDirectory, CancellationToken.None); }
|
||||
catch { }
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(localDirectory) && Directory.Exists(localDirectory))
|
||||
{
|
||||
try { Directory.Delete(localDirectory, true); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> RequirePasswordAsync(OpenListSettings settings)
|
||||
{
|
||||
var password = await _settingsService.GetPasswordAsync(settings);
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
throw new InvalidOperationException("OpenList 密码无效,请重新输入并保存。");
|
||||
return password;
|
||||
}
|
||||
|
||||
private sealed class BoundOpenListMediaStorage : IMediaStorage, ICanonicalMediaStorage
|
||||
{
|
||||
private readonly OpenListMediaStorage _owner;
|
||||
private readonly OpenListSettings _settings;
|
||||
|
||||
public BoundOpenListMediaStorage(OpenListMediaStorage owner, OpenListSettings settings)
|
||||
{
|
||||
_owner = owner;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public StorageType StorageType => StorageType.OpenList;
|
||||
|
||||
public async Task<string> CanonicalizePathAsync(
|
||||
string path,
|
||||
bool createParentDirectories = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var password = await _owner.RequirePasswordAsync(_settings);
|
||||
var actual = await _owner._client.ResolveCanonicalObjectPathAsync(_settings, password,
|
||||
OpenListTransferService.ToActualPath(_settings, path), createParentDirectories, cancellationToken);
|
||||
return OpenListTransferService.ToLogicalPath(_settings, actual);
|
||||
}
|
||||
|
||||
public async Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var password = await _owner.RequirePasswordAsync(_settings);
|
||||
await _owner._client.EnsureDirectoryAsync(_settings, password,
|
||||
OpenListTransferService.ToActualPath(_settings, path), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default) =>
|
||||
await GetLengthAsync(path, cancellationToken) is > 0;
|
||||
|
||||
public async Task<long?> GetLengthAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
var password = await _owner.RequirePasswordAsync(_settings);
|
||||
var info = await _owner._client.TryGetObjectAsync(_settings, password,
|
||||
OpenListTransferService.ToActualPath(_settings, path), cancellationToken);
|
||||
return info is { IsDirectory: false } ? info.Size : null;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(string path, Stream source, long? contentLength = null,
|
||||
string contentType = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await _owner._settingsService.GetAsync();
|
||||
if (!string.Equals(StorageConfigurationFingerprint.Create(current),
|
||||
StorageConfigurationFingerprint.Create(_settings), StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("OpenList 配置已变化,绑定存储拒绝写入新目标。");
|
||||
await _owner._transfers.TransferAsync(path, source, contentLength, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<StorageReadResult> OpenReadAsync(string path, long? from = null, long? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var password = await _owner.RequirePasswordAsync(_settings);
|
||||
return await _owner._client.OpenReadAsync(_settings, password,
|
||||
OpenListTransferService.ToActualPath(_settings, path), from, to, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
var password = await _owner.RequirePasswordAsync(_settings);
|
||||
await _owner._client.DeleteObjectAsync(_settings, password,
|
||||
OpenListTransferService.ToActualPath(_settings, path), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user