This commit is contained in:
Antonin Lenfant Kodia 2026-06-15 16:03:43 +02:00 committed by GitHub
commit 962f2cc35c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 732 additions and 45 deletions

View file

@ -150,14 +150,24 @@ Suggested configuration:
#### Synology Download Station
The Synology Download Station downloader uses an external Download Station server. You will need to set this up yourself.
The Synology Download Station downloader hands each file to your NAS's Download Station, which downloads it and writes it to disk. The bytes never pass through rdt-client, so its memory stays flat regardless of file size.
Prerequisites on the Synology:
- Install and start the **Download Station** package.
- Use a DSM account that has **both Download Station and File Station permission** (File Station is required so rdt-client can create the per-download destination folder). Disable 2-step verification on this account (the API login is username/password only), or use a dedicated service account.
- Download Station's **Default destination** needs to be set for the new user. Rdt-client sets this account's default destination for you if it doesn't already have one, so you normally don't need to do anything. If that automatic set fails, sign into Download Station *as that account* and set it under **Settings → BT/HTTP/FTP/NZB → Location → Default destination** — otherwise every task that account creates stays stuck in **"Waiting"** (DSM logs `Failed to get default download destination of user [<account>]`).
It has the following options:
- Url: The URL to the Synology DownloadStation. A common URL is `http://127.0.0.1:5000`
- Url: The URL to the Synology DownloadStation. A common URL is `http://127.0.0.1:5000`. From inside a Docker container `127.0.0.1` is the container itself — use the NAS's LAN IP (e.g. `http://192.168.1.50:5000`) and the DSM web port (HTTP `5000` by default).
- Username: The username to use when connecting to the Synology DownloadStation.
- Password: The password to use when connecting to the Synology DownloadStation.
- Download Path: The root path to download the file on the Synology DownloadStation host. If left empty, the default path configured on your Download Station server will be used.
- Download Path: The destination on the Synology, **relative to a shared folder** (e.g. `Media/Downloads/Torrents`) — **not** an absolute path like `/volume1/Media/Downloads/Torrents`. **This must resolve to the exact same physical folder as the general Download path** — see Path mapping below. If left empty, the default Download Station destination is used.
**Path mapping (important).** Download Station runs on the NAS; rdt-client runs in its container. They see the same folder through different mounts, so:
> **The Synology *Download Path* (above) and rdt-client's general *Download path* must resolve to the exact same physical folder on the NAS.**
### Troubleshooting

View file

@ -154,7 +154,7 @@ http://127.0.0.1:6800/jsonrpc.")]
public String DownloadStationUrl { get; set; } = "http://127.0.0.1:5000";
[DisplayName("Synology DownloadStation Username")]
[Description("The username to use when connecting to the Synology DownloadStation.")]
[Description("The username to use when connecting to the Synology DownloadStation. The account needs Download Station and File Station permissions (so the per-download destination folder can be created).")]
public String? DownloadStationUsername { get; set; } = null;
[DisplayName("Synology DownloadStation Password")]
@ -162,7 +162,7 @@ http://127.0.0.1:6800/jsonrpc.")]
public String? DownloadStationPassword { get; set; } = null;
[DisplayName("Synology Download Station Download Path")]
[Description("The root path to doawnload the file on the Synology DownloadStation host, if empty use the default DownloadStation path.")]
[Description("The root path to download the file on the Synology DownloadStation host, if empty the default DownloadStation path is used.")]
public String? DownloadStationDownloadPath { get; set; } = null;
[DisplayName("Minimum free disk space (GB) (Bezzad only)")]

View file

