From c8313361a23da10de48cdab829b89b3b6558499d Mon Sep 17 00:00:00 2001 From: Antonin Lenfant-Kodia Date: Mon, 15 Jun 2026 15:42:10 +0200 Subject: [PATCH 1/4] 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) --- README.md | 36 ++- .../Models/Internal/DbSettings.cs | 2 +- .../DownloadStationDownloaderTest.cs | 158 +++++++++++- .../Downloaders/DownloadStationDownloader.cs | 237 ++++++++++++++++-- 4 files changed, 408 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 755ba15..8d47df0 100644 --- a/README.md +++ b/README.md @@ -150,14 +150,44 @@ 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. + +To set it up: + +1. Complete the Synology prerequisites below. +2. In rdt-client, go to **Settings** and set **Download client** to **Synology Download Station**. +3. Fill in the Download Station options below (URL, username, password, download path). +4. Make sure the path mapping lines up (see below), then **Save**. + +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 — Download Station will not create it itself). 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** is **per-user**. When you set a Download Path (below), rdt-client sets this account's default destination for you **only if it doesn't already have one** (an existing default is left untouched), so you normally don't need to do anything. If that automatic set fails (e.g. the account isn't allowed to change Download Station settings), 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 []`). A default set for your admin account does **not** apply to a dedicated download 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` (Download Station would treat `volume1` as a share name and fail with "Destination does not exist"). **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.** + +This is the single most common failure. If the two don't line up, Download Station saves the file somewhere rdt-client can't see, the download "finishes", and the import never happens. The two settings look different only because each names that one folder in a different dialect, with the **container bind mount as the bridge** between them: + +- **Download Path** (this setting) is NAS share-relative: `Media/Downloads/Torrents` → physically `/volume1/Media/Downloads/Torrents`. +- **Download path** (the general rdt-client setting) is the container path: `/data/downloads`, bind-mounted so that exact path *is* the share folder above — `-v /volume1/Media/Downloads/Torrents:/data/downloads`. + +The match must cover the **full** general Download path, including any subfolder. If your general Download path is `/data/downloads/Completed`, then *that* (not `/data/downloads`) is what must equal the NAS folder — so either bind-mount the share there, or set this Download Path to the matching subfolder (`Media/Downloads/Torrents/Completed`). Note that, unlike the in-process Bezzad downloader, Download Station writes the file straight into this folder *as it downloads*, so a folder named `Completed` will briefly hold in-progress files — that is normal with Download Station. + +A working example: bind-mount `/volume1/Media/Downloads/Torrents` to `/data/downloads`, set the general Download path to `/data/downloads`, and set this Download Path to `Media/Downloads/Torrents` — all three name the same folder. + +Separately, the **Mapped Path** must point at that same folder as Sonarr/Radarr see it (through *their* mount), or the download completes but never imports. + +If you see `DownloadStation reported the download finished, but no file was found at (DownloadStation saved to )` in the log, the two Download paths are not the same folder — line them up. ### Troubleshooting diff --git a/server/RdtClient.Data/Models/Internal/DbSettings.cs b/server/RdtClient.Data/Models/Internal/DbSettings.cs index c94f27a..54ef2ee 100644 --- a/server/RdtClient.Data/Models/Internal/DbSettings.cs +++ b/server/RdtClient.Data/Models/Internal/DbSettings.cs @@ -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 on the Synology to download to, relative to a shared folder (e.g. \"Media/Downloads/Torrents\"), NOT an absolute path like \"/volume1/Media/...\". If empty, the default Download Station destination is used. The account also needs File Station permission so the per-download destination folder can be created.")] public String? DownloadStationDownloadPath { get; set; } = null; [DisplayName("Minimum free disk space (GB) (Bezzad only)")] diff --git a/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs b/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs index e992403..fde1e4d 100644 --- a/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs +++ b/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs @@ -1,14 +1,19 @@ +using System.IO.Abstractions.TestingHelpers; using Moq; using RdtClient.Service.Helpers; using RdtClient.Service.Services.Downloaders; using Synology.Api.Client; 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; namespace RdtClient.Service.Test.Services.Downloaders; internal class Mocks { + public readonly Mock CreateFolderEndpointMock = new(); public readonly String Gid; public readonly Mock SynologyClientMock = new(); public readonly Mock TaskEndpointMock = new(); @@ -16,9 +21,17 @@ internal class Mocks public Mocks(String gid = "123456") { Gid = gid; + var downloadStationApiMock = new Mock(); downloadStationApiMock.Setup(a => a.TaskEndpoint()).Returns(TaskEndpointMock.Object); SynologyClientMock.Setup(c => c.DownloadStationApi()).Returns(downloadStationApiMock.Object); + + var fileStationApiMock = new Mock(); + fileStationApiMock.Setup(a => a.CreateFolderEndpoint()).Returns(CreateFolderEndpointMock.Object); + SynologyClientMock.Setup(c => c.FileStationApi()).Returns(fileStationApiMock.Object); + + CreateFolderEndpointMock.Setup(e => e.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new FileStationCreateFolderCreateResponse()); } } @@ -55,7 +68,7 @@ public class DownloadStationDownloaderTest } [Fact] - public async Task Download_WhenAlreadyAdded_Throws() + public async Task Download_WhenTaskAlreadyExists_AdoptsItAndReturnsGid() { // Arrange var mocks = new Mocks(); @@ -69,10 +82,12 @@ public class DownloadStationDownloaderTest mocks.SynologyClientMock.Object); // Act - var exception = await Assert.ThrowsAsync(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 (upstream #792), 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 +131,37 @@ 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(); + mocks.CreateFolderEndpointMock.Setup(e => e.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new FileStationCreateFolderCreateResponse()) + .Callback(() => calls.Add("folder")); + mocks.TaskEndpointMock.Setup(t => t.CreateAsync(It.IsAny())) + .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(p => p.Single() == "/Media/Downloads/Torrents/MyTorrent"), true), Times.Once); + } + [Fact] public async Task Download_After5Tries_Throws() { @@ -210,4 +256,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())) + .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()), 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())).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); + } } diff --git a/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs b/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs index 5353162..1f377ef 100644 --- a/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs +++ b/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs @@ -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,7 +19,9 @@ 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; @@ -33,10 +38,11 @@ public class DownloadStationDownloader : IDownloader String filePath, String downloadPath, ISynologyClient synologyClient, - IDelayProvider? delayProvider = null) + IDelayProvider? delayProvider = null, + IFileSystem? fileSystem = null) { _logger = Log.ForContext(); - _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 +50,7 @@ public class DownloadStationDownloader : IDownloader _remotePath = remotePath; _synologyClient = synologyClient; _delayProvider = delayProvider ?? new DelayProvider(); + _fileSystem = fileSystem ?? new FileSystem(); } public event EventHandler? DownloadComplete; @@ -51,10 +58,18 @@ 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(); + if (_gid == null) + { + return; + } + + _logger.Debug($"Remove download {_uri} {_gid} from Synology DownloadStation"); + + try + { await _synologyClient.DownloadStationApi() .TaskEndpoint() .DeleteAsync(new() @@ -66,6 +81,11 @@ public class DownloadStationDownloader : IDownloader 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 Download() @@ -85,13 +105,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 (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 < 5) + while (retryCount < RetryCount) { _gid = await GetGidFromUri(); @@ -104,6 +130,10 @@ public class DownloadStationDownloader : IDownloader 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/. + await EnsureRemoteFolderExists(path); + var createResult = await _synologyClient .DownloadStationApi() .TaskEndpoint() @@ -112,6 +142,7 @@ public class DownloadStationDownloader : IDownloader _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 +153,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 +171,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 Init(String? gid, String uri, String filePath, String downloadPath, String? category) @@ -169,7 +218,16 @@ public class DownloadStationDownloader : IDownloader } var synologyClient = new SynologyClient(Settings.Get.DownloadClient.DownloadStationUrl); - await synologyClient.LoginAsync(Settings.Get.DownloadClient.DownloadStationUsername, Settings.Get.DownloadClient.DownloadStationPassword); + + 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; @@ -177,6 +235,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 { @@ -199,11 +261,91 @@ public class DownloadStationDownloader : IDownloader return new(gid, uri, remotePath, filePath, downloadPath, synologyClient); } + /// + /// 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. + /// + private static async Task EnsureDefaultDestination(ISynologyClient synologyClient, String destination) + { + var logger = Log.ForContext(); + + 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."); + } + } + + /// + /// 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". + /// + 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 GetGidFromUri() { - var tasks = await _synologyClient.DownloadStationApi().TaskEndpoint().ListAsync(); + try + { + var tasks = await _synologyClient.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 (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() @@ -232,17 +374,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,6 +407,51 @@ public class DownloadStationDownloader : IDownloader }); } + /// + /// A DownloadStation task is done downloading in several terminal states (it is not always Finished — a + /// completed torrent settles into Seeding/Downloaded). Also treat fully-transferred tasks as complete. + /// + 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; + } + + /// + /// 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. + /// + 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 GetTask() { try @@ -263,8 +463,11 @@ public class DownloadStationDownloader : IDownloader return await _synologyClient.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; } } From 643a2e3d6348a81f72abdef4fdb9b8526f8b50e1 Mon Sep 17 00:00:00 2001 From: Antonin Lenfant-Kodia Date: Mon, 15 Jun 2026 15:42:10 +0200 Subject: [PATCH 2/4] Share one authenticated Synology session across downloads Init() created a new SynologyClient and logged in for every download. A many-file download opened dozens of near-simultaneous sessions for one DSM account; DSM invalidates older session IDs, so in-flight File Station calls failed with error 119 "SID not found". Add SynologyClientProvider: a thread-safe cache that authenticates once and shares the client across downloads (concurrent first calls collapse to a single login; later calls reuse it; a credential change re-authenticates). Wrap every Synology call in a session-retry that drops the session and re-authenticates once on a session error (119/106/107) so an expired SID self-heals, reading credentials fresh from settings so edits take effect live. Adds provider unit tests and a downloader test covering the 119 re-auth path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DownloadStationDownloaderTest.cs | 41 ++++++ .../Downloaders/SynologyClientProviderTest.cs | 108 +++++++++++++++ .../Downloaders/DownloadStationDownloader.cs | 103 +++++++++++---- .../Downloaders/SynologyClientProvider.cs | 124 ++++++++++++++++++ 4 files changed, 350 insertions(+), 26 deletions(-) create mode 100644 server/RdtClient.Service.Test/Services/Downloaders/SynologyClientProviderTest.cs create mode 100644 server/RdtClient.Service/Services/Downloaders/SynologyClientProvider.cs diff --git a/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs b/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs index fde1e4d..1723166 100644 --- a/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs +++ b/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs @@ -3,11 +3,13 @@ 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; @@ -162,6 +164,45 @@ public class DownloadStationDownloaderTest mocks.CreateFolderEndpointMock.Verify(e => e.CreateAsync(It.Is(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())) + .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(), It.IsAny())) + .ThrowsAsync(new SynologyApiException(new Mock().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(), It.IsAny()), Times.Exactly(2)); + } + [Fact] public async Task Download_After5Tries_Throws() { diff --git a/server/RdtClient.Service.Test/Services/Downloaders/SynologyClientProviderTest.cs b/server/RdtClient.Service.Test/Services/Downloaders/SynologyClientProviderTest.cs new file mode 100644 index 0000000..2cd4337 --- /dev/null +++ b/server/RdtClient.Service.Test/Services/Downloaders/SynologyClientProviderTest.cs @@ -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().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().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(); + firstMock.Setup(c => c.LogoutAsync()).Returns(Task.CompletedTask); + + var clients = new Queue([firstMock.Object, new Mock().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().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().Object); + + var afterStaleInvalidate = await SynologyClientProvider.GetClientAsync("http://nas:5000", "user", "pass"); + + Assert.Same(current, afterStaleInvalidate); + Assert.Equal(1, logins); + + SynologyClientProvider.Reset(); + } +} diff --git a/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs b/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs index 1f377ef..366b508 100644 --- a/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs +++ b/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs @@ -27,9 +27,13 @@ public class DownloadStationDownloader : IDownloader 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> _reacquireClient; + private String? _gid; public DownloadStationDownloader(String? gid, @@ -39,7 +43,8 @@ public class DownloadStationDownloader : IDownloader String downloadPath, ISynologyClient synologyClient, IDelayProvider? delayProvider = null, - IFileSystem? fileSystem = null) + IFileSystem? fileSystem = null, + Func>? reacquireClient = null) { _logger = Log.ForContext(); _logger.Debug($"Instantiated new DownloadStation Downloader for URI {uri} to filePath {filePath} (remote {remotePath}) and downloadPath {downloadPath} and GID {gid}"); @@ -51,6 +56,32 @@ public class DownloadStationDownloader : IDownloader _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)); + } + + /// + /// 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. + /// + private async Task WithSessionRetry(Func> 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? DownloadComplete; @@ -68,18 +99,20 @@ public class DownloadStationDownloader : IDownloader _logger.Debug($"Remove download {_uri} {_gid} from Synology DownloadStation"); + var gid = _gid; + try { - await _synologyClient.DownloadStationApi() - .TaskEndpoint() - .DeleteAsync(new() - { - Ids = - [ - _gid - ], - ForceComplete = false - }); + await WithSessionRetry(c => c.DownloadStationApi() + .TaskEndpoint() + .DeleteAsync(new() + { + Ids = + [ + gid + ], + ForceComplete = false + })); } catch (Exception ex) { @@ -134,10 +167,9 @@ public class DownloadStationDownloader : IDownloader // `path` is the share-relative destination folder with a leading slash, e.g. /Media/Downloads/Torrents/. await EnsureRemoteFolderExists(path); - var createResult = await _synologyClient - .DownloadStationApi() - .TaskEndpoint() - .CreateAsync(new(_uri, path[1..])); + 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}"); @@ -217,15 +249,21 @@ public class DownloadStationDownloader : IDownloader throw new("No username/password specified for Synology download station"); } - var synologyClient = new SynologyClient(Settings.Get.DownloadClient.DownloadStationUrl); + var url = Settings.Get.DownloadClient.DownloadStationUrl!; + var username = Settings.Get.DownloadClient.DownloadStationUsername!; + var password = Settings.Get.DownloadClient.DownloadStationPassword!; + + ISynologyClient synologyClient; try { - await synologyClient.LoginAsync(Settings.Get.DownloadClient.DownloadStationUsername, Settings.Get.DownloadClient.DownloadStationPassword); + // 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 '{Settings.Get.DownloadClient.DownloadStationUsername}': {ex.Message}. " + + 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."); } @@ -258,7 +296,18 @@ 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!)); } /// @@ -312,9 +361,9 @@ public class DownloadStationDownloader : IDownloader { try { - await _synologyClient.FileStationApi() - .CreateFolderEndpoint() - .CreateAsync([folderPath], true); + await WithSessionRetry(c => c.FileStationApi() + .CreateFolderEndpoint() + .CreateAsync([folderPath], true)); _logger.Debug($"Ensured DownloadStation destination folder exists: {folderPath}"); } @@ -334,7 +383,7 @@ public class DownloadStationDownloader : IDownloader { try { - var tasks = await _synologyClient.DownloadStationApi().TaskEndpoint().ListAsync(); + var tasks = await WithSessionRetry(c => c.DownloadStationApi().TaskEndpoint().ListAsync()); return tasks.Task?.FirstOrDefault(t => t.Additional?.Detail?.Uri == _uri)?.Id; } @@ -456,12 +505,14 @@ public class DownloadStationDownloader : IDownloader { 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 (Exception ex) { diff --git a/server/RdtClient.Service/Services/Downloaders/SynologyClientProvider.cs b/server/RdtClient.Service/Services/Downloaders/SynologyClientProvider.cs new file mode 100644 index 0000000..fbde66f --- /dev/null +++ b/server/RdtClient.Service/Services/Downloaders/SynologyClientProvider.cs @@ -0,0 +1,124 @@ +using Synology.Api.Client; + +namespace RdtClient.Service.Services.Downloaders; + +/// +/// Holds a single authenticated 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 ) when a download hits a session +/// error so the next caller logs in again. +/// +public static class SynologyClientProvider +{ + private static readonly Func> 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; + + /// + /// Builds an authenticated client. Overridable so tests can supply a fake without a live DSM. Restored by + /// . + /// + public static Func> ClientFactory { get; set; } = DefaultClientFactory; + + /// + /// 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. + /// + public static async Task 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(); + } + } + + /// + /// Drops the cached session so the next authenticates again. Pass the client that + /// just failed so a session another caller has already refreshed is not thrown away. + /// + 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(); + } + } + + /// + /// Clears the cached session and restores the default factory. For test isolation. + /// + 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. + } + } +} From b2a725c285b1779f69287c5f5a6454d095cb7bcf Mon Sep 17 00:00:00 2001 From: Antonin Lenfant-Kodia Date: Mon, 15 Jun 2026 15:58:23 +0200 Subject: [PATCH 3/4] Simplify comments --- server/RdtClient.Data/Models/Internal/DbSettings.cs | 4 ++-- .../Services/Downloaders/DownloadStationDownloaderTest.cs | 2 +- .../Services/Downloaders/DownloadStationDownloader.cs | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/RdtClient.Data/Models/Internal/DbSettings.cs b/server/RdtClient.Data/Models/Internal/DbSettings.cs index 54ef2ee..f5c9548 100644 --- a/server/RdtClient.Data/Models/Internal/DbSettings.cs +++ b/server/RdtClient.Data/Models/Internal/DbSettings.cs @@ -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 on the Synology to download to, relative to a shared folder (e.g. \"Media/Downloads/Torrents\"), NOT an absolute path like \"/volume1/Media/...\". If empty, the default Download Station destination is used. The account also needs File Station permission so the per-download destination folder can be created.")] + [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)")] diff --git a/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs b/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs index 1723166..1684e16 100644 --- a/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs +++ b/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs @@ -88,7 +88,7 @@ public class DownloadStationDownloaderTest // 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 (upstream #792), so the task is never actually removed. + // 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(); diff --git a/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs b/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs index 366b508..c5347ab 100644 --- a/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs +++ b/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs @@ -116,7 +116,7 @@ public class DownloadStationDownloader : IDownloader } catch (Exception ex) { - // The Synology DELETE response can itself fail to deserialize (upstream #792); don't let cleanup throw. + // The Synology DELETE response can itself fail to deserialize; don't let cleanup throw. _logger.Debug($"Failed to remove DownloadStation task {_gid}: {ex.Message}"); } } @@ -140,8 +140,8 @@ public class DownloadStationDownloader : IDownloader { // 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. + // 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; @@ -389,7 +389,7 @@ public class DownloadStationDownloader : IDownloader } catch (Exception ex) { - // The task-list response can fail to deserialize when a task carries a non-string error_detail (upstream #723); + // 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}"); From 3e9d2dd28a321bee0a2ddb946f457513c4f576f4 Mon Sep 17 00:00:00 2001 From: Antonin Lenfant-Kodia Date: Mon, 15 Jun 2026 16:03:37 +0200 Subject: [PATCH 4/4] Simplify README --- README.md | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8d47df0..cc4a7ec 100644 --- a/README.md +++ b/README.md @@ -152,43 +152,23 @@ Suggested configuration: 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. -To set it up: - -1. Complete the Synology prerequisites below. -2. In rdt-client, go to **Settings** and set **Download client** to **Synology Download Station**. -3. Fill in the Download Station options below (URL, username, password, download path). -4. Make sure the path mapping lines up (see below), then **Save**. - 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 — Download Station will not create it itself). 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** is **per-user**. When you set a Download Path (below), rdt-client sets this account's default destination for you **only if it doesn't already have one** (an existing default is left untouched), so you normally don't need to do anything. If that automatic set fails (e.g. the account isn't allowed to change Download Station settings), 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 []`). A default set for your admin account does **not** apply to a dedicated download account. +- 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 []`). It has the following options: - 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 destination on the Synology, **relative to a shared folder** (e.g. `Media/Downloads/Torrents`) — **not** an absolute path like `/volume1/Media/Downloads/Torrents` (Download Station would treat `volume1` as a share name and fail with "Destination does not exist"). **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. +- 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.** -This is the single most common failure. If the two don't line up, Download Station saves the file somewhere rdt-client can't see, the download "finishes", and the import never happens. The two settings look different only because each names that one folder in a different dialect, with the **container bind mount as the bridge** between them: - -- **Download Path** (this setting) is NAS share-relative: `Media/Downloads/Torrents` → physically `/volume1/Media/Downloads/Torrents`. -- **Download path** (the general rdt-client setting) is the container path: `/data/downloads`, bind-mounted so that exact path *is* the share folder above — `-v /volume1/Media/Downloads/Torrents:/data/downloads`. - -The match must cover the **full** general Download path, including any subfolder. If your general Download path is `/data/downloads/Completed`, then *that* (not `/data/downloads`) is what must equal the NAS folder — so either bind-mount the share there, or set this Download Path to the matching subfolder (`Media/Downloads/Torrents/Completed`). Note that, unlike the in-process Bezzad downloader, Download Station writes the file straight into this folder *as it downloads*, so a folder named `Completed` will briefly hold in-progress files — that is normal with Download Station. - -A working example: bind-mount `/volume1/Media/Downloads/Torrents` to `/data/downloads`, set the general Download path to `/data/downloads`, and set this Download Path to `Media/Downloads/Torrents` — all three name the same folder. - -Separately, the **Mapped Path** must point at that same folder as Sonarr/Radarr see it (through *their* mount), or the download completes but never imports. - -If you see `DownloadStation reported the download finished, but no file was found at (DownloadStation saved to )` in the log, the two Download paths are not the same folder — line them up. - ### Troubleshooting - If you forgot your logins simply delete the `rdtclient.db` and restart the service.