From 96f68dcb22a55c938aa2998b83e9df202049757d Mon Sep 17 00:00:00 2001
From: Yaknar
Date: Fri, 11 Sep 2026 12:26:44 +0200
Subject: [PATCH 1/2] feat: add HIGHWAY provider support
Add high-way.me as a debrid provider (Provider.HIGHWAY), modeled on
TorBoxDebridClient since it's the only existing client that already
handles both torrents and NZBs against a polled, non-instant backend.
Server integration (HighwayDebridClient, transfers.php):
- add/list/status/delete via bearer token (API key setting)
- 32-hex spool-reference -> numeric transfer id promotion once HIGHWAY
starts a queued transfer, mirrored in Torrents' reconcile loop
- state mapping (queued/downloading/completed/error -> TorrentStatus)
- account info via ?stats=1 (username, premium, plan expiry)
- pre-add cache check via ?check= for GetAvailableFiles()
Two download modes:
- Symlink: resolves files straight from an rclone WebDAV mount
(content_path + files[].path), no HTTP transfer
- Internal/Aria2c: downloads each file via its own signed
files[].download_url (respects include/exclude/min-size filters, no
unzip step); falls back to the whole-transfer ZIP only when
per-file signed URLs aren't available server-side
Fixes found while testing against the live server:
- DownloadClient: fall back to the provider's reported size when a
download's Content-Length is unknown (HIGHWAY's on-the-fly ZIP
streams without one, so progress got stuck at 0%)
- DebridClientUser/Profile: add a Premium flag so accounts with an
unlimited/no-expiry plan don't show "Not premium" in the navbar
- torrent.component.html: fix the status label switch, which used
stale numeric literals from before TorrentStatus.Queued=0 was added
and was off by one for every provider, not just HIGHWAY
UI: HIGHWAY option + help text in the setup wizard and navbar
provider link; the Settings page picks up the new provider and its
API-key field automatically via the existing enum/reflection UI.
Docs: README HIGHWAY section incl. rclone.conf snippet.
HIGHWAY_PROVIDER_SPEC.md documents the transfers.php API this is
built against.
Tests: new HighwayDebridClientTest (add/list/status/delete, state
mapping, symlink path resolution, per-file vs ZIP fallback, error
handling); existing Torrents(...) call sites updated for the new
ctor parameter.
---
README.md | 47 +-
client/src/app/models/profile.model.ts | 1 +
client/src/app/navbar/navbar.component.html | 2 +
client/src/app/navbar/navbar.component.ts | 3 +
client/src/app/setup/setup.component.html | 11 +-
client/src/app/torrent/torrent.component.html | 16 +-
client/src/app/torrent/torrent.component.ts | 4 +-
server/RdtClient.Data/Enums/Provider.cs | 5 +-
.../Models/DebridClient/DebridClientUser.cs | 6 +
.../Models/Internal/DbSettings.cs | 4 +-
.../RdtClient.Data/Models/Internal/Profile.cs | 1 +
.../WatchFolderCheckerTests.cs | 2 +-
.../Services/NzbTorrentsTest.cs | 1 +
.../Services/QBittorrentTest.cs | 2 +-
.../Services/SabnzbdTest.cs | 2 +-
.../TorrentClients/HighwayDebridClientTest.cs | 587 +++++++++++++
.../Services/TorrentRunnerTest.cs | 1 +
.../Services/TorrentsTest.cs | 7 +
server/RdtClient.Service/DiConfig.cs | 1 +
.../DebridClients/HighwayDebridClient.cs | 788 ++++++++++++++++++
.../Services/DownloadClient.cs | 13 +-
server/RdtClient.Service/Services/Torrents.cs | 32 +
.../Controllers/QBittorrentControllerTest.cs | 2 +-
.../Controllers/SabnzbdControllerTest.cs | 2 +-
.../Controllers/TorrentsControllerNzbTest.cs | 2 +-
25 files changed, 1521 insertions(+), 21 deletions(-)
create mode 100644 server/RdtClient.Service.Test/Services/TorrentClients/HighwayDebridClientTest.cs
create mode 100644 server/RdtClient.Service/Services/DebridClients/HighwayDebridClient.cs
diff --git a/README.md b/README.md
index 7fccf690..84757a86 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,15 @@
# Real-Debrid Torrent & Usenet Client
-This is a web interface to manage your torrents on Real-Debrid, AllDebrid, Premiumize, TorBox or DebridLink. It supports the following features:
+This is a web interface to manage your torrents on Real-Debrid, AllDebrid, Premiumize, TorBox, DebridLink or HIGHWAY. It supports the following features:
- Add new torrents through magnets or files
-- Add usenet downloads through NZB files (TorBox and Premiumize only)
-- Download all files from Real-Debrid, AllDebrid, Premiumize or TorBox to your local machine automatically
+- Add usenet downloads through NZB files (TorBox, Premiumize and HIGHWAY only)
+- Download all files from Real-Debrid, AllDebrid, Premiumize, TorBox or HIGHWAY to your local machine automatically
- Unpack all files when finished downloading
- Implements a fake qBittorrent API so you can hook up other applications like Sonarr, Radarr or Couchpotato.
- Built with Angular 21 and .NET 10
-**You will need a Premium service at Real-Debrid, AllDebrid, Premiumize, Torbox or DebridLink!**
+**You will need a Premium service at Real-Debrid, AllDebrid, Premiumize, Torbox or DebridLink, or a HIGHWAY account!**
[Click here to sign up for Real-Debrid.](https://real-debrid.com/?id=1348683)
@@ -21,6 +21,8 @@ This is a web interface to manage your torrents on Real-Debrid, AllDebrid, Premi
[Click here to sign up for DebridLink.](https://debrid-link.fr/id/6duif)
+[Click here for HIGHWAY.](https://high-way.me/)
+
(referal links so I can get a few free premium days)
## Docker Setup
@@ -171,6 +173,41 @@ It has the following options:
> **The Synology *Download Path* (above) and rdt-client's general *Download path* must resolve to the exact same physical folder on the NAS.**
+### HIGHWAY provider
+
+[HIGHWAY](https://high-way.me/) (`high-way.me`) is also a multi hoster that downloads torrents **and** usenet server-side. rdt-client pushes magnets/NZBs to it and pulls the finished files down.
+
+Configuration under `Settings` → `Provider`:
+
+- **Provider**: `HIGHWAY`
+- **API Key**: a token you create once in the [HIGHWAY Center](https://high-way.me/) (log in, open **Sitzungen** → **API-Token erstellen**). This is a 32-character hex string and is revocable from that same list.
+
+Getting the bytes to disk — two modes:
+
+**A. Symlink + rclone WebDAV mount (recommended).** Mount HIGHWAY's WebDAV and use the **Symlink** download client. Example `rclone.conf`:
+
+```ini
+[highway]
+type = webdav
+url = https://cloud.high-way.me/dav/
+vendor = other
+user =
+pass =
+```
+
+```
+rclone mount highway: /mnt/highway --vfs-cache-mode full
+```
+
+Set **Rclone mount path** to `/mnt/highway/` and set the **Post Torrent Download Action** so it does **not** remove the transfer from the provider. rdt-client symlinks the finished files straight from the mount — no re-download.
+
+**B. Internal / Aria2c (no mount).** rdt-client downloads each file directly using its own signed URL. The usual file filters (include/exclude regex, minimum size) apply. If HIGHWAY hasn't signed per-file it falls back to fetching the whole transfer as one ZIP.
+
+Notes:
+
+- HIGHWAY torrents leech from peers server-side, so expect real `downloading` progress for minutes/hours. Only content HIGHWAY already has is linked instantly.
+- When HIGHWAY is at its active-transfer limit a new add goes into HIGHWAY's own queue and reports `queued`; this is normal and it will start automatically.
+
### Troubleshooting
- If you forgot your logins simply delete the `rdtclient.db` and restart the service.
@@ -199,7 +236,7 @@ Notice: the progress and ETA reported in Sonarr's Activity tab will not be accur
#### Usenet/NZB
-RdtClient also emulates part of the SABnzbd API so Sonarr and Radarr can add NZB downloads. This requires a provider that supports Usenet/NZB downloads, currently TorBox or Premiumize.
+RdtClient also emulates part of the SABnzbd API so Sonarr and Radarr can add NZB downloads. This requires a provider that supports Usenet/NZB downloads, currently TorBox, Premiumize or HIGHWAY.
1. Login to Sonarr or Radarr and click `Settings`.
1. Go to the `Download Client` tab and click the plus to add.
diff --git a/client/src/app/models/profile.model.ts b/client/src/app/models/profile.model.ts
index 041bc6d3..99dd1621 100644
--- a/client/src/app/models/profile.model.ts
+++ b/client/src/app/models/profile.model.ts
@@ -2,6 +2,7 @@ export class Profile {
public provider: string;
public userName: string;
public expiration: Date;
+ public premium: boolean;
public currentVersion: string;
public latestVersion: string;
public isInsecure: boolean;
diff --git a/client/src/app/navbar/navbar.component.html b/client/src/app/navbar/navbar.component.html
index ba581244..c2a9bbea 100644
--- a/client/src/app/navbar/navbar.component.html
+++ b/client/src/app/navbar/navbar.component.html
@@ -45,6 +45,8 @@
@if (profile.expiration) {
Premium Status: {{ profile.expiration | date }}
+ } @else if (profile.premium) {
+ Premium Status: Unlimited
} @else {
Not premium
}
diff --git a/client/src/app/navbar/navbar.component.ts b/client/src/app/navbar/navbar.component.ts
index 80ffe366..0ad79030 100644
--- a/client/src/app/navbar/navbar.component.ts
+++ b/client/src/app/navbar/navbar.component.ts
@@ -60,6 +60,9 @@ export class NavbarComponent implements OnInit {
case 'DebridLink':
this.providerLink = 'https://debrid-link.com/';
break;
+ case 'HIGHWAY':
+ this.providerLink = 'https://high-way.me/';
+ break;
}
});
diff --git a/client/src/app/setup/setup.component.html b/client/src/app/setup/setup.component.html
index e71b18f5..6da4ec18 100644
--- a/client/src/app/setup/setup.component.html
+++ b/client/src/app/setup/setup.component.html
@@ -62,7 +62,7 @@
@@ -92,6 +94,7 @@
Premiumize
TorBox
DebridLink
+ HIGHWAY
@@ -147,6 +150,12 @@
>.
}
+ @if (provider === 5) {
+
+ Log in to your HIGHWAY Center, open Sitzungen and use
+ API-Token erstellen to create a token.
+
+ }
Real-Debrid Status
@switch (torrent.rdStatus) {
- @case (0) {
+ @case (RealDebridStatus.Queued) {
+ Queued
+ }
+ @case (RealDebridStatus.Processing) {
Processing
}
- @case (1) {
+ @case (RealDebridStatus.WaitingForFileSelection) {
Waiting For File Selection
}
- @case (2) {
+ @case (RealDebridStatus.Downloading) {
Downloading
}
- @case (3) {
+ @case (RealDebridStatus.Finished) {
Finished
}
- @case (99) {
+ @case (RealDebridStatus.Uploading) {
+ Uploading
+ }
+ @case (RealDebridStatus.Error) {
Error
}
}
diff --git a/client/src/app/torrent/torrent.component.ts b/client/src/app/torrent/torrent.component.ts
index 561f44b2..d2ede266 100644
--- a/client/src/app/torrent/torrent.component.ts
+++ b/client/src/app/torrent/torrent.component.ts
@@ -2,7 +2,7 @@ import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { saveAs } from 'file-saver-es';
-import { Torrent } from '../models/torrent.model';
+import { RealDebridStatus, Torrent } from '../models/torrent.model';
import { TorrentService } from '../torrent.service';
import { NgClass, DatePipe } from '@angular/common';
import { CdkCopyToClipboard } from '@angular/cdk/clipboard';
@@ -36,6 +36,8 @@ export class TorrentComponent implements OnInit {
private torrentService = inject(TorrentService);
private currentTorrentId: string | null = null;
+ protected readonly RealDebridStatus = RealDebridStatus;
+
public torrent: Torrent;
public activeTab: number = 0;
diff --git a/server/RdtClient.Data/Enums/Provider.cs b/server/RdtClient.Data/Enums/Provider.cs
index fabb4beb..80630f7f 100644
--- a/server/RdtClient.Data/Enums/Provider.cs
+++ b/server/RdtClient.Data/Enums/Provider.cs
@@ -17,5 +17,8 @@ public enum Provider
TorBox,
[Description("DebridLink")]
- DebridLink
+ DebridLink,
+
+ [Description("HIGHWAY")]
+ HIGHWAY
}
diff --git a/server/RdtClient.Data/Models/DebridClient/DebridClientUser.cs b/server/RdtClient.Data/Models/DebridClient/DebridClientUser.cs
index 93d03844..bd8fde2f 100644
--- a/server/RdtClient.Data/Models/DebridClient/DebridClientUser.cs
+++ b/server/RdtClient.Data/Models/DebridClient/DebridClientUser.cs
@@ -4,4 +4,10 @@ public class DebridClientUser
{
public String? Username { get; set; }
public DateTimeOffset? Expiration { get; set; }
+
+ ///
+ /// Explicitly premium even without a dated (e.g. an unlimited/lifetime plan).
+ /// Most providers only ever set , which is premium enough on its own.
+ ///
+ public Boolean Premium { get; set; }
}
diff --git a/server/RdtClient.Data/Models/Internal/DbSettings.cs b/server/RdtClient.Data/Models/Internal/DbSettings.cs
index 585fe3c1..73710526 100644
--- a/server/RdtClient.Data/Models/Internal/DbSettings.cs
+++ b/server/RdtClient.Data/Models/Internal/DbSettings.cs
@@ -195,6 +195,7 @@ public class DbSettingsProvider
https://www.premiumize.me/
https://debrid-link.com/
https://torbox.app/
+https://high-way.me/
At this point only 1 provider can be used at the time.")]
public Provider Provider { get; set; } = Provider.RealDebrid;
@@ -208,7 +209,8 @@ public class DbSettingsProvider
or
https://torbox.app/settings/
or
-https://debrid-link.com/webapp/apikey ")]
+https://debrid-link.com/webapp/apikey
+For HIGHWAY, log in to your HIGHWAY Center and create a token under ""Sitzungen"" → ""API-Token erstellen"".")]
public String ApiKey { get; set; } = "";
///
diff --git a/server/RdtClient.Data/Models/Internal/Profile.cs b/server/RdtClient.Data/Models/Internal/Profile.cs
index 62977259..109827a3 100644
--- a/server/RdtClient.Data/Models/Internal/Profile.cs
+++ b/server/RdtClient.Data/Models/Internal/Profile.cs
@@ -5,6 +5,7 @@ public class Profile
public String? Provider { get; set; }
public String? UserName { get; set; }
public DateTimeOffset? Expiration { get; set; }
+ public Boolean Premium { get; set; }
public String? CurrentVersion { get; set; }
public String? LatestVersion { get; set; }
public Boolean? IsInsecure { get; set; }
diff --git a/server/RdtClient.Service.Test/BackgroundServices/WatchFolderCheckerTests.cs b/server/RdtClient.Service.Test/BackgroundServices/WatchFolderCheckerTests.cs
index 991b041c..e8aaf497 100644
--- a/server/RdtClient.Service.Test/BackgroundServices/WatchFolderCheckerTests.cs
+++ b/server/RdtClient.Service.Test/BackgroundServices/WatchFolderCheckerTests.cs
@@ -35,7 +35,7 @@ public WatchFolderCheckerTests()
DownloadClient = new()
});
- _torrentsServiceMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
+ _torrentsServiceMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
_serviceProviderMock
.Setup(x => x.GetService(typeof(IServiceScopeFactory)))
diff --git a/server/RdtClient.Service.Test/Services/NzbTorrentsTest.cs b/server/RdtClient.Service.Test/Services/NzbTorrentsTest.cs
index 546c1f6d..86869537 100644
--- a/server/RdtClient.Service.Test/Services/NzbTorrentsTest.cs
+++ b/server/RdtClient.Service.Test/Services/NzbTorrentsTest.cs
@@ -30,6 +30,7 @@ public NzbTorrentsTest()
null!,
null!,
null!,
+ null!,
new TestSettings(),
new TorrentRunnerState());
}
diff --git a/server/RdtClient.Service.Test/Services/QBittorrentTest.cs b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs
index 09b0eb01..00ad7dde 100644
--- a/server/RdtClient.Service.Test/Services/QBittorrentTest.cs
+++ b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs
@@ -22,7 +22,7 @@ public QBittorrentTest()
_loggerMock = new();
_settings = new();
_runnerState = new();
- _torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, _runnerState);
+ _torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, _runnerState);
_authenticationMock = new(null!, null!, null!);
_qBittorrent = new(_loggerMock.Object, _settings, _authenticationMock.Object, _torrentsMock.Object, null!, _runnerState);
diff --git a/server/RdtClient.Service.Test/Services/SabnzbdTest.cs b/server/RdtClient.Service.Test/Services/SabnzbdTest.cs
index 8b481ba0..c95d8897 100644
--- a/server/RdtClient.Service.Test/Services/SabnzbdTest.cs
+++ b/server/RdtClient.Service.Test/Services/SabnzbdTest.cs
@@ -21,7 +21,7 @@ public class SabnzbdTest
public SabnzbdTest()
{
_settings = new();
- _torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
+ _torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(new List());
}
diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/HighwayDebridClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/HighwayDebridClientTest.cs
new file mode 100644
index 00000000..eca78bc3
--- /dev/null
+++ b/server/RdtClient.Service.Test/Services/TorrentClients/HighwayDebridClientTest.cs
@@ -0,0 +1,587 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Moq.Protected;
+using RdtClient.Data.Enums;
+using RdtClient.Data.Models.Data;
+using RdtClient.Data.Models.DebridClient;
+using RdtClient.Service.Services;
+using RdtClient.Service.Services.DebridClients;
+using DownloadClient = RdtClient.Data.Enums.DownloadClient;
+using Transfer = RdtClient.Service.Services.DebridClients.HighwayDebridClient.HighwayTransfer;
+using TransferFile = RdtClient.Service.Services.DebridClients.HighwayDebridClient.HighwayFile;
+using Stats = RdtClient.Service.Services.DebridClients.HighwayDebridClient.HighwayStats;
+using CheckResponse = RdtClient.Service.Services.DebridClients.HighwayDebridClient.HighwayCheckResponse;
+using CheckEntry = RdtClient.Service.Services.DebridClients.HighwayDebridClient.HighwayCheckEntry;
+
+namespace RdtClient.Service.Test.Services.TorrentClients;
+
+public class HighwayDebridClientTest
+{
+ private readonly Mock _fileFilterMock = new();
+ private readonly Mock _httpClientFactoryMock = new();
+ private readonly Mock> _loggerMock = new();
+ private readonly TestSettings _settings = new();
+
+ public HighwayDebridClientTest()
+ {
+ _settings.Current.Provider.ApiKey = "3f9a1c2b4d5e6f708192a3b4c5d6e7f8";
+ _settings.Current.Provider.Timeout = 100;
+ }
+
+ private Mock CreateClient()
+ {
+ return new(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _settings);
+ }
+
+ [Fact]
+ public async Task GetDownloads_MapsServiceToDownloadType()
+ {
+ var transfers = new List
+ {
+ new() { Id = "4213", Service = "torrent", Name = "Torrent.Release", State = "downloading", Progress = 62, Size = 100 },
+ new() { Id = "3f9a1c2b4d5e6f708192a3b4c5d6e7f8", Service = "usenet", Name = "Usenet.Release", State = "queued" }
+ };
+
+ var clientMock = CreateClient();
+ clientMock.Protected().Setup>>("ListTransfers").ReturnsAsync(transfers);
+
+ var result = await clientMock.Object.GetDownloads();
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal(DownloadType.Torrent, result.Single(r => r.Id == "4213").Type);
+ Assert.Equal(DownloadType.Nzb, result.Single(r => r.Id == "3f9a1c2b4d5e6f708192a3b4c5d6e7f8").Type);
+ }
+
+ [Theory]
+ [InlineData("4213")]
+ [InlineData("3f9a1c2b4d5e6f708192a3b4c5d6e7f8")]
+ public async Task AddTorrentMagnet_ReturnsTransferId(String id)
+ {
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("PostTransfer", ItExpr.IsAny())
+ .ReturnsAsync(new Transfer { Id = id, Service = "torrent", State = "queued" });
+
+ var result = await clientMock.Object.AddTorrentMagnet("magnet:?xt=urn:btih:0123");
+
+ Assert.Equal(id, result);
+ }
+
+ [Fact]
+ public async Task AddTorrentMagnet_Throws_WhenNoIdReturned()
+ {
+ var clientMock = CreateClient();
+ clientMock.Protected().Setup>("PostTransfer", ItExpr.IsAny()).ReturnsAsync((Transfer?)null);
+
+ await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet("magnet:?xt=urn:btih:0123"));
+ }
+
+ [Theory]
+ [InlineData("queued", TorrentStatus.Processing)]
+ [InlineData("downloading", TorrentStatus.Downloading)]
+ [InlineData("completed", TorrentStatus.Finished)]
+ [InlineData("error", TorrentStatus.Error)]
+ public async Task UpdateData_MapsState(String state, TorrentStatus expected)
+ {
+ var torrent = new Torrent { RdId = "4213", Type = DownloadType.Torrent };
+
+ var cached = new DebridClientTorrent
+ {
+ Id = "4213",
+ Filename = "Some.Release",
+ Status = state,
+ Message = "boom",
+ Bytes = 8123,
+ Progress = 50
+ };
+
+ var result = await CreateClient().Object.UpdateData(torrent, cached);
+
+ Assert.Equal(expected, result.RdStatus);
+
+ if (state == "error")
+ {
+ Assert.Equal("boom", result.Error);
+ }
+ }
+
+ [Fact]
+ public async Task UpdateData_StoresContentPathInRdHost()
+ {
+ var torrent = new Torrent { RdId = "4213", Type = DownloadType.Torrent };
+
+ var cached = new DebridClientTorrent
+ {
+ Id = "4213",
+ Filename = "Some.Release",
+ Status = "downloading",
+ Host = "TORRENT/Some.Release"
+ };
+
+ var result = await CreateClient().Object.UpdateData(torrent, cached);
+
+ Assert.Equal("TORRENT/Some.Release", result.RdHost);
+ Assert.Equal(Provider.HIGHWAY, result.ClientKind);
+ }
+
+ [Fact]
+ public async Task UpdateData_MarksDeleted_WhenTransferNotFound()
+ {
+ var torrent = new Torrent { RdId = "4213", Type = DownloadType.Torrent };
+
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("GetTransfer", ItExpr.IsAny(), ItExpr.IsAny())
+ .ReturnsAsync((Transfer?)null);
+
+ var result = await clientMock.Object.UpdateData(torrent, null);
+
+ Assert.Equal("deleted", result.RdStatusRaw);
+ }
+
+ [Fact]
+ public async Task SelectFiles_RetriesSymlink_WhenFilesNotYetIndexed()
+ {
+ // HIGHWAY can report state:"completed" with files:[] (cloud_files index lag, esp. usenet, spec §8).
+ // Symlink mode needs named files to symlink, so it must keep waiting.
+ var torrent = new Torrent { DownloadClient = DownloadClient.Symlink };
+
+ var result = await CreateClient().Object.SelectFiles(torrent);
+
+ Assert.Null(result);
+ }
+
+ [Theory]
+ [InlineData(DownloadClient.Bezzad)]
+ [InlineData(DownloadClient.Aria2c)]
+ [InlineData(DownloadClient.DownloadStation)]
+ public async Task SelectFiles_DoesNotBlockNonSymlinkClients_WhenFilesNotYetIndexed(DownloadClient downloadClient)
+ {
+ // Non-symlink clients always fetch the whole transfer as one ZIP (GetDownloadInfos), so a missing
+ // files[] index must not stall CreateDownloads for them.
+ var torrent = new Torrent { DownloadClient = downloadClient };
+
+ var result = await CreateClient().Object.SelectFiles(torrent);
+
+ Assert.Equal(1, result);
+ }
+
+ [Fact]
+ public async Task SelectFiles_ReturnsActualFileCount_WhenFilesIndexed()
+ {
+ var torrent = new Torrent
+ {
+ DownloadClient = DownloadClient.Symlink,
+ RdFiles = JsonSerializer.Serialize(new List
+ {
+ new() { Id = 0, Path = "a.mkv", Selected = true },
+ new() { Id = 1, Path = "b.nfo", Selected = true }
+ })
+ };
+
+ var result = await CreateClient().Object.SelectFiles(torrent);
+
+ Assert.Equal(2, result);
+ }
+
+ [Fact]
+ public async Task GetDownloadInfos_Symlink_ReturnsPerFileSentinels_SkippingPartial()
+ {
+ var files = new List
+ {
+ new() { Id = 0, Path = "Some.Release.mkv", Bytes = 8120, Selected = true },
+ new() { Id = 1, Path = "Some.Release.part", Bytes = 10, Selected = false }
+ };
+
+ var torrent = new Torrent
+ {
+ RdId = "4213",
+ Type = DownloadType.Torrent,
+ DownloadClient = DownloadClient.Symlink,
+ RdFiles = JsonSerializer.Serialize(files)
+ };
+
+ _fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny(), It.IsAny())).Returns(true);
+
+ var result = await CreateClient().Object.GetDownloadInfos(torrent);
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.Equal("highway://4213/torrent/0", result[0].RestrictedLink);
+ Assert.Equal("Some.Release.mkv", result[0].FileName);
+ }
+
+ [Fact]
+ public async Task GetDownloadInfos_NonSymlink_ReturnsPerFileDownloadInfos_WhenDownloadUrlsPresent()
+ {
+ var files = new List
+ {
+ new() { Id = 0, Path = "a.mkv", Bytes = 8120, Selected = true, DownloadLink = "https://nl.dwld.link/dav/TORRENT/Some.Release/a.mkv?sig=1" },
+ new() { Id = 1, Path = "b.nfo", Bytes = 42, Selected = true, DownloadLink = "https://nl.dwld.link/dav/TORRENT/Some.Release/b.nfo?sig=2" }
+ };
+
+ var torrent = new Torrent
+ {
+ RdId = "4213",
+ RdName = "Some.Release",
+ Type = DownloadType.Torrent,
+ DownloadClient = DownloadClient.Bezzad,
+ RdFiles = JsonSerializer.Serialize(files)
+ };
+
+ _fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny(), It.IsAny())).Returns(true);
+
+ var result = await CreateClient().Object.GetDownloadInfos(torrent);
+
+ Assert.NotNull(result);
+ Assert.Equal(2, result.Count);
+ Assert.Equal("https://nl.dwld.link/dav/TORRENT/Some.Release/a.mkv?sig=1", result[0].RestrictedLink);
+ Assert.Equal("a.mkv", result[0].FileName);
+ Assert.Equal("https://nl.dwld.link/dav/TORRENT/Some.Release/b.nfo?sig=2", result[1].RestrictedLink);
+ Assert.Equal("b.nfo", result[1].FileName);
+ }
+
+ [Fact]
+ public async Task GetDownloadInfos_NonSymlink_FallsBackToZipSentinel_WhenNoDownloadUrls()
+ {
+ var torrent = new Torrent
+ {
+ RdId = "4213",
+ RdName = "Some.Release",
+ Type = DownloadType.Torrent,
+ DownloadClient = DownloadClient.Bezzad,
+ RdFiles = JsonSerializer.Serialize(new List { new() { Id = 0, Path = "a.mkv", Selected = true } })
+ };
+
+ _fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny(), It.IsAny())).Returns(true);
+
+ var result = await CreateClient().Object.GetDownloadInfos(torrent);
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.Equal("highway://4213/torrent/zip", result[0].RestrictedLink);
+ Assert.Equal("Some.Release.zip", result[0].FileName);
+ }
+
+ [Fact]
+ public async Task GetDownloadInfos_NonSymlink_FallsBackToZipSentinel_WhenOnlySomeFilesHaveDownloadUrls()
+ {
+ var files = new List
+ {
+ new() { Id = 0, Path = "a.mkv", Selected = true, DownloadLink = "https://nl.dwld.link/dav/TORRENT/Some.Release/a.mkv?sig=1" },
+ new() { Id = 1, Path = "b.nfo", Selected = true, DownloadLink = null }
+ };
+
+ var torrent = new Torrent
+ {
+ RdId = "4213",
+ RdName = "Some.Release",
+ Type = DownloadType.Torrent,
+ DownloadClient = DownloadClient.Bezzad,
+ RdFiles = JsonSerializer.Serialize(files)
+ };
+
+ _fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny(), It.IsAny())).Returns(true);
+
+ var result = await CreateClient().Object.GetDownloadInfos(torrent);
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.Equal("highway://4213/torrent/zip", result[0].RestrictedLink);
+ }
+
+ [Fact]
+ public async Task GetDownloadInfos_NonSymlink_ReturnsEmpty_WhenAllFilesFilteredOut()
+ {
+ var torrent = new Torrent
+ {
+ RdId = "4213",
+ RdName = "Some.Release",
+ Type = DownloadType.Torrent,
+ DownloadClient = DownloadClient.Bezzad,
+ RdFiles = JsonSerializer.Serialize(new List { new() { Id = 0, Path = "sample.mkv", Selected = true } })
+ };
+
+ _fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny(), It.IsAny())).Returns(false);
+
+ var result = await CreateClient().Object.GetDownloadInfos(torrent);
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Theory]
+ [InlineData(DownloadClient.Symlink)]
+ [InlineData(DownloadClient.Bezzad)]
+ public async Task GetDownloadInfos_ReturnsNull_WhenFilesNotYetPopulated(DownloadClient downloadClient)
+ {
+ var torrent = new Torrent
+ {
+ RdId = "4213",
+ Type = DownloadType.Torrent,
+ DownloadClient = downloadClient
+ };
+
+ var result = await CreateClient().Object.GetDownloadInfos(torrent);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetDownloadInfos_ReturnsNull_ForSpoolReference()
+ {
+ var torrent = new Torrent
+ {
+ RdId = "3f9a1c2b4d5e6f708192a3b4c5d6e7f8",
+ Type = DownloadType.Torrent,
+ DownloadClient = DownloadClient.Bezzad
+ };
+
+ var result = await CreateClient().Object.GetDownloadInfos(torrent);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task Unrestrict_ReturnsFreshSignedDownloadUrl()
+ {
+ var torrent = new Torrent { RdId = "4213", Type = DownloadType.Torrent };
+
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("GetTransfer", ItExpr.Is(s => s == "4213"), ItExpr.Is(s => s == "torrent"))
+ .ReturnsAsync(new Transfer { Id = "4213", DownloadUrl = "https://nl.dwld.link/dav/TORRENT/Some.Release/?download=zip" });
+
+ var result = await clientMock.Object.Unrestrict(torrent, "highway://4213/torrent/zip");
+
+ Assert.Equal("https://nl.dwld.link/dav/TORRENT/Some.Release/?download=zip", result);
+ }
+
+ [Fact]
+ public async Task Unrestrict_ReturnsSentinelUnchanged_ForPerFileSymlinkRef()
+ {
+ var torrent = new Torrent { RdId = "4213", Type = DownloadType.Torrent };
+
+ var result = await CreateClient().Object.Unrestrict(torrent, "highway://4213/torrent/2");
+
+ Assert.Equal("highway://4213/torrent/2", result);
+ }
+
+ [Fact]
+ public async Task Unrestrict_ReturnsRealUrlUnchanged_ForNonSentinelLink()
+ {
+ // Non-symlink per-file downloads embed the real, already-signed download_url directly
+ // (see GetDownloadInfos) - nothing to resolve here, mirrors DebridLinkClient.
+ var torrent = new Torrent { RdId = "4213", Type = DownloadType.Torrent };
+ const String realUrl = "https://nl.dwld.link/dav/TORRENT/Some.Release/a.mkv?zip_user_id=1&zip_expires=1&zip_signature=abc";
+
+ var result = await CreateClient().Object.Unrestrict(torrent, realUrl);
+
+ Assert.Equal(realUrl, result);
+ }
+
+ [Fact]
+ public async Task Unrestrict_Throws_WhenNoDownloadUrl()
+ {
+ var torrent = new Torrent { RdId = "4213", Type = DownloadType.Torrent };
+
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("GetTransfer", ItExpr.IsAny(), ItExpr.IsAny())
+ .ReturnsAsync(new Transfer { Id = "4213", DownloadUrl = null });
+
+ await Assert.ThrowsAsync(() => clientMock.Object.Unrestrict(torrent, "highway://4213/torrent/zip"));
+ }
+
+ [Fact]
+ public async Task Unrestrict_ThrowsNotFoundMessage_WhenTransferIsGone()
+ {
+ var torrent = new Torrent { RdId = "4213", Type = DownloadType.Torrent };
+
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("GetTransfer", ItExpr.IsAny(), ItExpr.IsAny())
+ .ReturnsAsync((Transfer?)null);
+
+ var ex = await Assert.ThrowsAsync(() => clientMock.Object.Unrestrict(torrent, "highway://4213/torrent/zip"));
+ Assert.Contains("was not found", ex.Message);
+ }
+
+ [Fact]
+ public async Task Delete_OmitsService_ForSpoolId()
+ {
+ var torrent = new Torrent { RdId = "3f9a1c2b4d5e6f708192a3b4c5d6e7f8", Type = DownloadType.Torrent };
+
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("DeleteTransfer", ItExpr.IsAny(), ItExpr.IsAny())
+ .ReturnsAsync(true);
+
+ await clientMock.Object.Delete(torrent);
+
+ clientMock.Protected()
+ .Verify>("DeleteTransfer", Times.Once(), ItExpr.Is(s => s == torrent.RdId), ItExpr.IsNull());
+ }
+
+ [Fact]
+ public async Task Delete_PassesService_ForNumericId()
+ {
+ var torrent = new Torrent { RdId = "4213", Type = DownloadType.Nzb };
+
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("DeleteTransfer", ItExpr.IsAny(), ItExpr.IsAny())
+ .ReturnsAsync(true);
+
+ await clientMock.Object.Delete(torrent);
+
+ clientMock.Protected()
+ .Verify>("DeleteTransfer", Times.Once(), ItExpr.Is(s => s == "4213"), ItExpr.Is(s => s == "usenet"));
+ }
+
+ [Fact]
+ public void GetSymlinkPath_PrependsServiceDirToApiFilePath()
+ {
+ // The real API returns files[].path already prefixed with the top-level folder name,
+ // and content_path is "TORRENT/" - the mount-relative path is "TORRENT//".
+ var torrent = new Torrent
+ {
+ Type = DownloadType.Torrent,
+ RdName = "Some.Release",
+ RdHost = "TORRENT/Some.Release",
+ RdFiles = JsonSerializer.Serialize(new List
+ {
+ new() { Id = 0, Path = "Some.Release/Some.Release.mkv", Selected = true }
+ })
+ };
+
+ var download = new Download { Link = "highway://4213/torrent/0", FileName = "Some.Release.mkv" };
+
+ var path = HighwayDebridClient.GetSymlinkPath(torrent, download);
+
+ Assert.Equal("TORRENT/Some.Release/Some.Release.mkv", path!.Replace('\\', '/'));
+ }
+
+ [Fact]
+ public void GetSymlinkPath_LeavesAlreadyMountRelativePathUntouched()
+ {
+ var torrent = new Torrent
+ {
+ Type = DownloadType.Torrent,
+ RdHost = "TORRENT/Some.Release",
+ RdFiles = JsonSerializer.Serialize(new List
+ {
+ new() { Id = 0, Path = "TORRENT/Some.Release/Some.Release.mkv", Selected = true }
+ })
+ };
+
+ var download = new Download { Link = "highway://4213/torrent/0", FileName = "Some.Release.mkv" };
+
+ var path = HighwayDebridClient.GetSymlinkPath(torrent, download);
+
+ Assert.Equal("TORRENT/Some.Release/Some.Release.mkv", path!.Replace('\\', '/'));
+ }
+
+ [Fact]
+ public async Task GetUser_UsesUsernameFromStats()
+ {
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("GetStats")
+ .ReturnsAsync(new Stats { Username = "maxmuster", Plan = "Pro", Premium = true, PlanExpiresAt = 1761955200 });
+
+ var user = await clientMock.Object.GetUser();
+
+ Assert.Equal("maxmuster", user.Username);
+ Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1761955200), user.Expiration);
+ }
+
+ [Fact]
+ public async Task GetUser_LeavesExpirationNull_WhenPlanHasNoExpiry()
+ {
+ var clientMock = CreateClient();
+ clientMock.Protected().Setup>("GetStats").ReturnsAsync(new Stats { Username = "maxmuster", PlanExpiresAt = null });
+
+ var user = await clientMock.Object.GetUser();
+
+ Assert.Null(user.Expiration);
+ }
+
+ [Fact]
+ public async Task GetUser_FallsBackToPlan_WhenNoUsername()
+ {
+ var clientMock = CreateClient();
+ clientMock.Protected().Setup>("GetStats").ReturnsAsync(new Stats { Plan = "Pro" });
+
+ var user = await clientMock.Object.GetUser();
+
+ Assert.Equal("Pro", user.Username);
+ }
+
+ [Fact]
+ public async Task GetUser_FallsBackToStub_WhenStatsUnavailable()
+ {
+ var clientMock = CreateClient();
+ clientMock.Protected().Setup>("GetStats").ReturnsAsync((Stats?)null);
+
+ var user = await clientMock.Object.GetUser();
+
+ Assert.Equal("HIGHWAY", user.Username);
+ }
+
+ [Fact]
+ public async Task GetAvailableFiles_ReturnsFiles_WhenCachedAndLinked()
+ {
+ const String hash = "3641ad15ad451518f558d5ce5b76be4a618911c1";
+
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("CheckCache", ItExpr.Is(h => h == hash), ItExpr.Is(s => s == "torrent"))
+ .ReturnsAsync(new CheckResponse
+ {
+ Service = "torrent",
+ Results = new() { [hash] = new CheckEntry { Known = true, Ready = true, Id = "4213" } }
+ });
+ clientMock.Protected()
+ .Setup>("GetTransfer", ItExpr.Is(i => i == "4213"), ItExpr.IsAny())
+ .ReturnsAsync(new Transfer
+ {
+ Id = "4213",
+ Files =
+ [
+ new TransferFile { Path = "Some.Release/Some.Release.mkv", Size = 8120 },
+ new TransferFile { Path = "Some.Release/info.nfo", Size = 42 }
+ ]
+ });
+
+ var result = await clientMock.Object.GetAvailableFiles(hash);
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal("Some.Release/Some.Release.mkv", result[0].Filename);
+ Assert.Equal(8120, result[0].Filesize);
+ }
+
+ [Fact]
+ public async Task GetAvailableFiles_ReturnsEmpty_WhenNotReady()
+ {
+ const String hash = "3641ad15ad451518f558d5ce5b76be4a618911c1";
+
+ var clientMock = CreateClient();
+ clientMock.Protected()
+ .Setup>("CheckCache", ItExpr.IsAny(), ItExpr.IsAny())
+ .ReturnsAsync(new CheckResponse
+ {
+ Service = "torrent",
+ Results = new() { [hash] = new CheckEntry { Known = true, Ready = false } }
+ });
+
+ Assert.Empty(await clientMock.Object.GetAvailableFiles(hash));
+ }
+
+ [Fact]
+ public async Task GetAvailableFiles_ReturnsEmpty_ForNonHexHash()
+ {
+ Assert.Empty(await CreateClient().Object.GetAvailableFiles("not-a-hash"));
+ }
+}
diff --git a/server/RdtClient.Service.Test/Services/TorrentRunnerTest.cs b/server/RdtClient.Service.Test/Services/TorrentRunnerTest.cs
index 4422edde..ef56ff70 100644
--- a/server/RdtClient.Service.Test/Services/TorrentRunnerTest.cs
+++ b/server/RdtClient.Service.Test/Services/TorrentRunnerTest.cs
@@ -57,6 +57,7 @@ public async Task Tick_ShouldNotRequeueCompletedErrorTorrent()
null!,
null!,
null!,
+ null!,
testSettings,
runnerState);
diff --git a/server/RdtClient.Service.Test/Services/TorrentsTest.cs b/server/RdtClient.Service.Test/Services/TorrentsTest.cs
index a854c5a6..60a8dbbd 100644
--- a/server/RdtClient.Service.Test/Services/TorrentsTest.cs
+++ b/server/RdtClient.Service.Test/Services/TorrentsTest.cs
@@ -121,6 +121,7 @@ public async Task RunTorrentComplete_WhenCommandSet_ShouldRunCommand(Torrent tor
null!,
null!,
null!,
+ null!,
new TestSettings(),
new TorrentRunnerState());
@@ -189,6 +190,7 @@ public async Task RunTorrentComplete_WhenCommandNotSet_ShouldNotRunCommand(Torre
null!,
null!,
null!,
+ null!,
new TestSettings(),
new TorrentRunnerState());
@@ -239,6 +241,7 @@ public async Task RunTorrentComplete_WhenStdOut_Logs(Torrent torrent, List();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddSingleton();
services.AddSingleton();
diff --git a/server/RdtClient.Service/Services/DebridClients/HighwayDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/HighwayDebridClient.cs
new file mode 100644
index 00000000..d23ed1bd
--- /dev/null
+++ b/server/RdtClient.Service/Services/DebridClients/HighwayDebridClient.cs
@@ -0,0 +1,788 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using RdtClient.Data.Enums;
+using RdtClient.Data.Models.Data;
+using RdtClient.Data.Models.DebridClient;
+using RdtClient.Data.Models.Internal;
+using RdtClient.Service.Helpers;
+using Download = RdtClient.Data.Models.Data.Download;
+using Torrent = RdtClient.Data.Models.Data.Torrent;
+
+namespace RdtClient.Service.Services.DebridClients;
+
+///
+/// Client for HIGHWAY (high-way.me ), a self-hosted "cloud download center" that downloads
+/// torrents and usenet server-side. See HIGHWAY_PROVIDER_SPEC.md in the repo root.
+///
+public class HighwayDebridClient(
+ ILogger logger,
+ IHttpClientFactory httpClientFactory,
+ IDownloadableFileFilter fileFilter,
+ ISettings settings)
+ : IDebridClient
+{
+ /// Sentinel link scheme used between and .
+ private const String LinkScheme = "highway://";
+
+ /// HIGHWAY has a single public API host; not user-configurable to keep the settings page small.
+ private const String ApiBaseUrl = "https://cloud.high-way.me";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ public async Task> GetDownloads()
+ {
+ var transfers = await ListTransfers();
+
+ return transfers.Select(Map).ToList();
+ }
+
+ public async Task GetUser()
+ {
+ HighwayStats? stats = null;
+
+ try
+ {
+ stats = await GetStats();
+ }
+ catch (JsonException)
+ {
+ // older server builds ignore ?stats=1 and return the transfer list instead
+ }
+
+ var username = stats?.Username;
+
+ if (String.IsNullOrWhiteSpace(username))
+ {
+ username = stats?.Plan;
+ }
+
+ return new()
+ {
+ Username = String.IsNullOrWhiteSpace(username) ? "HIGHWAY" : username,
+ // Dated plans (subscription/legacy) get an expiry; unlimited/no-expiry plans don't, but are
+ // still premium - Premium below is what tells the UI that (see DebridClientUser.Premium).
+ Expiration = stats?.PlanExpiresAt is { } expiresAt ? DateTimeOffset.FromUnixTimeSeconds(expiresAt) : null,
+ Premium = stats?.Premium ?? false
+ };
+ }
+
+ public async Task AddTorrentMagnet(String magnetLink)
+ {
+ using var content = new MultipartFormDataContent
+ {
+ { new StringContent(magnetLink), "magnet" }
+ };
+
+ var result = await PostTransfer(content);
+
+ return result?.Id ?? throw new InvalidOperationException("HIGHWAY API did not return a transfer id.");
+ }
+
+ public async Task AddTorrentFile(Byte[] bytes)
+ {
+ using var content = BuildFileContent(bytes, "file.torrent");
+
+ var result = await PostTransfer(content);
+
+ return result?.Id ?? throw new InvalidOperationException("HIGHWAY API did not return a transfer id.");
+ }
+
+ public async Task AddNzbFile(Byte[] bytes, String? name)
+ {
+ var fileName = String.IsNullOrWhiteSpace(name) ? "file.nzb" : name;
+
+ if (!fileName.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase))
+ {
+ fileName += ".nzb";
+ }
+
+ using var content = BuildFileContent(bytes, fileName);
+
+ var result = await PostTransfer(content);
+
+ return result?.Id ?? throw new InvalidOperationException("HIGHWAY API did not return a transfer id.");
+ }
+
+ public async Task AddNzbLink(String nzbLink)
+ {
+ // transfers.php only accepts an uploaded .nzb, so fetch it ourselves first.
+ var bytes = await DownloadNzb(nzbLink);
+
+ String? name = null;
+
+ try
+ {
+ name = Path.GetFileName(new Uri(nzbLink).AbsolutePath);
+ }
+ catch (UriFormatException)
+ {
+ // fall back to the default name
+ }
+
+ return await AddNzbFile(bytes, name);
+ }
+
+ public async Task> GetAvailableFiles(String hash)
+ {
+ // rdt-client only calls this with a torrent infohash (40- or 64-hex).
+ if (String.IsNullOrWhiteSpace(hash) || !hash.All(Uri.IsHexDigit) || hash.Length is not (40 or 64))
+ {
+ return [];
+ }
+
+ HighwayCheckResponse? check = null;
+
+ try
+ {
+ check = await CheckCache(hash, "torrent");
+ }
+ catch (JsonException)
+ {
+ // older server builds ignore ?check= and return the transfer list instead
+ }
+
+ if (check?.Results == null || !check.Results.TryGetValue(hash, out var entry) || entry == null || !entry.Ready)
+ {
+ return [];
+ }
+
+ // The cache lookup itself has no file breakdown; if this user already linked the transfer, read it.
+ if (String.IsNullOrWhiteSpace(entry.Id))
+ {
+ return [];
+ }
+
+ var transfer = await GetTransfer(entry.Id, "torrent");
+
+ return (transfer?.Files ?? [])
+ .Select(file => new DebridClientAvailableFile
+ {
+ Filename = file.Path ?? "",
+ Filesize = file.Size
+ })
+ .Where(file => !String.IsNullOrWhiteSpace(file.Filename))
+ .ToList();
+ }
+
+ public Task SelectFiles(Torrent torrent)
+ {
+ // HIGHWAY always downloads the whole transfer; nothing to actually "select". The `files[]` list
+ // (spec §5) can lag behind state:"completed" - it's populated from HIGHWAY's cloud_files index,
+ // which may not have caught up yet, especially for usenet (spec §8).
+ //
+ // Symlink mode needs named files[] entries to symlink out of the rclone mount, so wait/retry while
+ // it's empty. Every other download client always fetches the whole transfer as one ZIP
+ // (GetDownloadInfos below) regardless of files[], so don't block those on it.
+ if (torrent.Files.Count == 0 && torrent.DownloadClient == Data.Enums.DownloadClient.Symlink)
+ {
+ return Task.FromResult(null);
+ }
+
+ return Task.FromResult(Math.Max(torrent.Files.Count, 1));
+ }
+
+ public async Task Delete(Torrent torrent)
+ {
+ if (String.IsNullOrWhiteSpace(torrent.RdId))
+ {
+ return;
+ }
+
+ var service = IsSpoolId(torrent.RdId) ? null : Service(torrent.Type);
+
+ await DeleteTransfer(torrent.RdId, service);
+ }
+
+ public async Task Unrestrict(Torrent torrent, String link)
+ {
+ if (String.IsNullOrWhiteSpace(link))
+ {
+ throw new ArgumentException("Link cannot be null or empty", nameof(link));
+ }
+
+ // Per-file downloads (non-symlink mode) already embed the real, signed download_url straight from
+ // GetDownloadInfos - nothing to resolve (mirrors DebridLinkClient).
+ if (!link.StartsWith(LinkScheme, StringComparison.Ordinal))
+ {
+ return link;
+ }
+
+ var (id, service, fileRef) = ParseLink(link);
+
+ // Symlink mode resolves files straight from the rclone mount and never needs a real URL,
+ // so leave the per-file sentinel untouched. Only the ZIP sentinel needs a signed URL.
+ if (fileRef != "zip")
+ {
+ return link;
+ }
+
+ var transfer = await GetTransfer(id, service);
+
+ if (transfer == null)
+ {
+ throw new($"HIGHWAY transfer {id} was not found. It was likely deleted or unlinked on HIGHWAY " +
+ "since this torrent was added here - remove it in rdt-client and re-add it.");
+ }
+
+ if (String.IsNullOrWhiteSpace(transfer.DownloadUrl))
+ {
+ throw new($"HIGHWAY did not return a download URL for transfer {id}. Signed URLs may be disabled on the server; " +
+ "use the Symlink download client with an rclone WebDAV mount instead.");
+ }
+
+ return transfer.DownloadUrl;
+ }
+
+ public async Task UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent)
+ {
+ try
+ {
+ if (String.IsNullOrWhiteSpace(torrent.RdId))
+ {
+ return torrent;
+ }
+
+ var rdTorrent = torrentClientTorrent;
+
+ if (rdTorrent == null)
+ {
+ var service = IsSpoolId(torrent.RdId) ? null : Service(torrent.Type);
+ var transfer = await GetTransfer(torrent.RdId, service);
+
+ if (transfer == null)
+ {
+ torrent.RdStatusRaw = "deleted";
+
+ return torrent;
+ }
+
+ rdTorrent = Map(transfer);
+ }
+
+ if (!String.IsNullOrWhiteSpace(rdTorrent.Filename))
+ {
+ torrent.RdName = rdTorrent.Filename;
+ }
+
+ if (rdTorrent.Bytes > 0)
+ {
+ torrent.RdSize = rdTorrent.Bytes;
+ }
+
+ if (rdTorrent.Files != null)
+ {
+ torrent.RdFiles = JsonSerializer.Serialize(rdTorrent.Files);
+ }
+
+ torrent.ClientKind = Provider.HIGHWAY;
+ torrent.RdHost = rdTorrent.Host; // content_path, needed by the Symlink downloader
+ torrent.RdProgress = rdTorrent.Progress;
+ torrent.RdAdded ??= DateTimeOffset.UtcNow;
+ torrent.RdSpeed = rdTorrent.Speed;
+ torrent.RdSeeders = rdTorrent.Seeders;
+ torrent.RdStatusRaw = rdTorrent.Status;
+
+ if (rdTorrent.Ended != null && torrent.RdEnded == null)
+ {
+ torrent.RdEnded = rdTorrent.Ended;
+ }
+
+ logger.LogTrace("Updating status for {TorrentName} from {OldStatus} to {NewStatus}", torrent.RdName, torrent.RdStatus, rdTorrent.Status);
+
+ torrent.RdStatus = rdTorrent.Status switch
+ {
+ "queued" => TorrentStatus.Processing,
+ "downloading" => TorrentStatus.Downloading,
+ "completed" => TorrentStatus.Finished,
+ "error" => TorrentStatus.Error,
+ _ => torrent.RdStatus is null or TorrentStatus.Queued ? TorrentStatus.Processing : torrent.RdStatus.Value
+ };
+
+ if (rdTorrent.Status == "error" && !String.IsNullOrWhiteSpace(rdTorrent.Message))
+ {
+ torrent.Error = rdTorrent.Message;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to update HIGHWAY transfer {RdId}: {Message}", torrent.RdId, ex.Message);
+
+ throw;
+ }
+
+ return torrent;
+ }
+
+ public async Task?> GetDownloadInfos(Torrent torrent)
+ {
+ if (String.IsNullOrWhiteSpace(torrent.RdId) || IsSpoolId(torrent.RdId))
+ {
+ return null;
+ }
+
+ if (torrent.Files.Count == 0)
+ {
+ // Not indexed/visible on HIGHWAY yet - null (not []) so rdt-client retries next cycle
+ // instead of marking the torrent as "all files excluded".
+ return null;
+ }
+
+ var service = Service(torrent.Type);
+
+ if (torrent.DownloadClient == Data.Enums.DownloadClient.Symlink)
+ {
+ logger.LogDebug("Resolving HIGHWAY transfer {RdName} via the rclone mount (symlink).", torrent.RdName);
+
+ return torrent.Files
+ .Where(file => file.Selected && fileFilter.IsDownloadable(torrent, file.Path, file.Bytes))
+ .Select(file => new DownloadInfo
+ {
+ RestrictedLink = $"{LinkScheme}{torrent.RdId}/{service}/{file.Id}",
+ FileName = Path.GetFileName(file.Path)
+ })
+ .ToList();
+ }
+
+ var downloadableFiles = torrent.Files
+ .Where(file => file.Selected && fileFilter.IsDownloadable(torrent, file.Path, file.Bytes))
+ .ToList();
+
+ if (downloadableFiles.Count == 0)
+ {
+ return [];
+ }
+
+ // Each file's own signed download_url (spec §4.3/§5) lets us fetch it directly, respecting the usual
+ // filters, with no ZIP/unzip step. Only fall back to the whole-folder ZIP when signed per-file URLs
+ // aren't available server-side (same condition that leaves the top-level download_url null too).
+ if (downloadableFiles.All(file => !String.IsNullOrWhiteSpace(file.DownloadLink)))
+ {
+ logger.LogDebug("Downloading HIGHWAY transfer {RdName} as individual files.", torrent.RdName);
+
+ return downloadableFiles.Select(file => new DownloadInfo
+ {
+ RestrictedLink = file.DownloadLink!,
+ FileName = Path.GetFileName(file.Path)
+ })
+ .ToList();
+ }
+
+ logger.LogDebug("Downloading HIGHWAY transfer {RdName} as a folder ZIP (no per-file signed URLs).", torrent.RdName);
+
+ return
+ [
+ new()
+ {
+ RestrictedLink = $"{LinkScheme}{torrent.RdId}/{service}/zip",
+ FileName = $"{torrent.RdName}.zip"
+ }
+ ];
+ }
+
+ public Task GetFileName(Download download)
+ {
+ // FileName is always set in GetDownloadInfos.
+ Debug.Assert(download.FileName != null);
+
+ return Task.FromResult(download.FileName);
+ }
+
+ ///
+ /// Resolves the path of a file inside the rclone WebDAV mount. The mount root maps to HIGHWAY's DAV root,
+ /// content_path is TORRENT/<name> or USENET/<name> and each
+ /// files[].path already starts with <name>/ , so the mount-relative path is
+ /// <service dir>/<files[].path> .
+ ///
+ public static String? GetSymlinkPath(Torrent torrent, Download download)
+ {
+ var fileName = DownloadHelper.GetFileName(download);
+
+ if (String.IsNullOrWhiteSpace(fileName))
+ {
+ return null;
+ }
+
+ var match = torrent.Files.FirstOrDefault(f => !String.IsNullOrWhiteSpace(f.Path) && f.Path.EndsWith(fileName, StringComparison.Ordinal));
+ var relativePath = match?.Path ?? fileName;
+
+ var serviceDir = torrent.RdHost?.Split('/', '\\').FirstOrDefault(segment => !String.IsNullOrWhiteSpace(segment))
+ ?? (torrent.Type == DownloadType.Nzb ? "USENET" : "TORRENT");
+
+ // If the stored file path already starts with the service dir, it is already mount-relative.
+ if (relativePath.StartsWith(serviceDir + "/", StringComparison.OrdinalIgnoreCase) ||
+ relativePath.StartsWith(serviceDir + "\\", StringComparison.OrdinalIgnoreCase))
+ {
+ return relativePath;
+ }
+
+ return Path.Combine(serviceDir, relativePath);
+ }
+
+ // ---- HTTP seams (overridable for tests) -------------------------------------------------------
+
+ protected virtual Task PostTransfer(HttpContent content)
+ {
+ return SendAsync(HttpMethod.Post, null, content);
+ }
+
+ protected virtual async Task> ListTransfers()
+ {
+ var result = await SendAsync(HttpMethod.Get, null);
+
+ return result?.Transfers ?? [];
+ }
+
+ protected virtual Task GetTransfer(String id, String? service)
+ {
+ return SendAsync(HttpMethod.Get, BuildIdQuery(id, service));
+ }
+
+ protected virtual async Task DeleteTransfer(String id, String? service)
+ {
+ var result = await SendAsync(HttpMethod.Delete, BuildIdQuery(id, service));
+
+ return result?.Removed ?? false;
+ }
+
+ protected virtual Task GetStats()
+ {
+ return SendAsync(HttpMethod.Get, "stats=1");
+ }
+
+ protected virtual Task CheckCache(String hashes, String service)
+ {
+ return SendAsync(HttpMethod.Get, $"check={Uri.EscapeDataString(hashes)}&service={service}");
+ }
+
+ protected virtual async Task DownloadNzb(String url)
+ {
+ var httpClient = httpClientFactory.CreateClient();
+ httpClient.Timeout = TimeSpan.FromSeconds(settings.Current.Provider.Timeout);
+
+ return await httpClient.GetByteArrayAsync(url);
+ }
+
+ // ---- internals ------------------------------------------------------------------------------
+
+ private async Task SendAsync(HttpMethod method, String? query, HttpContent? content = null)
+ {
+ var token = settings.Current.Provider.ApiKey;
+
+ if (String.IsNullOrWhiteSpace(token))
+ {
+ throw new("HIGHWAY API token is not set (provider API Key).");
+ }
+
+ var url = $"{ApiBaseUrl}/center-api/transfers.php";
+
+ if (!String.IsNullOrWhiteSpace(query))
+ {
+ url += "?" + query;
+ }
+
+ using var request = new HttpRequestMessage(method, url);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ request.Content = content;
+
+ var httpClient = httpClientFactory.CreateClient();
+ httpClient.Timeout = TimeSpan.FromSeconds(settings.Current.Provider.Timeout);
+
+ HttpResponseMessage response;
+
+ try
+ {
+ response = await httpClient.SendAsync(request);
+ }
+ catch (TaskCanceledException ex)
+ {
+ logger.LogError(ex, "The connection to HIGHWAY timed out: {Message}", ex.Message);
+
+ throw;
+ }
+
+ var body = await response.Content.ReadAsStringAsync();
+
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ return default;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var (error, message) = ParseError(body);
+
+ if (response.StatusCode == HttpStatusCode.TooManyRequests)
+ {
+ throw new RateLimitException(message ?? "HIGHWAY rate limit reached", TimeSpan.FromMinutes(2));
+ }
+
+ if (error is "invalid_token" or "authentication_required")
+ {
+ throw new($"HIGHWAY authentication failed ({error}). Check the API token in the provider settings. {message}");
+ }
+
+ if (error == "system_feature_unavailable")
+ {
+ throw new($"The HIGHWAY token does not belong to a high-way.me account ({error}). {message}");
+ }
+
+ throw new($"HIGHWAY request failed ({(Int32)response.StatusCode} {error}): {message ?? body}");
+ }
+
+ if (String.IsNullOrWhiteSpace(body))
+ {
+ return default;
+ }
+
+ return JsonSerializer.Deserialize(body, JsonOptions);
+ }
+
+ private static (String? Error, String? Message) ParseError(String body)
+ {
+ try
+ {
+ var element = JsonSerializer.Deserialize(body);
+ var error = element.TryGetProperty("error", out var e) ? e.GetString() : null;
+ var message = element.TryGetProperty("message", out var m) ? m.GetString() : null;
+
+ return (error, message);
+ }
+ catch (JsonException)
+ {
+ return (null, null);
+ }
+ }
+
+ private static String BuildIdQuery(String id, String? service)
+ {
+ var query = $"id={Uri.EscapeDataString(id)}";
+
+ if (!String.IsNullOrWhiteSpace(service))
+ {
+ query += $"&service={service}";
+ }
+
+ return query;
+ }
+
+ private static MultipartFormDataContent BuildFileContent(Byte[] bytes, String fileName)
+ {
+ var fileContent = new ByteArrayContent(bytes);
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+
+ return new()
+ {
+ { fileContent, "file", fileName }
+ };
+ }
+
+ private static (String Id, String? Service, String FileRef) ParseLink(String link)
+ {
+ var rest = link.StartsWith(LinkScheme, StringComparison.Ordinal) ? link[LinkScheme.Length..] : link;
+ var parts = rest.Split('/');
+
+ if (parts.Length != 3)
+ {
+ throw new ArgumentException($"Invalid HIGHWAY link format: {link}", nameof(link));
+ }
+
+ return (parts[0], String.IsNullOrWhiteSpace(parts[1]) ? null : parts[1], parts[2]);
+ }
+
+ private static String Service(DownloadType type)
+ {
+ return type == DownloadType.Nzb ? "usenet" : "torrent";
+ }
+
+ private static Boolean IsSpoolId(String id)
+ {
+ return id.Length == 32 && id.All(Uri.IsHexDigit);
+ }
+
+ private static DebridClientTorrent Map(HighwayTransfer transfer)
+ {
+ var type = transfer.Service == "usenet" ? DownloadType.Nzb : DownloadType.Torrent;
+
+ Int64? speed = null;
+
+ if (transfer.Eta is > 0 && transfer.Size > transfer.Downloaded && transfer.Downloaded >= 0)
+ {
+ speed = (transfer.Size - transfer.Downloaded) / transfer.Eta.Value;
+ }
+
+ return new()
+ {
+ Id = transfer.Id ?? "",
+ Filename = transfer.Name ?? "",
+ OriginalFilename = transfer.Name,
+ Hash = transfer.Id ?? "",
+ Bytes = transfer.Size,
+ OriginalBytes = transfer.Size,
+ Host = transfer.ContentPath,
+ Split = 0,
+ Progress = transfer.Progress,
+ Status = transfer.State,
+ Message = transfer.StateDetail,
+ Type = type,
+ Added = DateTimeOffset.UtcNow,
+ Files = (transfer.Files ?? [])
+ .Select((file, index) => new DebridClientFile
+ {
+ Id = index,
+ Path = file.Path ?? "",
+ Bytes = file.Size,
+ Selected = !file.Partial,
+ DownloadLink = file.DownloadUrl
+ })
+ .ToList(),
+ Links = [],
+ Ended = transfer.State == "completed" ? DateTimeOffset.UtcNow : null,
+ Speed = speed,
+ Seeders = transfer.Seeder
+ };
+ }
+
+ // ---- API DTOs (public so tests can stub the seams) -----------------------------------------
+
+ public sealed class HighwayTransfer
+ {
+ [JsonPropertyName("id")]
+ public String? Id { get; set; }
+
+ [JsonPropertyName("service")]
+ public String? Service { get; set; }
+
+ [JsonPropertyName("name")]
+ public String? Name { get; set; }
+
+ [JsonPropertyName("state")]
+ public String? State { get; set; }
+
+ [JsonPropertyName("state_detail")]
+ public String? StateDetail { get; set; }
+
+ [JsonPropertyName("progress")]
+ public Int64 Progress { get; set; }
+
+ [JsonPropertyName("size")]
+ public Int64 Size { get; set; }
+
+ [JsonPropertyName("downloaded")]
+ public Int64 Downloaded { get; set; }
+
+ [JsonPropertyName("eta")]
+ public Int64? Eta { get; set; }
+
+ [JsonPropertyName("seeder")]
+ public Int64 Seeder { get; set; }
+
+ [JsonPropertyName("content_path")]
+ public String? ContentPath { get; set; }
+
+ [JsonPropertyName("download_url")]
+ public String? DownloadUrl { get; set; }
+
+ [JsonPropertyName("files")]
+ public List? Files { get; set; }
+ }
+
+ public sealed class HighwayFile
+ {
+ [JsonPropertyName("path")]
+ public String? Path { get; set; }
+
+ [JsonPropertyName("size")]
+ public Int64 Size { get; set; }
+
+ [JsonPropertyName("partial")]
+ public Boolean Partial { get; set; }
+
+ /// Signed, credential-free direct link to this one file. Null if signed DAV URLs aren't configured server-side.
+ [JsonPropertyName("download_url")]
+ public String? DownloadUrl { get; set; }
+ }
+
+ public sealed class HighwayTransferList
+ {
+ [JsonPropertyName("transfers")]
+ public List? Transfers { get; set; }
+ }
+
+ public sealed class HighwayDeleteResult
+ {
+ [JsonPropertyName("removed")]
+ public Boolean Removed { get; set; }
+ }
+
+ /// Response of ?stats=1 (§4.5). Only the bits rdt-client surfaces are modelled.
+ public sealed class HighwayStats
+ {
+ [JsonPropertyName("username")]
+ public String? Username { get; set; }
+
+ [JsonPropertyName("plan")]
+ public String? Plan { get; set; }
+
+ [JsonPropertyName("plan_expires_at")]
+ public Int64? PlanExpiresAt { get; set; }
+
+ [JsonPropertyName("premium")]
+ public Boolean Premium { get; set; }
+
+ [JsonPropertyName("fair_use")]
+ public Boolean FairUse { get; set; }
+
+ [JsonPropertyName("storage")]
+ public HighwayStorage? Storage { get; set; }
+ }
+
+ public sealed class HighwayStorage
+ {
+ [JsonPropertyName("limit")]
+ public Int64 Limit { get; set; }
+
+ [JsonPropertyName("used")]
+ public Int64 Used { get; set; }
+
+ [JsonPropertyName("available")]
+ public Int64 Available { get; set; }
+ }
+
+ /// Response of ?check=<hash> (§4.6).
+ public sealed class HighwayCheckResponse
+ {
+ [JsonPropertyName("service")]
+ public String? Service { get; set; }
+
+ [JsonPropertyName("results")]
+ public Dictionary? Results { get; set; }
+ }
+
+ public sealed class HighwayCheckEntry
+ {
+ [JsonPropertyName("known")]
+ public Boolean Known { get; set; }
+
+ [JsonPropertyName("ready")]
+ public Boolean Ready { get; set; }
+
+ [JsonPropertyName("id")]
+ public String? Id { get; set; }
+
+ [JsonPropertyName("name")]
+ public String? Name { get; set; }
+
+ [JsonPropertyName("size")]
+ public Int64 Size { get; set; }
+
+ [JsonPropertyName("progress")]
+ public Int64 Progress { get; set; }
+ }
+}
diff --git a/server/RdtClient.Service/Services/DownloadClient.cs b/server/RdtClient.Service/Services/DownloadClient.cs
index fd17b30b..24a6f2a6 100644
--- a/server/RdtClient.Service/Services/DownloadClient.cs
+++ b/server/RdtClient.Service/Services/DownloadClient.cs
@@ -53,6 +53,11 @@ public async Task Start()
downloadPath = DebridLinkClient.GetSymlinkPath(torrent, download);
}
+ if (torrent.ClientKind == Provider.HIGHWAY && Type == Data.Enums.DownloadClient.Symlink)
+ {
+ downloadPath = HighwayDebridClient.GetSymlinkPath(torrent, download);
+ }
+
if (filePath == null || downloadPath == null)
{
throw new("Invalid download path");
@@ -76,6 +81,12 @@ public async Task Start()
? DownloadHelper.GetExpectedFileSize(torrent, download)
: null;
+ // Some providers hand back a dynamically generated, streamed download (e.g. HIGHWAY's on-the-fly
+ // folder ZIP) without a Content-Length header, so the downloader can never report a real total and
+ // progress gets stuck at 0%. When this is the torrent's only download it represents the whole
+ // transfer, so the provider's own reported total size is a good stand-in.
+ var fallbackBytesTotal = torrent.Downloads.Count == 1 ? torrent.RdSize ?? 0 : 0;
+
Downloader.DownloadComplete += (_, args) =>
{
Finished = true;
@@ -91,7 +102,7 @@ public async Task Start()
{
Speed = args.Speed;
BytesDone = args.BytesDone;
- BytesTotal = args.BytesTotal;
+ BytesTotal = args.BytesTotal > 0 ? args.BytesTotal : fallbackBytesTotal;
var bytesAdded = BytesDone - LastBytesDone;
diff --git a/server/RdtClient.Service/Services/Torrents.cs b/server/RdtClient.Service/Services/Torrents.cs
index a94748e6..01bbb394 100644
--- a/server/RdtClient.Service/Services/Torrents.cs
+++ b/server/RdtClient.Service/Services/Torrents.cs
@@ -33,6 +33,7 @@ public class Torrents(
RealDebridDebridClient realDebridDebridClient,
DebridLinkClient debridLinkClient,
TorBoxDebridClient torBoxDebridClient,
+ HighwayDebridClient highwayDebridClient,
ISettings settings,
ITorrentRunnerState runnerState)
{
@@ -56,6 +57,7 @@ private IDebridClient DebridClient
Provider.AllDebrid => allDebridDebridClient,
Provider.DebridLink => debridLinkClient,
Provider.TorBox => torBoxDebridClient,
+ Provider.HIGHWAY => highwayDebridClient,
_ => throw new("Invalid Provider")
};
}
@@ -703,6 +705,7 @@ public async Task GetProfile()
Provider = Enum.GetName(settings.Current.Provider.Provider),
UserName = user.Username,
Expiration = user.Expiration,
+ Premium = user.Premium || user.Expiration.HasValue,
CurrentVersion = UpdateChecker.CurrentVersion,
LatestVersion = UpdateChecker.LatestVersion,
IsInsecure = UpdateChecker.IsInsecure,
@@ -760,6 +763,35 @@ public async Task UpdateRdData()
}
}
+ // HIGHWAY promotes a queued spool reference (32-hex id) to a numeric transfer id once a slot frees.
+ if (torrent == null
+ && Settings.Get.Provider.Provider == Provider.HIGHWAY
+ && !String.IsNullOrWhiteSpace(rdTorrent.Id)
+ && Int64.TryParse(rdTorrent.Id, out _)
+ && !String.IsNullOrWhiteSpace(rdTorrent.Filename))
+ {
+ torrent = torrents.FirstOrDefault(localTorrent => localTorrent.ClientKind is null or Provider.HIGHWAY
+ && localTorrent.Type == rdTorrent.Type
+ && !String.IsNullOrWhiteSpace(localTorrent.RdId)
+ && localTorrent.RdId.Length == 32
+ && localTorrent.RdId.All(Uri.IsHexDigit)
+ && String.Equals(localTorrent.RdName, rdTorrent.Filename, StringComparison.OrdinalIgnoreCase));
+
+ if (torrent != null)
+ {
+ torrentsByRdId.Remove(torrent.RdId!);
+
+ await torrentData.UpdateRdId(torrent, rdTorrent.Id);
+ torrent.RdId = rdTorrent.Id;
+ torrent.ClientKind = Provider.HIGHWAY;
+ torrentsByRdId[rdTorrent.Id] = torrent;
+
+ logger.LogInformation("Promoted HIGHWAY spool reference to transfer id for {TorrentName} -> {RdId}",
+ torrent.RdName ?? rdTorrent.Filename,
+ rdTorrent.Id);
+ }
+ }
+
// Auto import torrents only torrents that have their files selected
if (torrent == null && settings.Current.Provider.AutoImport)
{
diff --git a/server/RdtClient.Web.Test/Controllers/QBittorrentControllerTest.cs b/server/RdtClient.Web.Test/Controllers/QBittorrentControllerTest.cs
index 8b9a9308..6e85f466 100644
--- a/server/RdtClient.Web.Test/Controllers/QBittorrentControllerTest.cs
+++ b/server/RdtClient.Web.Test/Controllers/QBittorrentControllerTest.cs
@@ -19,7 +19,7 @@ public QBittorrentControllerTest()
{
_settings = new();
_qBittorrentMock = new(new Mock>().Object, _settings, null!, null!, null!, new TorrentRunnerState());
- _torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
+ _torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
_controller = new(new Mock>().Object,
_qBittorrentMock.Object,
diff --git a/server/RdtClient.Web.Test/Controllers/SabnzbdControllerTest.cs b/server/RdtClient.Web.Test/Controllers/SabnzbdControllerTest.cs
index 9a1e2620..d3a40d71 100644
--- a/server/RdtClient.Web.Test/Controllers/SabnzbdControllerTest.cs
+++ b/server/RdtClient.Web.Test/Controllers/SabnzbdControllerTest.cs
@@ -24,7 +24,7 @@ public SabnzbdControllerTest()
_settings.Current.General.AuthenticationType = AuthenticationType.None;
_settings.Current.Provider.ApiKey = "test-api-key";
- var torrentsMock = new Mock(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
+ var torrentsMock = new Mock(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
var sabnzbdLoggerMock = new Mock>();
_sabnzbdMock = new(sabnzbdLoggerMock.Object, torrentsMock.Object, null!, _settings);
var loggerMock = new Mock>();
diff --git a/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs b/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs
index 5466dbbb..18c971e0 100644
--- a/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs
+++ b/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs
@@ -17,7 +17,7 @@ public class TorrentsControllerNzbTest
public TorrentsControllerNzbTest()
{
- _torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, new TestSettings(), new TorrentRunnerState());
+ _torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, new TestSettings(), new TorrentRunnerState());
_loggerMock = new();
_coordinatorMock = new();
_controller = new(_loggerMock.Object, _torrentsMock.Object, null!, _coordinatorMock.Object);
From 753507b173229fbc71ba4feeb506a6078849718e Mon Sep 17 00:00:00 2001
From: Yaknar
Date: Fri, 11 Sep 2026 13:09:40 +0200
Subject: [PATCH 2/2] Update HighwayDebridClient.cs
Remove outdated info
---
.../Services/DebridClients/HighwayDebridClient.cs | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/server/RdtClient.Service/Services/DebridClients/HighwayDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/HighwayDebridClient.cs
index d23ed1bd..8e03e1df 100644
--- a/server/RdtClient.Service/Services/DebridClients/HighwayDebridClient.cs
+++ b/server/RdtClient.Service/Services/DebridClients/HighwayDebridClient.cs
@@ -16,7 +16,7 @@ namespace RdtClient.Service.Services.DebridClients;
///
/// Client for HIGHWAY (high-way.me ), a self-hosted "cloud download center" that downloads
-/// torrents and usenet server-side. See HIGHWAY_PROVIDER_SPEC.md in the repo root.
+/// torrents and usenet server-side.
///
public class HighwayDebridClient(
ILogger logger,
@@ -173,10 +173,6 @@ public async Task> GetAvailableFiles(String has
public Task SelectFiles(Torrent torrent)
{
- // HIGHWAY always downloads the whole transfer; nothing to actually "select". The `files[]` list
- // (spec §5) can lag behind state:"completed" - it's populated from HIGHWAY's cloud_files index,
- // which may not have caught up yet, especially for usenet (spec §8).
- //
// Symlink mode needs named files[] entries to symlink out of the rclone mount, so wait/retry while
// it's empty. Every other download client always fetches the whole transfer as one ZIP
// (GetDownloadInfos below) regardless of files[], so don't block those on it.
@@ -359,7 +355,7 @@ public async Task UpdateData(Torrent torrent, DebridClientTorrent? torr
return [];
}
- // Each file's own signed download_url (spec §4.3/§5) lets us fetch it directly, respecting the usual
+ // Each file's own signed download_url lets us fetch it directly, respecting the usual
// filters, with no ZIP/unzip step. Only fall back to the whole-folder ZIP when signed per-file URLs
// aren't available server-side (same condition that leaves the top-level download_url null too).
if (downloadableFiles.All(file => !String.IsNullOrWhiteSpace(file.DownloadLink)))
@@ -721,7 +717,7 @@ public sealed class HighwayDeleteResult
public Boolean Removed { get; set; }
}
- /// Response of ?stats=1 (§4.5). Only the bits rdt-client surfaces are modelled.
+ /// Response of ?stats=1 . Only the bits rdt-client surfaces are modelled.
public sealed class HighwayStats
{
[JsonPropertyName("username")]
@@ -755,7 +751,7 @@ public sealed class HighwayStorage
public Int64 Available { get; set; }
}
- /// Response of ?check=<hash> (§4.6).
+ /// Response of ?check=<hash> .
public sealed class HighwayCheckResponse
{
[JsonPropertyName("service")]