@ -1,14 +1,21 @@
using System.IO.Abstractions.TestingHelpers;
using Moq;
using RdtClient.Service.Helpers;
using RdtClient.Service.Services.Downloaders;
using Synology.Api.Client;
using Synology.Api.Client.ApiDescription;
using Synology.Api.Client.Apis.DownloadStation;
using Synology.Api.Client.Apis.DownloadStation.Task.Models;
using Synology.Api.Client.Apis.FileStation;
using Synology.Api.Client.Apis.FileStation.CreateFolder;
using Synology.Api.Client.Apis.FileStation.CreateFolder.Models;
using Synology.Api.Client.Exceptions;
namespace RdtClient.Service.Test.Services.Downloaders;
internal class Mocks
{
public readonly Mock<IFileStationCreateFolderEndpoint> CreateFolderEndpointMock = new();
public readonly String Gid;
public readonly Mock<ISynologyClient> SynologyClientMock = new();
public readonly Mock<IDownloadStationTaskEndpoint> TaskEndpointMock = new();
@ -16,9 +23,17 @@ internal class Mocks
public Mocks(String gid = "123456")
{
Gid = gid;
var downloadStationApiMock = new Mock<IDownloadStationApi>();
downloadStationApiMock.Setup(a => a.TaskEndpoint()).Returns(TaskEndpointMock.Object);
SynologyClientMock.Setup(c => c.DownloadStationApi()).Returns(downloadStationApiMock.Object);
var fileStationApiMock = new Mock<IFileStationApi>();
fileStationApiMock.Setup(a => a.CreateFolderEndpoint()).Returns(CreateFolderEndpointMock.Object);
SynologyClientMock.Setup(c => c.FileStationApi()).Returns(fileStationApiMock.Object);
CreateFolderEndpointMock.Setup(e => e.CreateAsync(It.IsAny<String[]>(), It.IsAny<Boolean>()))
.ReturnsAsync(new FileStationCreateFolderCreateResponse());
}
}
@ -55,7 +70,7 @@ public class DownloadStationDownloaderTest
}
[Fact]
public async Task Download_WhenAlreadyAdded_Throws()
public async Task Download_WhenTaskAlreadyExists_AdoptsItAndReturnsGid()
{
// Arrange
var mocks = new Mocks();
@ -69,10 +84,12 @@ public class DownloadStationDownloaderTest
mocks.SynologyClientMock.Object);
// Act
var exception = await Assert.ThrowsAsync<Exception>(downloadStationDownloader.Download);
var gid = await downloadStationDownloader.Download();
// Assert
Assert.Contains("already been added", exception.Message, StringComparison.OrdinalIgnoreCase);
// Assert: an existing task must be reused (idempotent), not re-created or thrown as "already added".
// Throwing would brick every retry that reuses the gid because the DownloadStation delete fails to
// deserialize its response, so the task is never actually removed.
Assert.Equal(mocks.Gid, gid);
mocks.TaskEndpointMock.Verify(t => t.GetInfoAsync(mocks.Gid), Times.Once);
mocks.TaskEndpointMock.VerifyNoOtherCalls();
}
@ -116,6 +133,76 @@ public class DownloadStationDownloaderTest
mocks.TaskEndpointMock.VerifyNoOtherCalls();
}
[Fact]
public async Task Download_CreatesDestinationFolderBeforeCreatingTask()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new());
mocks.TaskEndpointMock.Setup(t => t.ListAsync()).ReturnsAsync(new DownloadStationTaskListResponse { Total = 0, Offset = 0 });
var calls = new List<String>();
mocks.CreateFolderEndpointMock.Setup(e => e.CreateAsync(It.IsAny<String[]>(), It.IsAny<Boolean>()))
.ReturnsAsync(new FileStationCreateFolderCreateResponse())
.Callback(() => calls.Add("folder"));
mocks.TaskEndpointMock.Setup(t => t.CreateAsync(It.IsAny<DownloadStationTaskCreateRequest>()))
.ReturnsAsync(new DownloadStationTaskCreateResponse { TaskId = [mocks.Gid] })
.Callback(() => calls.Add("task"));
var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid,
"https://fake.url/file.txt",
"/Media/Downloads/Torrents/MyTorrent/file.txt",
"/path/to/file.txt",
"download-path",
mocks.SynologyClientMock.Object);
// Act
await downloadStationDownloader.Download();
// Assert
Assert.Equal(["folder", "task"], calls);
mocks.CreateFolderEndpointMock.Verify(e => e.CreateAsync(It.Is<String[]>(p => p.Single() == "/Media/Downloads/Torrents/MyTorrent"), true), Times.Once);
}
[Fact]
public async Task Download_WhenSynologyReportsSessionError119_ReAuthenticatesAndRetries()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new());
mocks.TaskEndpointMock.Setup(t => t.ListAsync()).ReturnsAsync(new DownloadStationTaskListResponse { Total = 0, Offset = 0 });
mocks.TaskEndpointMock.Setup(t => t.CreateAsync(It.IsAny<DownloadStationTaskCreateRequest>()))
.ReturnsAsync(new DownloadStationTaskCreateResponse { TaskId = [mocks.Gid] });
// The first folder-create fails with DSM error 119 ("SID not found"); after re-auth the retry succeeds.
mocks.CreateFolderEndpointMock.SetupSequence(e => e.CreateAsync(It.IsAny<String[]>(), It.IsAny<Boolean>()))
.ThrowsAsync(new SynologyApiException(new Mock<IApiInfo>().Object, "FileStation.CreateFolder", 119, "SID not found"))
.ReturnsAsync(new FileStationCreateFolderCreateResponse());
var reacquired = 0;
var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid,
"https://fake.url/file.txt",
"/Media/Downloads/Torrents/MyTorrent/file.txt",
"/path/to/file.txt",
"download-path",
mocks.SynologyClientMock.Object,
reacquireClient: () =>
{
reacquired++;
return Task.FromResult(mocks.SynologyClientMock.Object);
});
// Act
var result = await downloadStationDownloader.Download();
// Assert
Assert.Equal(mocks.Gid, result);
Assert.Equal(1, reacquired);
mocks.CreateFolderEndpointMock.Verify(e => e.CreateAsync(It.IsAny<String[]>(), It.IsAny<Boolean>()), Times.Exactly(2));
}
[Fact]
public async Task Download_After5Tries_Throws()
{
@ -210,4 +297,108 @@ public class DownloadStationDownloaderTest
mocks.TaskEndpointMock.VerifyNoOtherCalls();
}
[Theory]
[InlineData(DownloadStationTaskStatus.Finished)]
[InlineData(DownloadStationTaskStatus.Downloaded)]
[InlineData(DownloadStationTaskStatus.Seeding)]
public async Task Update_WhenCompleteAndFileExists_CompletesWithoutError(DownloadStationTaskStatus status)
{
// Arrange
var mocks = new Mocks();
const String filePath = "/data/downloads/file.mkv";
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ReturnsAsync(new DownloadStationTask { Status = status });
var fileSystem = new MockFileSystem();
fileSystem.AddFile(filePath, new MockFileData("content"));
var downloader = new DownloadStationDownloader(mocks.Gid, "https://fake.url/file.mkv", "/remote/file.mkv", filePath, "download-path",
mocks.SynologyClientMock.Object, new FakeDelayProvider(), fileSystem);
DownloadCompleteEventArgs? completed = null;
downloader.DownloadComplete += (_, e) => completed = e;
// Act
await downloader.Update();
// Assert
Assert.NotNull(completed);
Assert.Null(completed!.Error);
}
[Fact]
public async Task Update_WhenFinishedButFileMissing_CompletesWithError()
{
// Arrange
var mocks = new Mocks();
const String filePath = "/data/downloads/file.mkv";
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ReturnsAsync(new DownloadStationTask { Status = DownloadStationTaskStatus.Finished });
var downloader = new DownloadStationDownloader(mocks.Gid, "https://fake.url/file.mkv", "/remote/file.mkv", filePath, "download-path",
mocks.SynologyClientMock.Object, new FakeDelayProvider(), new MockFileSystem());
DownloadCompleteEventArgs? completed = null;
downloader.DownloadComplete += (_, e) => completed = e;
// Act
await downloader.Update();
// Assert
Assert.NotNull(completed);
Assert.NotNull(completed!.Error);
Assert.Contains("no file was found", completed.Error!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Update_WhenErrorDetailSet_CancelsAndReportsError()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid))
.ReturnsAsync(new DownloadStationTask
{
Status = DownloadStationTaskStatus.Downloading,
StatusExtra = new DownloadStationTaskStatusExtra { ErrorDetail = "torrent_duplicate" }
});
mocks.TaskEndpointMock.Setup(t => t.DeleteAsync(It.IsAny<DownloadStationTaskDeleteRequest>()))
.ReturnsAsync(new DownloadStationTaskDeleteResponse());
var downloader = new DownloadStationDownloader(mocks.Gid, "https://fake.url/file.mkv", "/remote/file.mkv", "/data/downloads/file.mkv", "download-path",
mocks.SynologyClientMock.Object, new FakeDelayProvider(), new MockFileSystem());
DownloadCompleteEventArgs? completed = null;
downloader.DownloadComplete += (_, e) => completed = e;
// Act
await downloader.Update();
// Assert
Assert.NotNull(completed);
Assert.Contains("torrent_duplicate", completed!.Error!, StringComparison.OrdinalIgnoreCase);
mocks.TaskEndpointMock.Verify(t => t.DeleteAsync(It.IsAny<DownloadStationTaskDeleteRequest>()), Times.Once);
}
[Fact]
public async Task Update_WhenCaptchaNeeded_ReportsError()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ReturnsAsync(new DownloadStationTask { Status = DownloadStationTaskStatus.CaptchaNeeded });
mocks.TaskEndpointMock.Setup(t => t.DeleteAsync(It.IsAny<DownloadStationTaskDeleteRequest>())).ReturnsAsync(new DownloadStationTaskDeleteResponse());
var downloader = new DownloadStationDownloader(mocks.Gid, "https://fake.url/file.mkv", "/remote/file.mkv", "/data/downloads/file.mkv", "download-path",
mocks.SynologyClientMock.Object, new FakeDelayProvider(), new MockFileSystem());
DownloadCompleteEventArgs? completed = null;
downloader.DownloadComplete += (_, e) => completed = e;
// Act
await downloader.Update();
// Assert
Assert.NotNull(completed);
Assert.Contains("captcha", completed!.Error!, StringComparison.OrdinalIgnoreCase);
}
}

