Fix and harden Synology Download Station support
The DownloadStation downloader left downloads stuck or silently failing. This fixes the core flow and documents setup: - Create the per-download destination folder via File Station (force_parent) before creating the task; DownloadStation does not create it for a direct-file task, which otherwise fails with "Destination does not exist". - Auto-set the account's default destination when it has none (DSM leaves every task "Waiting" otherwise), preserving the other server settings. - Detect completion across the terminal states (Finished/Downloaded/Seeding/ PreSeeding or fully transferred), then verify the file is visible at the container path before signalling success, so a path-mapping mismatch is a clear error instead of a silent import failure. - Surface DownloadStation errors (status_extra.error_detail, captcha) instead of polling progress forever. - Harden task-list / get-info / delete handling against responses that fail to deserialize (upstream #723/#792) so one bad task can't abort a download. - Make task creation idempotent: adopt an existing task instead of throwing "already added", which previously bricked every retry. Adds unit tests, fixes a settings description typo, and documents Download Station setup and the path-mapping requirement in the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
858bd7c95c
commit
c8313361a2
4 changed files with 408 additions and 25 deletions
36
README.md
36
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 [<account>]`). 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 <container path> (DownloadStation saved to <NAS path>)` in the log, the two Download paths are not the same folder — line them up.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -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)")]
|
||||
|
|
|
|||
|
|
@ -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<IFileStationCreateFolderEndpoint> CreateFolderEndpointMock = new();
|
||||
public readonly String Gid;
|
||||
public readonly Mock<ISynologyClient> SynologyClientMock = new();
|
||||
public readonly Mock<IDownloadStationTaskEndpoint> TaskEndpointMock = new();
|
||||
|
|
@ -16,9 +21,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 +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<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 (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<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_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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 +50,7 @@ public class DownloadStationDownloader : IDownloader
|
|||
_remotePath = remotePath;
|
||||
_synologyClient = synologyClient;
|
||||
_delayProvider = delayProvider ?? new DelayProvider();
|
||||
_fileSystem = fileSystem ?? new FileSystem();
|
||||
}
|
||||
|
||||
public event EventHandler<DownloadCompleteEventArgs>? 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<String> 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/<name>.
|
||||
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<DownloadStationDownloader> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DownloadStation resolves a per-account "default destination"; without one it leaves every task in "Waiting"
|
||||
/// (DSM logs "Failed to get default download destination of user"). Set it for this account when it has none,
|
||||
/// best-effort and preserving the other server settings, so downloads start without a manual setup step.
|
||||
/// </summary>
|
||||
private static async Task EnsureDefaultDestination(ISynologyClient synologyClient, String destination)
|
||||
{
|
||||
var logger = Log.ForContext<DownloadStationDownloader>();
|
||||
|
||||
try
|
||||
{
|
||||
var config = await synologyClient.DownloadStationApi().InfoEndpoint().GetConfigAsync();
|
||||
|
||||
if (!String.IsNullOrWhiteSpace(config.DefaultDestination))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-send the existing config unchanged except for the default destination, so nothing else is reset.
|
||||
var updated = new DownloadStationServerConfig(config.BtMaxDownloadSpeed,
|
||||
config.BtMaxUploadSpeed,
|
||||
config.EmulEnable,
|
||||
config.EmulMaxDownloadSpeed,
|
||||
config.EmulMaxUploadSpeed,
|
||||
config.FtpMaxDownloadSpeed,
|
||||
config.HttpMaxDownloadSpeed,
|
||||
config.NzbMaxDownloadSpeed,
|
||||
config.UnzipServiceEnable,
|
||||
destination,
|
||||
config.EmulDefaultDestination);
|
||||
|
||||
await synologyClient.DownloadStationApi().InfoEndpoint().SetServerConfigAsync(updated);
|
||||
|
||||
logger.Information($"Set the DownloadStation default destination to '{destination}' for this account (it had none).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"Could not set the DownloadStation default destination automatically ({ex.Message}). " +
|
||||
$"If downloads stay in 'Waiting', sign into Download Station as this account and set a Default destination " +
|
||||
$"under Settings -> BT/HTTP/FTP/NZB -> Location.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the destination folder exists on the Synology before a task is created. DownloadStation does not create
|
||||
/// the destination for a direct-file download task, so a missing folder yields "Destination does not exist".
|
||||
/// </summary>
|
||||
private async Task EnsureRemoteFolderExists(String folderPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _synologyClient.FileStationApi()
|
||||
.CreateFolderEndpoint()
|
||||
.CreateAsync([folderPath], true);
|
||||
|
||||
_logger.Debug($"Ensured DownloadStation destination folder exists: {folderPath}");
|
||||
}
|
||||
catch (SynologyApiException ex)
|
||||
{
|
||||
// With force_parent (createParentFolders), creating a folder that already exists returns success, so an
|
||||
// exception here means the folder could NOT be created — most commonly because the Synology account lacks
|
||||
// File Station permission or write access to the destination. Surface it clearly: the task create below
|
||||
// will then fail with "Destination does not exist", and this warning explains why.
|
||||
_logger.Warning($"Could not create the DownloadStation destination folder '{folderPath}' " +
|
||||
$"(File Station error {ex.ErrorCode}: {ex.ErrorDescription}). The Synology account must have " +
|
||||
$"File Station permission with read/write access to that path.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<String?> GetGidFromUri()
|
||||
{
|
||||
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
|
|||
});
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue