rdt-client/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs
Antonin Lenfant-Kodia b2a725c285 Simplify comments
2026-06-15 15:58:23 +02:00

525 lines
21 KiB
C#

using System.IO.Abstractions;
using RdtClient.Service.Helpers;
using Serilog;
using Synology.Api.Client;
using Synology.Api.Client.Apis.DownloadStation.Info.Models;
using Synology.Api.Client.Apis.DownloadStation.Task.Models;
using Synology.Api.Client.Exceptions;
namespace RdtClient.Service.Services.Downloaders;
internal class DelayProvider : IDelayProvider
{
public Task Delay(Int32 delay)
{
return Task.Delay(delay);
}
}
public class DownloadStationDownloader : IDownloader
{
private const Int32 RetryCount = 5;
private readonly IDelayProvider _delayProvider;
private readonly IFileSystem _fileSystem;
private readonly String _filePath;
private readonly ILogger _logger;
private readonly String? _remotePath;
private readonly String _uri;
// The Synology client is shared across downloads and can be swapped out by WithSessionRetry after a session
// expires, so it is not readonly.
private ISynologyClient _synologyClient;
private readonly Func<Task<ISynologyClient>> _reacquireClient;
private String? _gid;
public DownloadStationDownloader(String? gid,
String uri,
String? remotePath,
String filePath,
String downloadPath,
ISynologyClient synologyClient,
IDelayProvider? delayProvider = null,
IFileSystem? fileSystem = null,
Func<Task<ISynologyClient>>? reacquireClient = null)
{
_logger = Log.ForContext<DownloadStationDownloader>();
_logger.Debug($"Instantiated new DownloadStation Downloader for URI {uri} to filePath {filePath} (remote {remotePath}) and downloadPath {downloadPath} and GID {gid}");
_gid = gid;
_filePath = filePath;
_uri = uri;
_remotePath = remotePath;
_synologyClient = synologyClient;
_delayProvider = delayProvider ?? new DelayProvider();
_fileSystem = fileSystem ?? new FileSystem();
// When the caller doesn't supply a way to re-authenticate (e.g. unit tests with a mock client), session
// retry just reuses the current client.
_reacquireClient = reacquireClient ?? (() => Task.FromResult(_synologyClient));
}
/// <summary>
/// Runs a Synology API call, and if DownloadStation reports a session error (e.g. error 119 "SID not found",
/// 106 timeout, 107 duplicate login) drops the shared session, re-authenticates once and retries. With a single
/// shared login these are rare, but a session can still expire between downloads.
/// </summary>
private async Task<T> WithSessionRetry<T>(Func<ISynologyClient, Task<T>> op)
{
try
{
return await op(_synologyClient);
}
catch (SynologyApiException ex) when (ex.ErrorCode is 119 or 106 or 107)
{
_logger.Debug($"DownloadStation session error {ex.ErrorCode} ({ex.ErrorDescription}); re-authenticating and retrying once");
await SynologyClientProvider.InvalidateAsync(_synologyClient);
_synologyClient = await _reacquireClient();
return await op(_synologyClient);
}
}
public event EventHandler<DownloadCompleteEventArgs>? DownloadComplete;
public event EventHandler<DownloadProgressEventArgs>? DownloadProgress;
public async Task Cancel()
{
// Resolve the gid from the task list in case a task was created but its id was never persisted (orphan cleanup).
_gid ??= await GetGidFromUri();
if (_gid == null)
{
return;
}
_logger.Debug($"Remove download {_uri} {_gid} from Synology DownloadStation");
var gid = _gid;
try
{
await WithSessionRetry(c => c.DownloadStationApi()
.TaskEndpoint()
.DeleteAsync(new()
{
Ids =
[
gid
],
ForceComplete = false
}));
}
catch (Exception ex)
{
// The Synology DELETE response can itself fail to deserialize; don't let cleanup throw.
_logger.Debug($"Failed to remove DownloadStation task {_gid}: {ex.Message}");
}
}
public async Task<String> Download()
{
var path = Path.GetDirectoryName(_remotePath)?.Replace('\\', '/') ?? throw new($"Invalid file path {_filePath}");
if (!path.StartsWith('/'))
{
path = '/' + path;
}
_logger.Debug($"Starting download of {_uri}, writing to path: {path}");
if (_gid != null)
{
var task = await GetTask();
if (task != null)
{
// A task for this download already exists in DownloadStation; adopt it instead of throwing.
// Throwing here permanently bricks every retry that reuses the gid: the caller Cancel()s first,
// but the DownloadStation delete response fails to deserialize so the task is not actually
// removed, and the next Download() then errors with "already added" forever.
_logger.Debug($"Download with ID {_gid} already exists in DownloadStation; reusing it");
return _gid;
}
}
var retryCount = 0;
while (retryCount < RetryCount)
{
_gid = await GetGidFromUri();
if (_gid != null)
{
_logger.Debug($"Download with ID {_gid} found in DownloadStation");
return _gid;
}
try
{
// DownloadStation will not create the destination folder for a direct-file task, so create it first.
// `path` is the share-relative destination folder with a leading slash, e.g. /Media/Downloads/Torrents/<name>.
await EnsureRemoteFolderExists(path);
var createResult = await WithSessionRetry(c => c.DownloadStationApi()
.TaskEndpoint()
.CreateAsync(new(_uri, path[1..])));
_gid = createResult.TaskId?.FirstOrDefault();
_logger.Debug($"Added download to DownloadStation, received ID {_gid}");
// Only fall back to a list lookup when create returned no id; GetGidFromUri is guarded so it can't erase a known id.
_gid ??= await GetGidFromUri();
if (_gid != null)
{
_logger.Debug($"Download with ID {_gid} found in DownloadStation");
return _gid;
}
retryCount++;
_logger.Error($"Task not found in DownloadStation after a successful create. Retrying {retryCount}/{RetryCount}");
await _delayProvider.Delay(retryCount * 1000);
}
catch (Exception e)
{
retryCount++;
_logger.Error($"Error starting download: {e.Message}. Retrying {retryCount}/{RetryCount}");
await _delayProvider.Delay(retryCount * 1000);
}
}
throw new($"Unable to download file");
}
public async Task Pause()
{
_logger.Debug($"Pausing download {_uri} {_gid}");
if (_gid == null)
{
return;
}
try
{
await _synologyClient.DownloadStationApi().TaskEndpoint().PauseAsync(_gid);
}
catch (Exception ex)
{
_logger.Debug($"Failed to pause DownloadStation task {_gid}: {ex.Message}");
}
}
public async Task Resume()
{
_logger.Debug($"Resuming download {_uri} {_gid}");
if (_gid == null)
{
return;
}
try
{
await _synologyClient.DownloadStationApi().TaskEndpoint().ResumeAsync(_gid);
}
catch (Exception ex)
{
_logger.Debug($"Failed to resume DownloadStation task {_gid}: {ex.Message}");
}
}
public static async Task<DownloadStationDownloader> Init(String? gid, String uri, String filePath, String downloadPath, String? category)
{
if (Settings.Get.DownloadClient.DownloadStationUrl == null)
{
throw new("No URL specified for Synology download station");
}
if (Settings.Get.DownloadClient.DownloadStationUsername == null || Settings.Get.DownloadClient.DownloadStationPassword == null)
{
throw new("No username/password specified for Synology download station");
}
var url = Settings.Get.DownloadClient.DownloadStationUrl!;
var username = Settings.Get.DownloadClient.DownloadStationUsername!;
var password = Settings.Get.DownloadClient.DownloadStationPassword!;
ISynologyClient synologyClient;
try
{
// One authenticated client is shared across all downloads. Logging in per download made DSM invalidate
// older sessions under high file-count concurrency, surfacing as error 119 "SID not found".
synologyClient = await SynologyClientProvider.GetClientAsync(url, username, password);
}
catch (Exception ex)
{
throw new($"DownloadStation login failed for user '{username}': {ex.Message}. " +
$"If 2-step verification is enabled on this Synology account, use a dedicated service account without 2FA.");
}
String? remotePath = null;
String? rootPath;
if (!String.IsNullOrWhiteSpace(Settings.Get.DownloadClient.DownloadStationDownloadPath))
{
rootPath = Settings.Get.DownloadClient.DownloadStationDownloadPath;
// DownloadStation resolves a per-account default destination and leaves every task in "Waiting" if the
// account has none. Make sure this account has one (set to the configured root) so downloads can start.
await EnsureDefaultDestination(synologyClient, rootPath);
}
else
{
var config = await synologyClient.DownloadStationApi().InfoEndpoint().GetConfigAsync();
rootPath = config.DefaultDestination;
}
if (rootPath != null)
{
if (String.IsNullOrWhiteSpace(category))
{
remotePath = Path.Combine(rootPath, downloadPath).Replace('\\', '/');
}
else
{
remotePath = Path.Combine(rootPath, category, downloadPath).Replace('\\', '/');
}
}
// Re-acquire reads the credentials fresh from settings (not the values captured above) so that editing and
// saving the DownloadStation URL/credentials takes effect live: an in-flight download that has to re-authenticate
// converges on the current settings instead of flipping the shared session back to stale credentials.
return new(gid,
uri,
remotePath,
filePath,
downloadPath,
synologyClient,
reacquireClient: () => SynologyClientProvider.GetClientAsync(Settings.Get.DownloadClient.DownloadStationUrl!,
Settings.Get.DownloadClient.DownloadStationUsername!,
Settings.Get.DownloadClient.DownloadStationPassword!));
}
/// <summary>
/// DownloadStation resolves a per-account "default destination"; without one it leaves every task in "Waiting"
/// (DSM logs "Failed to get default download destination of user"). Set it for this account when it has none,
/// best-effort and preserving the other server settings, so downloads start without a manual setup step.
/// </summary>
private static async Task EnsureDefaultDestination(ISynologyClient synologyClient, String destination)
{
var logger = Log.ForContext<DownloadStationDownloader>();
try
{
var config = await synologyClient.DownloadStationApi().InfoEndpoint().GetConfigAsync();
if (!String.IsNullOrWhiteSpace(config.DefaultDestination))
{
return;
}
// Re-send the existing config unchanged except for the default destination, so nothing else is reset.
var updated = new DownloadStationServerConfig(config.BtMaxDownloadSpeed,
config.BtMaxUploadSpeed,
config.EmulEnable,
config.EmulMaxDownloadSpeed,
config.EmulMaxUploadSpeed,
config.FtpMaxDownloadSpeed,
config.HttpMaxDownloadSpeed,
config.NzbMaxDownloadSpeed,
config.UnzipServiceEnable,
destination,
config.EmulDefaultDestination);
await synologyClient.DownloadStationApi().InfoEndpoint().SetServerConfigAsync(updated);
logger.Information($"Set the DownloadStation default destination to '{destination}' for this account (it had none).");
}
catch (Exception ex)
{
logger.Warning($"Could not set the DownloadStation default destination automatically ({ex.Message}). " +
$"If downloads stay in 'Waiting', sign into Download Station as this account and set a Default destination " +
$"under Settings -> BT/HTTP/FTP/NZB -> Location.");
}
}
/// <summary>
/// Ensures the destination folder exists on the Synology before a task is created. DownloadStation does not create
/// the destination for a direct-file download task, so a missing folder yields "Destination does not exist".
/// </summary>
private async Task EnsureRemoteFolderExists(String folderPath)
{
try
{
await WithSessionRetry(c => c.FileStationApi()
.CreateFolderEndpoint()
.CreateAsync([folderPath], true));
_logger.Debug($"Ensured DownloadStation destination folder exists: {folderPath}");
}
catch (SynologyApiException ex)
{
// With force_parent (createParentFolders), creating a folder that already exists returns success, so an
// exception here means the folder could NOT be created — most commonly because the Synology account lacks
// File Station permission or write access to the destination. Surface it clearly: the task create below
// will then fail with "Destination does not exist", and this warning explains why.
_logger.Warning($"Could not create the DownloadStation destination folder '{folderPath}' " +
$"(File Station error {ex.ErrorCode}: {ex.ErrorDescription}). The Synology account must have " +
$"File Station permission with read/write access to that path.");
}
}
private async Task<String?> GetGidFromUri()
{
try
{
var tasks = await WithSessionRetry(c => c.DownloadStationApi().TaskEndpoint().ListAsync());
return tasks.Task?.FirstOrDefault(t => t.Additional?.Detail?.Uri == _uri)?.Id;
}
catch (Exception ex)
{
// The task-list response can fail to deserialize when a task carries a non-string error_detail;
// treat that as "not found" instead of aborting the whole download.
_logger.Debug($"Failed to list DownloadStation tasks for {_uri}: {ex.Message}");
return null;
}
}
public async Task Update()
{
if (_gid == null)
{
DownloadComplete?.Invoke(this,
new()
{
Error = "Task not found"
});
return;
}
var task = await GetTask();
if (task == null)
{
DownloadComplete?.Invoke(this,
new()
{
Error = "Task not found"
});
return;
}
// DownloadStation reports failures via status_extra.error_detail (the status enum has no error member),
// and stalls awaiting input on CaptchaNeeded. Surface both instead of polling progress forever.
if (!String.IsNullOrWhiteSpace(task.StatusExtra?.ErrorDetail) || task.Status == DownloadStationTaskStatus.CaptchaNeeded)
{
var detail = !String.IsNullOrWhiteSpace(task.StatusExtra?.ErrorDetail) ? task.StatusExtra!.ErrorDetail : task.Status.ToString();
await Cancel();
DownloadComplete?.Invoke(this,
new()
{
Error = $"DownloadStation error: {detail}"
});
return;
}
if (IsComplete(task))
{
await CompleteWhenFileExists();
return;
}
DownloadProgress?.Invoke(this,
new()
{
BytesDone = task.Additional?.Transfer?.SizeDownloaded ?? 0,
BytesTotal = task.Size,
Speed = task.Additional?.Transfer?.SpeedDownload ?? 0
});
}
/// <summary>
/// A DownloadStation task is done downloading in several terminal states (it is not always <c>Finished</c> — a
/// completed torrent settles into <c>Seeding</c>/<c>Downloaded</c>). Also treat fully-transferred tasks as complete.
/// </summary>
private static Boolean IsComplete(DownloadStationTask task)
{
if (task.Status is DownloadStationTaskStatus.Finished
or DownloadStationTaskStatus.Downloaded
or DownloadStationTaskStatus.Seeding
or DownloadStationTaskStatus.PreSeeding)
{
return true;
}
return task.Size > 0 && (task.Additional?.Transfer?.SizeDownloaded ?? 0) >= task.Size;
}
/// <summary>
/// DownloadStation finishing only means the file is on the Synology; verify it is visible at the container path
/// (the bind-mounted location rdt-client unpacks/imports from) before signalling success, mirroring the Aria2c
/// downloader. A missing file means the path mapping is wrong, which is otherwise a silent import failure.
/// </summary>
private async Task CompleteWhenFileExists()
{
for (var attempt = 0; attempt < 10; attempt++)
{
if (_fileSystem.File.Exists(_filePath))
{
DownloadComplete?.Invoke(this, new());
return;
}
await _delayProvider.Delay(attempt * 1000);
}
DownloadComplete?.Invoke(this,
new()
{
Error = $"DownloadStation reported the download finished, but no file was found at {_filePath} " +
$"(DownloadStation saved to {_remotePath}). Check that the Download path, the DownloadStation Download Path, " +
$"and the container bind mount all point at the same folder."
});
}
private async Task<DownloadStationTask?> GetTask()
{
try
{
var gid = _gid;
if (gid == null)
{
return null;
}
return await WithSessionRetry(c => c.DownloadStationApi().TaskEndpoint().GetInfoAsync(gid));
}
catch (Exception ex)
{
// GetInfo can throw on an empty/absent task or on the #723 error_detail deserialization; treat as "no task".
_logger.Debug($"Failed to get DownloadStation task {_gid}: {ex.Message}");
return null;
}
}
}