View file

@ -0,0 +1,108 @@
using Moq;
using RdtClient.Service.Services.Downloaders;
using Synology.Api.Client;
namespace RdtClient.Service.Test.Services.Downloaders;
public class SynologyClientProviderTest
{
public SynologyClientProviderTest()
{
// Static cache is shared across the process; start every test from a clean slate.
SynologyClientProvider.Reset();
}
[Fact]
public async Task GetClientAsync_ConcurrentCalls_AuthenticatesOnceAndSharesOneClient()
{
var logins = 0;
var shared = new Mock<ISynologyClient>().Object;
SynologyClientProvider.ClientFactory = (_, _, _) =>
{
Interlocked.Increment(ref logins);
return Task.FromResult(shared);
};
var clients = await Task.WhenAll(Enumerable.Range(0, 50).Select(_ => SynologyClientProvider.GetClientAsync("http://nas:5000", "user", "pass")));
Assert.Equal(1, logins);
Assert.All(clients, c => Assert.Same(shared, c));
SynologyClientProvider.Reset();
}
[Fact]
public async Task GetClientAsync_SameCredentials_ReusesClient_DifferentCredentials_ReAuthenticates()
{
var logins = 0;
SynologyClientProvider.ClientFactory = (_, _, _) =>
{
Interlocked.Increment(ref logins);
return Task.FromResult(new Mock<ISynologyClient>().Object);
};
await SynologyClientProvider.GetClientAsync("http://nas:5000", "user", "pass");
await SynologyClientProvider.GetClientAsync("http://nas:5000", "user", "pass");
await SynologyClientProvider.GetClientAsync("http://nas:5000", "user", "changed");
Assert.Equal(2, logins);
SynologyClientProvider.Reset();
}
[Fact]
public async Task InvalidateAsync_ThenGet_AuthenticatesAgainAndLogsOutTheStaleSession()
{
var logins = 0;
var firstMock = new Mock<ISynologyClient>();
firstMock.Setup(c => c.LogoutAsync()).Returns(Task.CompletedTask);
var clients = new Queue<ISynologyClient>([firstMock.Object, new Mock<ISynologyClient>().Object]);
SynologyClientProvider.ClientFactory = (_, _, _) =>
{
Interlocked.Increment(ref logins);
return Task.FromResult(clients.Dequeue());
};
var first = await SynologyClientProvider.GetClientAsync("http://nas:5000", "user", "pass");
await SynologyClientProvider.InvalidateAsync(first);
var second = await SynologyClientProvider.GetClientAsync("http://nas:5000", "user", "pass");
Assert.Equal(2, logins);
Assert.NotSame(first, second);
firstMock.Verify(c => c.LogoutAsync(), Times.Once);
SynologyClientProvider.Reset();
}
[Fact]
public async Task InvalidateAsync_WithAlreadyReplacedClient_DoesNotDropTheCurrentSession()
{
var logins = 0;
SynologyClientProvider.ClientFactory = (_, _, _) =>
{
Interlocked.Increment(ref logins);
return Task.FromResult(new Mock<ISynologyClient>().Object);
};
var current = await SynologyClientProvider.GetClientAsync("http://nas:5000", "user", "pass");
// A late failure referencing a session that was already rotated out must not invalidate the live one.
await SynologyClientProvider.InvalidateAsync(new Mock<ISynologyClient>().Object);
var afterStaleInvalidate = await SynologyClientProvider.GetClientAsync("http://nas:5000", "user", "pass");
Assert.Same(current, afterStaleInvalidate);
Assert.Equal(1, logins);
SynologyClientProvider.Reset();
}
}

