rdt-client/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs
Antonin Lenfant-Kodia c8313361a2 Fix and harden Synology Download Station support
The DownloadStation downloader left downloads stuck or silently failing.
This fixes the core flow and documents setup:

- Create the per-download destination folder via File Station (force_parent)
  before creating the task; DownloadStation does not create it for a
  direct-file task, which otherwise fails with "Destination does not exist".
- Auto-set the account's default destination when it has none (DSM leaves
  every task "Waiting" otherwise), preserving the other server settings.
- Detect completion across the terminal states (Finished/Downloaded/Seeding/
  PreSeeding or fully transferred), then verify the file is visible at the
  container path before signalling success, so a path-mapping mismatch is a
  clear error instead of a silent import failure.
- Surface DownloadStation errors (status_extra.error_detail, captcha) instead
  of polling progress forever.
- Harden task-list / get-info / delete handling against responses that fail
  to deserialize (upstream #723/#792) so one bad task can't abort a download.
- Make task creation idempotent: adopt an existing task instead of throwing
  "already added", which previously bricked every retry.

Adds unit tests, fixes a settings description typo, and documents Download
Station setup and the path-mapping requirement in the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:42:10 +02:00

474 lines
18 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 ISynologyClient _synologyClient;
private readonly String _uri;
private String? _gid;
public DownloadStationDownloader(String? gid,
String uri,
String? remotePath,
String filePath,
String downloadPath,
ISynologyClient synologyClient,
IDelayProvider? delayProvider = null,
IFileSystem? fileSystem = 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();
}
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");
try
{
await _synologyClient.DownloadStationApi()
.TaskEndpoint()
.DeleteAsync(new()
{
Ids =
[
_gid
],
ForceComplete = false
});
}
catch (Exception ex)
{
// The Synology DELETE response can itself fail to deserialize (upstream #792); 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 (upstream #792) 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 _synologyClient
.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 synologyClient = new SynologyClient(Settings.Get.DownloadClient.DownloadStationUrl);
try
{
await synologyClient.LoginAsync(Settings.Get.DownloadClient.DownloadStationUsername, Settings.Get.DownloadClient.DownloadStationPassword);
}
catch (Exception ex)
{
throw new($"DownloadStation login failed for user '{Settings.Get.DownloadClient.DownloadStationUsername}': {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('\\', '/');
}
}
return new(gid, uri, remotePath, filePath, downloadPath, synologyClient);
}
/// <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 _synologyClient.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 _synologyClient.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 (upstream #723);
// 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
{
if (_gid == null)
{
return null;
}
return await _synologyClient.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;
}
}
}