View file

@ -1,7 +1,10 @@
using RdtClient.Service.Helpers;
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;
@ -16,15 +19,21 @@ internal class DelayProvider : IDelayProvider
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;
// 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,
@ -33,10 +42,12 @@ public class DownloadStationDownloader : IDownloader
String filePath,
String downloadPath,
ISynologyClient synologyClient,
IDelayProvider? delayProvider = null)
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} and downloadPath {downloadPath} and GID {gid}");
_logger.Debug($"Instantiated new DownloadStation Downloader for URI {uri} to filePath {filePath} (remote {remotePath}) and downloadPath {downloadPath} and GID {gid}");
_gid = gid;
_filePath = filePath;
@ -44,6 +55,33 @@ public class DownloadStationDownloader : IDownloader
_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;
@ -51,20 +89,35 @@ public class DownloadStationDownloader : IDownloader
public async Task Cancel()
{
if (_gid != null)
{
_logger.Debug($"Remove download {_uri} {_gid} from Synology DownloadStation");
// Resolve the gid from the task list in case a task was created but its id was never persisted (orphan cleanup).
_gid ??= await GetGidFromUri();
await _synologyClient.DownloadStationApi()
.TaskEndpoint()
.DeleteAsync(new()
{
Ids =
[
_gid
],
ForceComplete = false
});
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}");
}
}
@ -85,13 +138,19 @@ public class DownloadStationDownloader : IDownloader
if (task != null)
{
throw new($"The download link {_uri} has already been added to DownloadStation");
// 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 < 5)
while (retryCount < RetryCount)
{
_gid = await GetGidFromUri();
@ -104,14 +163,18 @@ public class DownloadStationDownloader : IDownloader
try
{
var createResult = await _synologyClient
.DownloadStationApi()
.TaskEndpoint()
.CreateAsync(new(_uri, path[1..]));
// 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)
@ -122,7 +185,7 @@ public class DownloadStationDownloader : IDownloader
}
retryCount++;
_logger.Error($"Task not found in DownloadStation after creat Sucess. Retrying {retryCount}/{RetryCount}");
_logger.Error($"Task not found in DownloadStation after a successful create. Retrying {retryCount}/{RetryCount}");
await _delayProvider.Delay(retryCount * 1000);
}
catch (Exception e)
@ -140,20 +203,38 @@ public class DownloadStationDownloader : IDownloader
{
_logger.Debug($"Pausing download {_uri} {_gid}");
if (_gid != null)
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)
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)
@ -168,8 +249,23 @@ public class DownloadStationDownloader : IDownloader
throw new("No username/password specified for Synology download station");
}
var synologyClient = new SynologyClient(Settings.Get.DownloadClient.DownloadStationUrl);
await synologyClient.LoginAsync(Settings.Get.DownloadClient.DownloadStationUsername, Settings.Get.DownloadClient.DownloadStationPassword);
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;
@ -177,6 +273,10 @@ public class DownloadStationDownloader : IDownloader
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
{
@ -196,14 +296,105 @@ public class DownloadStationDownloader : IDownloader
}
}
return new(gid, uri, remotePath, filePath, downloadPath, synologyClient);
// 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()
{
var tasks = await _synologyClient.DownloadStationApi().TaskEndpoint().ListAsync();
try
{
var tasks = await WithSessionRetry(c => c.DownloadStationApi().TaskEndpoint().ListAsync());
return tasks.Task?.FirstOrDefault(t => t.Additional?.Detail?.Uri == _uri)?.Id;
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()
@ -232,17 +423,30 @@ public class DownloadStationDownloader : IDownloader
return;
}
if (task.Status == DownloadStationTaskStatus.Finished)
// 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 = null
Error = $"DownloadStation error: {detail}"
});
return;
}
if (IsComplete(task))
{
await CompleteWhenFileExists();
return;
}
DownloadProgress?.Invoke(this,
new()
{
@ -252,19 +456,69 @@ public class DownloadStationDownloader : IDownloader
});
}
/// <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)
var gid = _gid;
if (gid == null)
{
return null;
}
return await _synologyClient.DownloadStationApi().TaskEndpoint().GetInfoAsync(_gid);
return await WithSessionRetry(c => c.DownloadStationApi().TaskEndpoint().GetInfoAsync(gid));
}
catch
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;
}
}

View file

@ -0,0 +1,124 @@
using Synology.Api.Client;
namespace RdtClient.Service.Services.Downloaders;
/// <summary>
/// Holds a single authenticated <see cref="ISynologyClient" /> shared across all Download Station downloads.
/// Logging in once instead of once per download avoids the concurrent-login session churn that made DSM return
/// error 119 "SID not found" under high file-count concurrency. The session is created on demand, reused while
/// the credentials are unchanged, and dropped (via <see cref="InvalidateAsync" />) when a download hits a session
/// error so the next caller logs in again.
/// </summary>
public static class SynologyClientProvider
{
private static readonly Func<String, String, String, Task<ISynologyClient>> DefaultClientFactory = async (url, username, password) =>
{
var client = new SynologyClient(url);
await client.LoginAsync(username, password);
return client;
};
private static readonly SemaphoreSlim Gate = new(1, 1);
private static ISynologyClient? _client;
private static (String Url, String Username, String Password)? _key;
/// <summary>
/// Builds an authenticated client. Overridable so tests can supply a fake without a live DSM. Restored by
/// <see cref="Reset" />.
/// </summary>
public static Func<String, String, String, Task<ISynologyClient>> ClientFactory { get; set; } = DefaultClientFactory;
/// <summary>
/// Returns the shared authenticated client for these credentials, logging in once. Concurrent first calls
/// collapse to a single login; later calls reuse the cached client.
/// </summary>
public static async Task<ISynologyClient> GetClientAsync(String url, String username, String password)
{
var key = (url, username, password);
// Fast path: a client for these exact credentials already exists.
var current = _client;
if (current != null && _key == key)
{
return current;
}
await Gate.WaitAsync();
try
{
if (_client != null && _key == key)
{
return _client;
}
// No client yet, or the credentials changed: drop any stale session and authenticate fresh.
await TryLogoutAsync(_client);
_client = await ClientFactory(url, username, password);
_key = key;
return _client;
}
finally
{
Gate.Release();
}
}
/// <summary>
/// Drops the cached session so the next <see cref="GetClientAsync" /> authenticates again. Pass the client that
/// just failed so a session another caller has already refreshed is not thrown away.
/// </summary>
public static async Task InvalidateAsync(ISynologyClient? failedClient = null)
{
await Gate.WaitAsync();
try
{
if (failedClient != null && !ReferenceEquals(_client, failedClient))
{
return;
}
await TryLogoutAsync(_client);
_client = null;
_key = null;
}
finally
{
Gate.Release();
}
}
/// <summary>
/// Clears the cached session and restores the default factory. For test isolation.
/// </summary>
public static void Reset()
{
_client = null;
_key = null;
ClientFactory = DefaultClientFactory;
}
private static async Task TryLogoutAsync(ISynologyClient? client)
{
if (client == null)
{
return;
}
try
{
await client.LogoutAsync();
}
catch
{
// Best-effort: an expired or dead session cannot be logged out, and that is fine.
}
}
}