11 Commits

Author SHA1 Message Date
ahjephson
075ea9f855 Remove invalid async style from tests and quick code format 2025-10-23 17:05:45 +01:00
ahjephson
d01204a703 Split datamanager into separate classes. 2025-10-23 16:51:58 +01:00
ahjephson
ab1c594b07 Add unit tests for QBitTorrentClient 2025-10-23 16:03:05 +01:00
ahjephson
6a5d8b2610 Don't use async methods 2025-10-23 15:57:34 +01:00
ahjephson
b8412bb232 Add new v5 properties to AddTorrent 2025-10-22 14:05:13 +01:00
ahjephson
e64a13c7c9 Add ShareRatio to include ShareLimitAction 2025-10-22 12:42:30 +01:00
ahjephson
e4ea79a8ed Update planning doc 2025-10-22 11:41:06 +01:00
ahjephson
0976b72411 Add new v5 torrent properties 2025-10-22 11:39:04 +01:00
ahjephson
965fbcd010 Add copy comments/content path 2025-10-22 10:55:34 +01:00
ahjephson
3d0dbde9f4 Add v5 only preferences 2025-10-22 10:40:14 +01:00
ahjephson
5b4fbde7b2 Removed paused state. 2025-10-22 08:29:43 +01:00
84 changed files with 9409 additions and 775 deletions

View File

@@ -0,0 +1,506 @@
using AwesomeAssertions;
using Lantean.QBTMud.Models;
using Lantean.QBTMud.Services;
using QbtPeer = Lantean.QBitTorrentClient.Models.Peer;
using QbtTorrentPeers = Lantean.QBitTorrentClient.Models.TorrentPeers;
namespace Lantean.QBTMud.Test.Services
{
public class PeerDataManagerTests
{
private readonly PeerDataManager _target = new PeerDataManager();
// ---------- CreatePeerList ----------
[Fact]
public void GIVEN_NullPeers_WHEN_CreatePeerList_THEN_EmptyPeerList()
{
// arrange
var input = new QbtTorrentPeers(
fullUpdate: false,
peers: null,
peersRemoved: null,
requestId: 1,
showFlags: null);
// act
var result = _target.CreatePeerList(input);
// assert
result.Should().NotBeNull();
result.Peers.Should().NotBeNull();
result.Peers.Count.Should().Be(0);
}
[Fact]
public void GIVEN_MultiplePeers_WHEN_CreatePeerList_THEN_MapsAllFieldsAndKeys()
{
// arrange
var p1 = new QbtPeer(
client: "qBittorrent/4.6.0",
connection: "TCP",
country: "UK",
countryCode: "GB",
downloadSpeed: 1200,
downloaded: 11,
files: "file1.mkv",
flags: "D U H",
flagsDescription: "downloading, uploading, high priority",
iPAddress: "1.1.1.1",
i2pDestination: null,
clientId: "ClientA",
port: 6881,
progress: 0.5f,
relevance: 0.7f,
uploadSpeed: 3400,
uploaded: 22);
var p2 = new QbtPeer(
client: "Transmission/4.0",
connection: "uTP",
country: "Canada",
countryCode: "CA",
downloadSpeed: 2200,
downloaded: 33,
files: "file2.mp4",
flags: "Q",
flagsDescription: "queued",
iPAddress: "2.2.2.2",
i2pDestination: null,
clientId: "ClientB",
port: 51413,
progress: 0.9f,
relevance: 0.1f,
uploadSpeed: 100,
uploaded: 44);
var dict = new Dictionary<string, QbtPeer>
{
["1.1.1.1:6881"] = p1,
["2.2.2.2:51413"] = p2
};
var input = new QbtTorrentPeers(
fullUpdate: true,
peers: dict,
peersRemoved: null,
requestId: 2,
showFlags: true);
// act
var result = _target.CreatePeerList(input);
// assert
result.Should().NotBeNull();
result.Peers.Count.Should().Be(2);
result.Peers.Should().ContainKey("1.1.1.1:6881");
var a = result.Peers["1.1.1.1:6881"];
a.Key.Should().Be("1.1.1.1:6881");
a.Client.Should().Be("qBittorrent/4.6.0");
a.ClientId.Should().Be("ClientA");
a.Connection.Should().Be("TCP");
a.Country.Should().Be("UK");
a.CountryCode.Should().Be("GB");
a.Downloaded.Should().Be(11);
a.DownloadSpeed.Should().Be(1200);
a.Files.Should().Be("file1.mkv");
a.Flags.Should().Be("D U H");
a.FlagsDescription.Should().Be("downloading, uploading, high priority");
a.IPAddress.Should().Be("1.1.1.1");
a.Port.Should().Be(6881);
a.Progress.Should().Be(0.5f);
a.Relevance.Should().Be(0.7f);
a.Uploaded.Should().Be(22);
a.UploadSpeed.Should().Be(3400);
result.Peers.Should().ContainKey("2.2.2.2:51413");
var b = result.Peers["2.2.2.2:51413"];
b.Key.Should().Be("2.2.2.2:51413");
b.Client.Should().Be("Transmission/4.0");
b.ClientId.Should().Be("ClientB");
b.Connection.Should().Be("uTP");
b.Country.Should().Be("Canada");
b.CountryCode.Should().Be("CA");
b.Downloaded.Should().Be(33);
b.DownloadSpeed.Should().Be(2200);
b.Files.Should().Be("file2.mp4");
b.Flags.Should().Be("Q");
b.FlagsDescription.Should().Be("queued");
b.IPAddress.Should().Be("2.2.2.2");
b.Port.Should().Be(51413);
b.Progress.Should().Be(0.9f);
b.Relevance.Should().Be(0.1f);
b.Uploaded.Should().Be(44);
b.UploadSpeed.Should().Be(100);
}
[Fact]
public void GIVEN_PeerWithNullNumerics_WHEN_CreatePeerList_THEN_DefaultsToZeros()
{
// arrange
var nullish = new QbtPeer(
client: "ClientX",
connection: "TCP",
country: null,
countryCode: null,
downloadSpeed: null, // -> 0
downloaded: null, // -> 0
files: "file.dat",
flags: "",
flagsDescription: "",
iPAddress: "9.9.9.9",
i2pDestination: null,
clientId: "CID",
port: null, // -> 0
progress: null, // -> 0
relevance: null, // -> 0
uploadSpeed: null, // -> 0
uploaded: null); // -> 0
var input = new QbtTorrentPeers(
fullUpdate: false,
peers: new Dictionary<string, QbtPeer> { ["9.9.9.1:0"] = nullish },
peersRemoved: null,
requestId: 3,
showFlags: null);
// act
var result = _target.CreatePeerList(input);
// assert
result.Peers.Should().ContainKey("9.9.9.1:0");
var p = result.Peers["9.9.9.1:0"];
p.Client.Should().Be("ClientX");
p.Connection.Should().Be("TCP");
p.DownloadSpeed.Should().Be(0);
p.Downloaded.Should().Be(0);
p.Port.Should().Be(0);
p.Progress.Should().Be(0f);
p.Relevance.Should().Be(0f);
p.UploadSpeed.Should().Be(0);
p.Uploaded.Should().Be(0);
}
// ---------- MergeTorrentPeers ----------
[Fact]
public void GIVEN_NoChanges_WHEN_MergeTorrentPeers_THEN_DoesNothing()
{
// arrange
var peerList = new PeerList(new Dictionary<string, Peer>
{
["1.1.1.1:6881"] = new Peer(
key: "1.1.1.1:6881",
client: "qB",
clientId: "A",
connection: "TCP",
country: "UK",
countryCode: "GB",
downloaded: 1,
downloadSpeed: 2,
files: "f",
flags: "D",
flagsDescription: "down",
iPAddress: "1.1.1.1",
port: 6881,
progress: 0.1f,
relevance: 0.2f,
uploaded: 3,
uploadSpeed: 4)
});
var input = new QbtTorrentPeers(
fullUpdate: false,
peers: null,
peersRemoved: null,
requestId: 10,
showFlags: null);
// act
_target.MergeTorrentPeers(input, peerList);
// assert
peerList.Peers.Count.Should().Be(1);
peerList.Peers.Should().ContainKey("1.1.1.1:6881");
}
[Fact]
public void GIVEN_PeersRemovedWithExistingAndMissing_WHEN_MergeTorrentPeers_THEN_RemovesExistingOnly()
{
// arrange
var peerList = new PeerList(new Dictionary<string, Peer>
{
["a"] = new Peer("a", "c", "id", "TCP", null, null, 0, 0, "f", "F", "FD", "10.0.0.1", 1111, 0, 0, 0, 0),
["b"] = new Peer("b", "c2", "id2", "uTP", null, null, 0, 0, "f2", "F2", "FD2", "10.0.0.2", 2222, 0, 0, 0, 0),
});
var input = new QbtTorrentPeers(
fullUpdate: false,
peers: null,
peersRemoved: new List<string> { "a", "missing" },
requestId: 11,
showFlags: null);
// act
_target.MergeTorrentPeers(input, peerList);
// assert
peerList.Peers.Count.Should().Be(1);
peerList.Peers.Should().ContainKey("b");
peerList.Peers.Should().NotContainKey("a");
}
[Fact]
public void GIVEN_NewPeers_WHEN_MergeTorrentPeers_THEN_AddsAllWithProperMapping()
{
// arrange
var peerList = new PeerList(new Dictionary<string, Peer>());
var q1 = new QbtPeer(
client: "Client1",
connection: "TCP",
country: "US",
countryCode: "US",
downloadSpeed: 1000,
downloaded: 10,
files: "a.mkv",
flags: "D",
flagsDescription: "down",
iPAddress: "3.3.3.3",
i2pDestination: null,
clientId: "ID1",
port: 6000,
progress: 0.4f,
relevance: 0.9f,
uploadSpeed: 50,
uploaded: 5);
var q2 = new QbtPeer(
client: "Client2",
connection: "uTP",
country: "DE",
countryCode: "DE",
downloadSpeed: 2000,
downloaded: 20,
files: "b.mp4",
flags: "",
flagsDescription: "",
iPAddress: "4.4.4.4",
i2pDestination: null,
clientId: "ID2",
port: 7000,
progress: 0.8f,
relevance: 0.1f,
uploadSpeed: 150,
uploaded: 15);
var input = new QbtTorrentPeers(
fullUpdate: true,
peers: new Dictionary<string, QbtPeer>
{
["3.3.3.3:6000"] = q1,
["4.4.4.4:7000"] = q2
},
peersRemoved: null,
requestId: 12,
showFlags: true);
// act
_target.MergeTorrentPeers(input, peerList);
// assert
peerList.Peers.Count.Should().Be(2);
var p1 = peerList.Peers["3.3.3.3:6000"];
p1.Client.Should().Be("Client1");
p1.ClientId.Should().Be("ID1");
p1.Connection.Should().Be("TCP");
p1.Country.Should().Be("US");
p1.CountryCode.Should().Be("US");
p1.Downloaded.Should().Be(10);
p1.DownloadSpeed.Should().Be(1000);
p1.Files.Should().Be("a.mkv");
p1.Flags.Should().Be("D");
p1.FlagsDescription.Should().Be("down");
p1.IPAddress.Should().Be("3.3.3.3");
p1.Port.Should().Be(6000);
p1.Progress.Should().Be(0.4f);
p1.Relevance.Should().Be(0.9f);
p1.Uploaded.Should().Be(5);
p1.UploadSpeed.Should().Be(50);
var p2 = peerList.Peers["4.4.4.4:7000"];
p2.Client.Should().Be("Client2");
p2.ClientId.Should().Be("ID2");
p2.Connection.Should().Be("uTP");
p2.Country.Should().Be("DE");
p2.CountryCode.Should().Be("DE");
p2.Downloaded.Should().Be(20);
p2.DownloadSpeed.Should().Be(2000);
p2.Files.Should().Be("b.mp4");
p2.Flags.Should().Be("");
p2.FlagsDescription.Should().Be("");
p2.IPAddress.Should().Be("4.4.4.4");
p2.Port.Should().Be(7000);
p2.Progress.Should().Be(0.8f);
p2.Relevance.Should().Be(0.1f);
p2.Uploaded.Should().Be(15);
p2.UploadSpeed.Should().Be(150);
}
[Fact]
public void GIVEN_ExistingPeer_AND_UpdateWithPartialNulls_WHEN_MergeTorrentPeers_THEN_OnlyNonNullFieldsChange()
{
// arrange
var existing = new Peer(
key: "5.5.5.5:6881",
client: "OldClient",
clientId: "OldID",
connection: "TCP",
country: "ES",
countryCode: "ES",
downloaded: 111,
downloadSpeed: 222,
files: "old.dat",
flags: "X",
flagsDescription: "old",
iPAddress: "5.5.5.5",
port: 6881,
progress: 0.11f,
relevance: 0.22f,
uploaded: 333,
uploadSpeed: 444);
var peerList = new PeerList(new Dictionary<string, Peer>
{
[existing.Key] = existing
});
var update = new QbtPeer(
client: null, // keep OldClient
connection: "uTP", // overwrite
country: null, // keep ES
countryCode: "FR", // overwrite
downloadSpeed: null, // keep 222
downloaded: 999, // overwrite
files: null, // keep old.dat
flags: "N", // overwrite
flagsDescription: null, // keep old
iPAddress: null, // keep 5.5.5.5
i2pDestination: null,
clientId: "NewID", // overwrite
port: null, // keep 6881
progress: 0.77f, // overwrite
relevance: null, // keep 0.22
uploadSpeed: 888, // overwrite
uploaded: null); // keep 333
var input = new QbtTorrentPeers(
fullUpdate: false,
peers: new Dictionary<string, QbtPeer> { [existing.Key] = update },
peersRemoved: null,
requestId: 13,
showFlags: null);
// act
_target.MergeTorrentPeers(input, peerList);
// assert
var p = peerList.Peers[existing.Key];
p.Client.Should().Be("OldClient");
p.ClientId.Should().Be("NewID");
p.Connection.Should().Be("uTP");
p.Country.Should().Be("ES");
p.CountryCode.Should().Be("FR");
p.DownloadSpeed.Should().Be(222);
p.Downloaded.Should().Be(999);
p.Files.Should().Be("old.dat");
p.Flags.Should().Be("N");
p.FlagsDescription.Should().Be("old");
p.IPAddress.Should().Be("5.5.5.5");
p.Port.Should().Be(6881);
p.Progress.Should().Be(0.77f);
p.Relevance.Should().Be(0.22f);
p.UploadSpeed.Should().Be(888);
p.Uploaded.Should().Be(333);
}
[Fact]
public void GIVEN_KeyRemovedThenReaddedInSameMerge_WHEN_MergeTorrentPeers_THEN_PresentWithNewValues()
{
// arrange
var key = "6.6.6.6:6001";
var oldPeer = new Peer(
key: key,
client: "Old",
clientId: "OID",
connection: "TCP",
country: null,
countryCode: null,
downloaded: 1,
downloadSpeed: 2,
files: "old",
flags: "O",
flagsDescription: "old",
iPAddress: "6.6.6.6",
port: 6001,
progress: 0.1f,
relevance: 0.2f,
uploaded: 3,
uploadSpeed: 4);
var peerList = new PeerList(new Dictionary<string, Peer> { [key] = oldPeer });
var newPeer = new QbtPeer(
client: "New",
connection: "uTP",
country: "NL",
countryCode: "NL",
downloadSpeed: 999,
downloaded: 111,
files: "new",
flags: "N",
flagsDescription: "new",
iPAddress: "6.6.6.6",
i2pDestination: null,
clientId: "NID",
port: 6001,
progress: 0.9f,
relevance: 0.8f,
uploadSpeed: 777,
uploaded: 333);
var input = new QbtTorrentPeers(
fullUpdate: false,
peers: new Dictionary<string, QbtPeer> { [key] = newPeer },
peersRemoved: new List<string> { key },
requestId: 14,
showFlags: null);
// act
_target.MergeTorrentPeers(input, peerList);
// assert
peerList.Peers.Should().ContainKey(key);
var p = peerList.Peers[key];
p.Client.Should().Be("New");
p.ClientId.Should().Be("NID");
p.Connection.Should().Be("uTP");
p.Country.Should().Be("NL");
p.CountryCode.Should().Be("NL");
p.Downloaded.Should().Be(111);
p.DownloadSpeed.Should().Be(999);
p.Files.Should().Be("new");
p.Flags.Should().Be("N");
p.FlagsDescription.Should().Be("new");
p.IPAddress.Should().Be("6.6.6.6");
p.Port.Should().Be(6001);
p.Progress.Should().Be(0.9f);
p.Relevance.Should().Be(0.8f);
p.Uploaded.Should().Be(333);
p.UploadSpeed.Should().Be(777);
}
}
}

View File

@@ -0,0 +1,832 @@
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
using Lantean.QBTMud.Services;
namespace Lantean.QBTMud.Test.Services
{
public class PreferencesDataManagerTests
{
private readonly PreferencesDataManager _target = new PreferencesDataManager();
// ---------- Builders ----------
private static UpdatePreferences BuildAllSetA()
{
return new UpdatePreferences
{
AddToTopOfQueue = true,
AddStoppedEnabled = true,
AddTrackers = "a, b, c",
AddTrackersEnabled = true,
AltDlLimit = 1,
AltUpLimit = 2,
AlternativeWebuiEnabled = true,
AlternativeWebuiPath = "/alt-a",
AnnounceIp = "1.2.3.4",
AnnouncePort = 6881,
AnnounceToAllTiers = true,
AnnounceToAllTrackers = true,
AnonymousMode = true,
AppInstanceName = "app-a",
AsyncIoThreads = 3,
AutoDeleteMode = 4,
AutoTmmEnabled = true,
AutorunEnabled = true,
AutorunOnTorrentAddedEnabled = true,
AutorunOnTorrentAddedProgram = "prog-added-a",
AutorunProgram = "prog-a",
BannedIPs = "10.0.0.1;10.0.0.2",
BdecodeDepthLimit = 5,
BdecodeTokenLimit = 6,
BittorrentProtocol = 7,
BlockPeersOnPrivilegedPorts = true,
BypassAuthSubnetWhitelist = "192.168.0.0/24",
BypassAuthSubnetWhitelistEnabled = true,
BypassLocalAuth = true,
CategoryChangedTmmEnabled = true,
CheckingMemoryUse = 8,
ConnectionSpeed = 9,
CurrentInterfaceAddress = "eth0-addr-a",
CurrentInterfaceName = "eth0-name-a",
CurrentNetworkInterface = "eth0-a",
Dht = true,
DhtBootstrapNodes = "node-a;node-b",
DiskCache = 10,
DiskCacheTtl = 11,
DiskIoReadMode = 12,
DiskIoType = 13,
DiskIoWriteMode = 14,
DiskQueueSize = 15,
DlLimit = 16,
DontCountSlowTorrents = true,
DyndnsDomain = "dyndns-a",
DyndnsEnabled = true,
DyndnsPassword = "dyndns-pass-a",
DyndnsService = 17,
DyndnsUsername = "dyndns-user-a",
EmbeddedTrackerPort = 18,
EmbeddedTrackerPortForwarding = true,
EnableCoalesceReadWrite = true,
EnableEmbeddedTracker = true,
EnableMultiConnectionsFromSameIp = true,
EnablePieceExtentAffinity = true,
EnableUploadSuggestions = true,
Encryption = 19,
ExcludedFileNames = "*.tmp;*.bak",
ExcludedFileNamesEnabled = true,
ExportDir = "/export-a",
ExportDirFin = "/export-fin-a",
FileLogAge = 20,
FileLogAgeType = 21,
FileLogBackupEnabled = true,
FileLogDeleteOld = true,
FileLogEnabled = true,
FileLogMaxSize = 22,
FileLogPath = "/log-a",
FilePoolSize = 23,
HashingThreads = 24,
I2pAddress = "i2p-addr-a",
I2pEnabled = true,
I2pInboundLength = 25,
I2pInboundQuantity = 26,
I2pMixedMode = true,
I2pOutboundLength = 27,
I2pOutboundQuantity = 28,
I2pPort = 29,
IdnSupportEnabled = true,
IncompleteFilesExt = true,
UseUnwantedFolder = true,
IpFilterEnabled = true,
IpFilterPath = "/ipfilter-a",
IpFilterTrackers = true,
LimitLanPeers = true,
LimitTcpOverhead = true,
LimitUtpRate = true,
ListenPort = 30,
SslEnabled = true,
SslListenPort = 4430,
Locale = "en-A",
Lsd = true,
MailNotificationAuthEnabled = true,
MailNotificationEmail = "mail@a",
MailNotificationEnabled = true,
MailNotificationPassword = "mail-pass-a",
MailNotificationSender = "sender-a",
MailNotificationSmtp = "smtp-a",
MailNotificationSslEnabled = true,
MailNotificationUsername = "mail-user-a",
MarkOfTheWeb = true,
MaxActiveCheckingTorrents = 31,
MaxActiveDownloads = 32,
MaxActiveTorrents = 33,
MaxActiveUploads = 34,
MaxConcurrentHttpAnnounces = 35,
MaxConnec = 36,
MaxConnecPerTorrent = 37,
MaxInactiveSeedingTime = 38,
MaxInactiveSeedingTimeEnabled = true,
MaxRatio = 1.1f,
MaxRatioAct = 39,
MaxRatioEnabled = null, // kept null to avoid Validate() conflict in tests that don't call Validate
MaxSeedingTime = 40,
MaxSeedingTimeEnabled = null, // same as above
MaxUploads = 41,
MaxUploadsPerTorrent = 42,
MemoryWorkingSetLimit = 43,
MergeTrackers = true,
OutgoingPortsMax = 44,
OutgoingPortsMin = 45,
PeerTos = 46,
PeerTurnover = 47,
PeerTurnoverCutoff = 48,
PeerTurnoverInterval = 49,
PerformanceWarning = true,
Pex = true,
PreallocateAll = true,
ProxyAuthEnabled = true,
ProxyBittorrent = true,
ProxyHostnameLookup = true,
ProxyIp = "proxy-ip-a",
ProxyMisc = true,
ProxyPassword = "proxy-pass-a",
ProxyPeerConnections = true,
ProxyPort = 8080,
ProxyRss = true,
ProxyType = "http",
ProxyUsername = "proxy-user-a",
PythonExecutablePath = "/python-a",
QueueingEnabled = true,
RandomPort = true,
ReannounceWhenAddressChanged = true,
RecheckCompletedTorrents = true,
RefreshInterval = 50,
RequestQueueSize = 51,
ResolvePeerCountries = true,
ResumeDataStorageType = "fastresume",
RssAutoDownloadingEnabled = true,
RssDownloadRepackProperEpisodes = true,
RssFetchDelay = 5200L,
RssMaxArticlesPerFeed = 52,
RssProcessingEnabled = true,
RssRefreshInterval = 53,
RssSmartEpisodeFilters = "s01e01",
SavePath = "/save-a",
SavePathChangedTmmEnabled = true,
SaveResumeDataInterval = 54,
SaveStatisticsInterval = 540,
ScanDirs = new Dictionary<string, SaveLocation>
{
["watch"] = SaveLocation.Create(0),
["default"] = SaveLocation.Create(1),
["custom"] = SaveLocation.Create("/dls/a")
},
ScheduleFromHour = 1,
ScheduleFromMin = 2,
ScheduleToHour = 3,
ScheduleToMin = 4,
SchedulerDays = 5,
SchedulerEnabled = true,
SendBufferLowWatermark = 55,
SendBufferWatermark = 56,
SendBufferWatermarkFactor = 57,
SlowTorrentDlRateThreshold = 58,
SlowTorrentInactiveTimer = 59,
SlowTorrentUlRateThreshold = 60,
SocketBacklogSize = 61,
SocketReceiveBufferSize = 62,
SocketSendBufferSize = 63,
SsrfMitigation = true,
StopTrackerTimeout = 64,
TempPath = "/tmp-a",
TempPathEnabled = true,
TorrentChangedTmmEnabled = true,
TorrentContentLayout = "original",
TorrentContentRemoveOption = "to_trash",
TorrentFileSizeLimit = 65,
TorrentStopCondition = "metadata_received",
UpLimit = 66,
UploadChokingAlgorithm = 67,
UploadSlotsBehavior = 68,
Upnp = true,
UpnpLeaseDuration = 69,
UseCategoryPathsInManualMode = true,
UseHttps = true,
IgnoreSslErrors = true,
UseSubcategories = true,
UtpTcpMixedMode = 70,
ValidateHttpsTrackerCertificate = true,
WebUiAddress = "0.0.0.0",
WebUiApiKey = "api-key-a",
WebUiBanDuration = 71,
WebUiClickjackingProtectionEnabled = true,
WebUiCsrfProtectionEnabled = true,
WebUiCustomHttpHeaders = "X-Header: A",
WebUiDomainList = "a.example.com",
WebUiHostHeaderValidationEnabled = true,
WebUiHttpsCertPath = "/cert-a",
WebUiHttpsKeyPath = "/key-a",
WebUiMaxAuthFailCount = 72,
WebUiPort = 8081,
WebUiReverseProxiesList = "10.0.0.0/8",
WebUiReverseProxyEnabled = true,
WebUiSecureCookieEnabled = true,
WebUiSessionTimeout = 73,
WebUiUpnp = true,
WebUiUseCustomHttpHeadersEnabled = true,
WebUiUsername = "admin-a",
WebUiPassword = "pass-a",
ConfirmTorrentDeletion = true,
ConfirmTorrentRecheck = true,
StatusBarExternalIp = true
};
}
private static UpdatePreferences BuildAllSetB_AllNonNull()
{
return new UpdatePreferences
{
AddToTopOfQueue = false,
AddStoppedEnabled = false,
AddTrackers = "x, y",
AddTrackersEnabled = false,
AltDlLimit = 101,
AltUpLimit = 102,
AlternativeWebuiEnabled = false,
AlternativeWebuiPath = "/alt-b",
AnnounceIp = "5.6.7.8",
AnnouncePort = 6882,
AnnounceToAllTiers = false,
AnnounceToAllTrackers = false,
AnonymousMode = false,
AppInstanceName = "app-b",
AsyncIoThreads = 103,
AutoDeleteMode = 104,
AutoTmmEnabled = false,
AutorunEnabled = false,
AutorunOnTorrentAddedEnabled = false,
AutorunOnTorrentAddedProgram = "prog-added-b",
AutorunProgram = "prog-b",
BannedIPs = "10.1.1.1",
BdecodeDepthLimit = 105,
BdecodeTokenLimit = 106,
BittorrentProtocol = 107,
BlockPeersOnPrivilegedPorts = false,
BypassAuthSubnetWhitelist = "10.10.0.0/16",
BypassAuthSubnetWhitelistEnabled = false,
BypassLocalAuth = false,
CategoryChangedTmmEnabled = false,
CheckingMemoryUse = 108,
ConnectionSpeed = 109,
CurrentInterfaceAddress = "eth1-addr-b",
CurrentInterfaceName = "eth1-name-b",
CurrentNetworkInterface = "eth1-b",
Dht = false,
DhtBootstrapNodes = "node-c",
DiskCache = 110,
DiskCacheTtl = 111,
DiskIoReadMode = 112,
DiskIoType = 113,
DiskIoWriteMode = 114,
DiskQueueSize = 115,
DlLimit = 116,
DontCountSlowTorrents = false,
DyndnsDomain = "dyndns-b",
DyndnsEnabled = false,
DyndnsPassword = "dyndns-pass-b",
DyndnsService = 117,
DyndnsUsername = "dyndns-user-b",
EmbeddedTrackerPort = 118,
EmbeddedTrackerPortForwarding = false,
EnableCoalesceReadWrite = false,
EnableEmbeddedTracker = false,
EnableMultiConnectionsFromSameIp = false,
EnablePieceExtentAffinity = false,
EnableUploadSuggestions = false,
Encryption = 119,
ExcludedFileNames = "*.cache",
ExcludedFileNamesEnabled = false,
ExportDir = "/export-b",
ExportDirFin = "/export-fin-b",
FileLogAge = 120,
FileLogAgeType = 121,
FileLogBackupEnabled = false,
FileLogDeleteOld = false,
FileLogEnabled = false,
FileLogMaxSize = 122,
FileLogPath = "/log-b",
FilePoolSize = 123,
HashingThreads = 124,
I2pAddress = "i2p-addr-b",
I2pEnabled = false,
I2pInboundLength = 125,
I2pInboundQuantity = 126,
I2pMixedMode = false,
I2pOutboundLength = 127,
I2pOutboundQuantity = 128,
I2pPort = 129,
IdnSupportEnabled = false,
IncompleteFilesExt = false,
UseUnwantedFolder = false,
IpFilterEnabled = false,
IpFilterPath = "/ipfilter-b",
IpFilterTrackers = false,
LimitLanPeers = false,
LimitTcpOverhead = false,
LimitUtpRate = false,
ListenPort = 130,
SslEnabled = false,
SslListenPort = 4431,
Locale = "en-B",
Lsd = false,
MailNotificationAuthEnabled = false,
MailNotificationEmail = "mail@b",
MailNotificationEnabled = false,
MailNotificationPassword = "mail-pass-b",
MailNotificationSender = "sender-b",
MailNotificationSmtp = "smtp-b",
MailNotificationSslEnabled = false,
MailNotificationUsername = "mail-user-b",
MarkOfTheWeb = false,
MaxActiveCheckingTorrents = 131,
MaxActiveDownloads = 132,
MaxActiveTorrents = 133,
MaxActiveUploads = 134,
MaxConcurrentHttpAnnounces = 135,
MaxConnec = 136,
MaxConnecPerTorrent = 137,
MaxInactiveSeedingTime = 238, // non-null and different from A
MaxInactiveSeedingTimeEnabled = false, // non-null here
MaxRatio = 2.2f, // non-null here
MaxRatioAct = 139,
MaxRatioEnabled = true, // non-null here
MaxSeedingTime = 240, // non-null here
MaxSeedingTimeEnabled = true, // non-null here
MaxUploads = 141,
MaxUploadsPerTorrent = 142,
MemoryWorkingSetLimit = 143,
MergeTrackers = false,
OutgoingPortsMax = 144,
OutgoingPortsMin = 145,
PeerTos = 146,
PeerTurnover = 147,
PeerTurnoverCutoff = 148,
PeerTurnoverInterval = 149,
PerformanceWarning = false,
Pex = false,
PreallocateAll = false,
ProxyAuthEnabled = false,
ProxyBittorrent = false,
ProxyHostnameLookup = false,
ProxyIp = "proxy-ip-b",
ProxyMisc = false,
ProxyPassword = "proxy-pass-b",
ProxyPeerConnections = false,
ProxyPort = 8888,
ProxyRss = false,
ProxyType = "socks5",
ProxyUsername = "proxy-user-b",
PythonExecutablePath = "/python-b",
QueueingEnabled = false,
RandomPort = false,
ReannounceWhenAddressChanged = false,
RecheckCompletedTorrents = false,
RefreshInterval = 150,
RequestQueueSize = 151,
ResolvePeerCountries = false,
ResumeDataStorageType = "sqlite",
RssAutoDownloadingEnabled = false,
RssDownloadRepackProperEpisodes = false,
RssFetchDelay = 5300L,
RssMaxArticlesPerFeed = 152,
RssProcessingEnabled = false,
RssRefreshInterval = 153,
RssSmartEpisodeFilters = "s02e02",
SavePath = "/save-b",
SavePathChangedTmmEnabled = false,
SaveResumeDataInterval = 154,
SaveStatisticsInterval = 1540,
ScanDirs = new Dictionary<string, SaveLocation>
{
["watch"] = SaveLocation.Create(0),
["default"] = SaveLocation.Create(1),
["custom"] = SaveLocation.Create("/dls/b")
},
ScheduleFromHour = 11,
ScheduleFromMin = 12,
ScheduleToHour = 13,
ScheduleToMin = 14,
SchedulerDays = 15,
SchedulerEnabled = false,
SendBufferLowWatermark = 155,
SendBufferWatermark = 156,
SendBufferWatermarkFactor = 157,
SlowTorrentDlRateThreshold = 158,
SlowTorrentInactiveTimer = 159,
SlowTorrentUlRateThreshold = 160,
SocketBacklogSize = 161,
SocketReceiveBufferSize = 162,
SocketSendBufferSize = 163,
SsrfMitigation = false,
StopTrackerTimeout = 164,
TempPath = "/tmp-b",
TempPathEnabled = false,
TorrentChangedTmmEnabled = false,
TorrentContentLayout = "subfolder",
TorrentContentRemoveOption = "delete",
TorrentFileSizeLimit = 165,
TorrentStopCondition = "files_checked",
UpLimit = 166,
UploadChokingAlgorithm = 167,
UploadSlotsBehavior = 168,
Upnp = false,
UpnpLeaseDuration = 169,
UseCategoryPathsInManualMode = false,
UseHttps = false,
IgnoreSslErrors = false,
UseSubcategories = false,
UtpTcpMixedMode = 170,
ValidateHttpsTrackerCertificate = false,
WebUiAddress = "127.0.0.1",
WebUiApiKey = "api-key-b",
WebUiBanDuration = 171,
WebUiClickjackingProtectionEnabled = false,
WebUiCsrfProtectionEnabled = false,
WebUiCustomHttpHeaders = "X-Header: B",
WebUiDomainList = "b.example.com",
WebUiHostHeaderValidationEnabled = false,
WebUiHttpsCertPath = "/cert-b",
WebUiHttpsKeyPath = "/key-b",
WebUiMaxAuthFailCount = 172,
WebUiPort = 8181,
WebUiReverseProxiesList = "192.168.0.0/16",
WebUiReverseProxyEnabled = false,
WebUiSecureCookieEnabled = false,
WebUiSessionTimeout = 173,
WebUiUpnp = false,
WebUiUseCustomHttpHeadersEnabled = false,
WebUiUsername = "admin-b",
WebUiPassword = "pass-b",
ConfirmTorrentDeletion = false,
ConfirmTorrentRecheck = false,
StatusBarExternalIp = false
};
}
private static UpdatePreferences BuildPartialChange()
{
// Only a handful of fields are non-null; the rest null (=> retain originals).
return new UpdatePreferences
{
AddToTopOfQueue = false, // bool
AltDlLimit = 222, // int
SavePath = "/save-partial", // string
MaxRatio = 3.3f, // float (leave MaxRatioEnabled null)
ProxyIp = "proxy-new", // string
TempPathEnabled = false, // bool
WebUiPort = 9090, // int
RssFetchDelay = 7777L, // long
ScanDirs = new Dictionary<string, SaveLocation>
{
["watch"] = SaveLocation.Create(0),
["custom"] = SaveLocation.Create("/new/custom")
},
// everything else null => “do not change”
};
}
private static void AssertAllEqual(UpdatePreferences actual, UpdatePreferences expected)
{
// value-by-value assertions (no reflection)
actual.AddToTopOfQueue.Should().Be(expected.AddToTopOfQueue);
actual.AddStoppedEnabled.Should().Be(expected.AddStoppedEnabled);
actual.AddTrackers.Should().Be(expected.AddTrackers);
actual.AddTrackersEnabled.Should().Be(expected.AddTrackersEnabled);
actual.AltDlLimit.Should().Be(expected.AltDlLimit);
actual.AltUpLimit.Should().Be(expected.AltUpLimit);
actual.AlternativeWebuiEnabled.Should().Be(expected.AlternativeWebuiEnabled);
actual.AlternativeWebuiPath.Should().Be(expected.AlternativeWebuiPath);
actual.AnnounceIp.Should().Be(expected.AnnounceIp);
actual.AnnouncePort.Should().Be(expected.AnnouncePort);
actual.AnnounceToAllTiers.Should().Be(expected.AnnounceToAllTiers);
actual.AnnounceToAllTrackers.Should().Be(expected.AnnounceToAllTrackers);
actual.AnonymousMode.Should().Be(expected.AnonymousMode);
actual.AppInstanceName.Should().Be(expected.AppInstanceName);
actual.AsyncIoThreads.Should().Be(expected.AsyncIoThreads);
actual.AutoDeleteMode.Should().Be(expected.AutoDeleteMode);
actual.AutoTmmEnabled.Should().Be(expected.AutoTmmEnabled);
actual.AutorunEnabled.Should().Be(expected.AutorunEnabled);
actual.AutorunOnTorrentAddedEnabled.Should().Be(expected.AutorunOnTorrentAddedEnabled);
actual.AutorunOnTorrentAddedProgram.Should().Be(expected.AutorunOnTorrentAddedProgram);
actual.AutorunProgram.Should().Be(expected.AutorunProgram);
actual.BannedIPs.Should().Be(expected.BannedIPs);
actual.BdecodeDepthLimit.Should().Be(expected.BdecodeDepthLimit);
actual.BdecodeTokenLimit.Should().Be(expected.BdecodeTokenLimit);
actual.BittorrentProtocol.Should().Be(expected.BittorrentProtocol);
actual.BlockPeersOnPrivilegedPorts.Should().Be(expected.BlockPeersOnPrivilegedPorts);
actual.BypassAuthSubnetWhitelist.Should().Be(expected.BypassAuthSubnetWhitelist);
actual.BypassAuthSubnetWhitelistEnabled.Should().Be(expected.BypassAuthSubnetWhitelistEnabled);
actual.BypassLocalAuth.Should().Be(expected.BypassLocalAuth);
actual.CategoryChangedTmmEnabled.Should().Be(expected.CategoryChangedTmmEnabled);
actual.CheckingMemoryUse.Should().Be(expected.CheckingMemoryUse);
actual.ConnectionSpeed.Should().Be(expected.ConnectionSpeed);
actual.CurrentInterfaceAddress.Should().Be(expected.CurrentInterfaceAddress);
actual.CurrentInterfaceName.Should().Be(expected.CurrentInterfaceName);
actual.CurrentNetworkInterface.Should().Be(expected.CurrentNetworkInterface);
actual.Dht.Should().Be(expected.Dht);
actual.DhtBootstrapNodes.Should().Be(expected.DhtBootstrapNodes);
actual.DiskCache.Should().Be(expected.DiskCache);
actual.DiskCacheTtl.Should().Be(expected.DiskCacheTtl);
actual.DiskIoReadMode.Should().Be(expected.DiskIoReadMode);
actual.DiskIoType.Should().Be(expected.DiskIoType);
actual.DiskIoWriteMode.Should().Be(expected.DiskIoWriteMode);
actual.DiskQueueSize.Should().Be(expected.DiskQueueSize);
actual.DlLimit.Should().Be(expected.DlLimit);
actual.DontCountSlowTorrents.Should().Be(expected.DontCountSlowTorrents);
actual.DyndnsDomain.Should().Be(expected.DyndnsDomain);
actual.DyndnsEnabled.Should().Be(expected.DyndnsEnabled);
actual.DyndnsPassword.Should().Be(expected.DyndnsPassword);
actual.DyndnsService.Should().Be(expected.DyndnsService);
actual.DyndnsUsername.Should().Be(expected.DyndnsUsername);
actual.EmbeddedTrackerPort.Should().Be(expected.EmbeddedTrackerPort);
actual.EmbeddedTrackerPortForwarding.Should().Be(expected.EmbeddedTrackerPortForwarding);
actual.EnableCoalesceReadWrite.Should().Be(expected.EnableCoalesceReadWrite);
actual.EnableEmbeddedTracker.Should().Be(expected.EnableEmbeddedTracker);
actual.EnableMultiConnectionsFromSameIp.Should().Be(expected.EnableMultiConnectionsFromSameIp);
actual.EnablePieceExtentAffinity.Should().Be(expected.EnablePieceExtentAffinity);
actual.EnableUploadSuggestions.Should().Be(expected.EnableUploadSuggestions);
actual.Encryption.Should().Be(expected.Encryption);
actual.ExcludedFileNames.Should().Be(expected.ExcludedFileNames);
actual.ExcludedFileNamesEnabled.Should().Be(expected.ExcludedFileNamesEnabled);
actual.ExportDir.Should().Be(expected.ExportDir);
actual.ExportDirFin.Should().Be(expected.ExportDirFin);
actual.FileLogAge.Should().Be(expected.FileLogAge);
actual.FileLogAgeType.Should().Be(expected.FileLogAgeType);
actual.FileLogBackupEnabled.Should().Be(expected.FileLogBackupEnabled);
actual.FileLogDeleteOld.Should().Be(expected.FileLogDeleteOld);
actual.FileLogEnabled.Should().Be(expected.FileLogEnabled);
actual.FileLogMaxSize.Should().Be(expected.FileLogMaxSize);
actual.FileLogPath.Should().Be(expected.FileLogPath);
actual.FilePoolSize.Should().Be(expected.FilePoolSize);
actual.HashingThreads.Should().Be(expected.HashingThreads);
actual.I2pAddress.Should().Be(expected.I2pAddress);
actual.I2pEnabled.Should().Be(expected.I2pEnabled);
actual.I2pInboundLength.Should().Be(expected.I2pInboundLength);
actual.I2pInboundQuantity.Should().Be(expected.I2pInboundQuantity);
actual.I2pMixedMode.Should().Be(expected.I2pMixedMode);
actual.I2pOutboundLength.Should().Be(expected.I2pOutboundLength);
actual.I2pOutboundQuantity.Should().Be(expected.I2pOutboundQuantity);
actual.I2pPort.Should().Be(expected.I2pPort);
actual.IdnSupportEnabled.Should().Be(expected.IdnSupportEnabled);
actual.IncompleteFilesExt.Should().Be(expected.IncompleteFilesExt);
actual.UseUnwantedFolder.Should().Be(expected.UseUnwantedFolder);
actual.IpFilterEnabled.Should().Be(expected.IpFilterEnabled);
actual.IpFilterPath.Should().Be(expected.IpFilterPath);
actual.IpFilterTrackers.Should().Be(expected.IpFilterTrackers);
actual.LimitLanPeers.Should().Be(expected.LimitLanPeers);
actual.LimitTcpOverhead.Should().Be(expected.LimitTcpOverhead);
actual.LimitUtpRate.Should().Be(expected.LimitUtpRate);
actual.ListenPort.Should().Be(expected.ListenPort);
actual.SslEnabled.Should().Be(expected.SslEnabled);
actual.SslListenPort.Should().Be(expected.SslListenPort);
actual.Locale.Should().Be(expected.Locale);
actual.Lsd.Should().Be(expected.Lsd);
actual.MailNotificationAuthEnabled.Should().Be(expected.MailNotificationAuthEnabled);
actual.MailNotificationEmail.Should().Be(expected.MailNotificationEmail);
actual.MailNotificationEnabled.Should().Be(expected.MailNotificationEnabled);
actual.MailNotificationPassword.Should().Be(expected.MailNotificationPassword);
actual.MailNotificationSender.Should().Be(expected.MailNotificationSender);
actual.MailNotificationSmtp.Should().Be(expected.MailNotificationSmtp);
actual.MailNotificationSslEnabled.Should().Be(expected.MailNotificationSslEnabled);
actual.MailNotificationUsername.Should().Be(expected.MailNotificationUsername);
actual.MarkOfTheWeb.Should().Be(expected.MarkOfTheWeb);
actual.MaxActiveCheckingTorrents.Should().Be(expected.MaxActiveCheckingTorrents);
actual.MaxActiveDownloads.Should().Be(expected.MaxActiveDownloads);
actual.MaxActiveTorrents.Should().Be(expected.MaxActiveTorrents);
actual.MaxActiveUploads.Should().Be(expected.MaxActiveUploads);
actual.MaxConcurrentHttpAnnounces.Should().Be(expected.MaxConcurrentHttpAnnounces);
actual.MaxConnec.Should().Be(expected.MaxConnec);
actual.MaxConnecPerTorrent.Should().Be(expected.MaxConnecPerTorrent);
actual.MaxInactiveSeedingTime.Should().Be(expected.MaxInactiveSeedingTime);
actual.MaxInactiveSeedingTimeEnabled.Should().Be(expected.MaxInactiveSeedingTimeEnabled);
actual.MaxRatio.Should().Be(expected.MaxRatio);
actual.MaxRatioAct.Should().Be(expected.MaxRatioAct);
actual.MaxRatioEnabled.Should().Be(expected.MaxRatioEnabled);
actual.MaxSeedingTime.Should().Be(expected.MaxSeedingTime);
actual.MaxSeedingTimeEnabled.Should().Be(expected.MaxSeedingTimeEnabled);
actual.MaxUploads.Should().Be(expected.MaxUploads);
actual.MaxUploadsPerTorrent.Should().Be(expected.MaxUploadsPerTorrent);
actual.MemoryWorkingSetLimit.Should().Be(expected.MemoryWorkingSetLimit);
actual.MergeTrackers.Should().Be(expected.MergeTrackers);
actual.OutgoingPortsMax.Should().Be(expected.OutgoingPortsMax);
actual.OutgoingPortsMin.Should().Be(expected.OutgoingPortsMin);
actual.PeerTos.Should().Be(expected.PeerTos);
actual.PeerTurnover.Should().Be(expected.PeerTurnover);
actual.PeerTurnoverCutoff.Should().Be(expected.PeerTurnoverCutoff);
actual.PeerTurnoverInterval.Should().Be(expected.PeerTurnoverInterval);
actual.PerformanceWarning.Should().Be(expected.PerformanceWarning);
actual.Pex.Should().Be(expected.Pex);
actual.PreallocateAll.Should().Be(expected.PreallocateAll);
actual.ProxyAuthEnabled.Should().Be(expected.ProxyAuthEnabled);
actual.ProxyBittorrent.Should().Be(expected.ProxyBittorrent);
actual.ProxyHostnameLookup.Should().Be(expected.ProxyHostnameLookup);
actual.ProxyIp.Should().Be(expected.ProxyIp);
actual.ProxyMisc.Should().Be(expected.ProxyMisc);
actual.ProxyPassword.Should().Be(expected.ProxyPassword);
actual.ProxyPeerConnections.Should().Be(expected.ProxyPeerConnections);
actual.ProxyPort.Should().Be(expected.ProxyPort);
actual.ProxyRss.Should().Be(expected.ProxyRss);
actual.ProxyType.Should().Be(expected.ProxyType);
actual.ProxyUsername.Should().Be(expected.ProxyUsername);
actual.PythonExecutablePath.Should().Be(expected.PythonExecutablePath);
actual.QueueingEnabled.Should().Be(expected.QueueingEnabled);
actual.RandomPort.Should().Be(expected.RandomPort);
actual.ReannounceWhenAddressChanged.Should().Be(expected.ReannounceWhenAddressChanged);
actual.RecheckCompletedTorrents.Should().Be(expected.RecheckCompletedTorrents);
actual.RefreshInterval.Should().Be(expected.RefreshInterval);
actual.RequestQueueSize.Should().Be(expected.RequestQueueSize);
actual.ResolvePeerCountries.Should().Be(expected.ResolvePeerCountries);
actual.ResumeDataStorageType.Should().Be(expected.ResumeDataStorageType);
actual.RssAutoDownloadingEnabled.Should().Be(expected.RssAutoDownloadingEnabled);
actual.RssDownloadRepackProperEpisodes.Should().Be(expected.RssDownloadRepackProperEpisodes);
actual.RssFetchDelay.Should().Be(expected.RssFetchDelay);
actual.RssMaxArticlesPerFeed.Should().Be(expected.RssMaxArticlesPerFeed);
actual.RssProcessingEnabled.Should().Be(expected.RssProcessingEnabled);
actual.RssRefreshInterval.Should().Be(expected.RssRefreshInterval);
actual.RssSmartEpisodeFilters.Should().Be(expected.RssSmartEpisodeFilters);
actual.SavePath.Should().Be(expected.SavePath);
actual.SavePathChangedTmmEnabled.Should().Be(expected.SavePathChangedTmmEnabled);
actual.SaveResumeDataInterval.Should().Be(expected.SaveResumeDataInterval);
actual.SaveStatisticsInterval.Should().Be(expected.SaveStatisticsInterval);
if (expected.ScanDirs is null)
{
actual.ScanDirs.Should().BeNull();
}
else
{
actual.ScanDirs.Should().NotBeNull();
actual.ScanDirs!.Count.Should().Be(expected.ScanDirs.Count);
foreach (var kv in expected.ScanDirs)
{
actual.ScanDirs.Should().ContainKey(kv.Key);
var act = actual.ScanDirs[kv.Key];
var exp = kv.Value;
act.IsWatchedFolder.Should().Be(exp.IsWatchedFolder);
act.IsDefaultFolder.Should().Be(exp.IsDefaultFolder);
act.SavePath.Should().Be(exp.SavePath);
}
}
actual.ScheduleFromHour.Should().Be(expected.ScheduleFromHour);
actual.ScheduleFromMin.Should().Be(expected.ScheduleFromMin);
actual.ScheduleToHour.Should().Be(expected.ScheduleToHour);
actual.ScheduleToMin.Should().Be(expected.ScheduleToMin);
actual.SchedulerDays.Should().Be(expected.SchedulerDays);
actual.SchedulerEnabled.Should().Be(expected.SchedulerEnabled);
actual.SendBufferLowWatermark.Should().Be(expected.SendBufferLowWatermark);
actual.SendBufferWatermark.Should().Be(expected.SendBufferWatermark);
actual.SendBufferWatermarkFactor.Should().Be(expected.SendBufferWatermarkFactor);
actual.SlowTorrentDlRateThreshold.Should().Be(expected.SlowTorrentDlRateThreshold);
actual.SlowTorrentInactiveTimer.Should().Be(expected.SlowTorrentInactiveTimer);
actual.SlowTorrentUlRateThreshold.Should().Be(expected.SlowTorrentUlRateThreshold);
actual.SocketBacklogSize.Should().Be(expected.SocketBacklogSize);
actual.SocketReceiveBufferSize.Should().Be(expected.SocketReceiveBufferSize);
actual.SocketSendBufferSize.Should().Be(expected.SocketSendBufferSize);
actual.SsrfMitigation.Should().Be(expected.SsrfMitigation);
actual.StopTrackerTimeout.Should().Be(expected.StopTrackerTimeout);
actual.TempPath.Should().Be(expected.TempPath);
actual.TempPathEnabled.Should().Be(expected.TempPathEnabled);
actual.TorrentChangedTmmEnabled.Should().Be(expected.TorrentChangedTmmEnabled);
actual.TorrentContentLayout.Should().Be(expected.TorrentContentLayout);
actual.TorrentContentRemoveOption.Should().Be(expected.TorrentContentRemoveOption);
actual.TorrentFileSizeLimit.Should().Be(expected.TorrentFileSizeLimit);
actual.TorrentStopCondition.Should().Be(expected.TorrentStopCondition);
actual.UpLimit.Should().Be(expected.UpLimit);
actual.UploadChokingAlgorithm.Should().Be(expected.UploadChokingAlgorithm);
actual.UploadSlotsBehavior.Should().Be(expected.UploadSlotsBehavior);
actual.Upnp.Should().Be(expected.Upnp);
actual.UpnpLeaseDuration.Should().Be(expected.UpnpLeaseDuration);
actual.UseCategoryPathsInManualMode.Should().Be(expected.UseCategoryPathsInManualMode);
actual.UseHttps.Should().Be(expected.UseHttps);
actual.IgnoreSslErrors.Should().Be(expected.IgnoreSslErrors);
actual.UseSubcategories.Should().Be(expected.UseSubcategories);
actual.UtpTcpMixedMode.Should().Be(expected.UtpTcpMixedMode);
actual.ValidateHttpsTrackerCertificate.Should().Be(expected.ValidateHttpsTrackerCertificate);
actual.WebUiAddress.Should().Be(expected.WebUiAddress);
actual.WebUiApiKey.Should().Be(expected.WebUiApiKey);
actual.WebUiBanDuration.Should().Be(expected.WebUiBanDuration);
actual.WebUiClickjackingProtectionEnabled.Should().Be(expected.WebUiClickjackingProtectionEnabled);
actual.WebUiCsrfProtectionEnabled.Should().Be(expected.WebUiCsrfProtectionEnabled);
actual.WebUiCustomHttpHeaders.Should().Be(expected.WebUiCustomHttpHeaders);
actual.WebUiDomainList.Should().Be(expected.WebUiDomainList);
actual.WebUiHostHeaderValidationEnabled.Should().Be(expected.WebUiHostHeaderValidationEnabled);
actual.WebUiHttpsCertPath.Should().Be(expected.WebUiHttpsCertPath);
actual.WebUiHttpsKeyPath.Should().Be(expected.WebUiHttpsKeyPath);
actual.WebUiMaxAuthFailCount.Should().Be(expected.WebUiMaxAuthFailCount);
actual.WebUiPort.Should().Be(expected.WebUiPort);
actual.WebUiReverseProxiesList.Should().Be(expected.WebUiReverseProxiesList);
actual.WebUiReverseProxyEnabled.Should().Be(expected.WebUiReverseProxyEnabled);
actual.WebUiSecureCookieEnabled.Should().Be(expected.WebUiSecureCookieEnabled);
actual.WebUiSessionTimeout.Should().Be(expected.WebUiSessionTimeout);
actual.WebUiUpnp.Should().Be(expected.WebUiUpnp);
actual.WebUiUseCustomHttpHeadersEnabled.Should().Be(expected.WebUiUseCustomHttpHeadersEnabled);
actual.WebUiUsername.Should().Be(expected.WebUiUsername);
actual.WebUiPassword.Should().Be(expected.WebUiPassword);
actual.ConfirmTorrentDeletion.Should().Be(expected.ConfirmTorrentDeletion);
actual.ConfirmTorrentRecheck.Should().Be(expected.ConfirmTorrentRecheck);
actual.StatusBarExternalIp.Should().Be(expected.StatusBarExternalIp);
}
// ---------- Tests ----------
[Fact]
public void GIVEN_NullOriginal_AND_ChangedHasAllNonNullValues_WHEN_MergePreferences_THEN_AllFieldsCopied()
{
var changed = BuildAllSetB_AllNonNull();
var result = _target.MergePreferences(null, changed);
AssertAllEqual(result, changed);
}
[Fact]
public void GIVEN_OriginalExists_AND_ChangedHasAllNonNullValues_WHEN_MergePreferences_THEN_AllFieldsOverwritten()
{
var original = BuildAllSetA();
var changed = BuildAllSetB_AllNonNull();
var result = _target.MergePreferences(original, changed);
AssertAllEqual(result, changed);
result.ScanDirs.Should().BeSameAs(changed.ScanDirs);
}
[Fact]
public void GIVEN_OriginalExists_AND_ChangedHasAllNullValues_WHEN_MergePreferences_THEN_AllOriginalRetained()
{
var original = BuildAllSetA();
var changed = new UpdatePreferences(); // everything null
var result = _target.MergePreferences(original, changed);
AssertAllEqual(result, original);
}
[Fact]
public void GIVEN_OriginalExists_AND_ChangedHasMixOfNullAndNonNull_WHEN_MergePreferences_THEN_OnlyNonNullOverwrite()
{
var original = BuildAllSetA();
var changed = BuildPartialChange();
var result = _target.MergePreferences(original, changed);
// Build expected by starting from A, then applying only the non-null fields we set in BuildPartialChange()
var expected = BuildAllSetA();
expected.AddToTopOfQueue = false;
expected.AltDlLimit = 222;
expected.SavePath = "/save-partial";
expected.MaxRatio = 3.3f;
// NOTE: MaxRatioEnabled remains as in A (null) because changed.MaxRatioEnabled is null.
expected.ProxyIp = "proxy-new";
expected.TempPathEnabled = false;
expected.WebUiPort = 9090;
expected.RssFetchDelay = 7777L;
expected.ScanDirs = new Dictionary<string, SaveLocation>
{
["watch"] = SaveLocation.Create(0),
["custom"] = SaveLocation.Create("/new/custom")
};
AssertAllEqual(result, expected);
}
// ---------- Validate() rule tests ----------
[Fact]
public void GIVEN_MaxRatioAndMaxRatioEnabledBothSet_WHEN_Validate_THEN_Throws()
{
var p = new UpdatePreferences { MaxRatio = 1.0f, MaxRatioEnabled = true };
Action act = () => p.Validate();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*max_ratio or max_ratio_enabled*");
}
[Fact]
public void GIVEN_MaxSeedingTimeAndMaxSeedingTimeEnabledBothSet_WHEN_Validate_THEN_Throws()
{
var p = new UpdatePreferences { MaxSeedingTime = 10, MaxSeedingTimeEnabled = true };
Action act = () => p.Validate();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*max_seeding_time or max_seeding_time_enabled*");
}
[Fact]
public void GIVEN_MaxInactiveSeedingTimeAndEnabledBothSet_WHEN_Validate_THEN_Throws()
{
var p = new UpdatePreferences { MaxInactiveSeedingTime = 10, MaxInactiveSeedingTimeEnabled = true };
Action act = () => p.Validate();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*max_inactive_seeding_time or max_inactive_seeding_time_enabled*");
}
}
}

View File

@@ -0,0 +1,301 @@
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
using Lantean.QBTMud.Services;
namespace Lantean.QBTMud.Test.Services
{
public class RssDataManagerTests
{
private readonly RssDataManager _target = new RssDataManager();
[Fact]
public void GIVEN_MultipleFeedsWithAndWithoutArticles_WHEN_CreateRssList_THEN_MetaCountsAndFlatteningCorrect()
{
// arrange
var items = new Dictionary<string, RssItem>
{
// key != uid on purpose in another test; here keep them equal
["feed-a"] = new RssItem(
articles: new[]
{
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "cat-1", comments: "c1", date: "2025-01-01",
description: "d1", id: "a1", link: "http://a/1",
thumbnail: "http://a/t1", title: "t1",
torrentURL: "http://a.torrent/1", isRead: false),
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "cat-2", comments: "c2", date: "2025-01-02",
description: "d2", id: "a2", link: "http://a/2",
thumbnail: "http://a/t2", title: "t2",
torrentURL: "http://a.torrent/2", isRead: true),
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "cat-3", comments: "c3", date: "2025-01-03",
description: "d3", id: "a3", link: "http://a/3",
thumbnail: "http://a/t3", title: "t3",
torrentURL: "http://a.torrent/3", isRead: false),
},
hasError: false,
isLoading: false,
lastBuildDate: "2025-01-05",
title: "Feed A",
uid: "feed-a",
url: "http://feed/a"),
// feed with one read article
["feed-b"] = new RssItem(
articles: new[]
{
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "movies", comments: null, date: "2025-02-01",
description: null, id: "b1", link: "http://b/1",
thumbnail: null, title: "m1",
torrentURL: "http://b.torrent/1", isRead: true),
},
hasError: true,
isLoading: true,
lastBuildDate: "2025-02-02",
title: "Feed B",
uid: "feed-b",
url: "http://feed/b"),
// feed with null article list -> should create feed only
["feed-c"] = new RssItem(
articles: null,
hasError: false,
isLoading: false,
lastBuildDate: "2025-03-03",
title: "Feed C",
uid: "feed-c",
url: "http://feed/c"),
};
// act
var result = _target.CreateRssList(items);
// assert: feeds exist
result.Feeds.Keys.Should().BeEquivalentTo(new[] { "feed-a", "feed-b", "feed-c" });
// feed-a meta and counts
var fa = result.Feeds["feed-a"];
fa.Uid.Should().Be("feed-a");
fa.Url.Should().Be("http://feed/a");
fa.Title.Should().Be("Feed A");
fa.LastBuildDate.Should().Be("2025-01-05");
fa.HasError.Should().BeFalse();
fa.IsLoading.Should().BeFalse();
fa.ArticleCount.Should().Be(3);
fa.UnreadCount.Should().Be(2); // two IsRead=false in feed-a
// feed-b meta and counts
var fb = result.Feeds["feed-b"];
fb.Uid.Should().Be("feed-b");
fb.Url.Should().Be("http://feed/b");
fb.Title.Should().Be("Feed B");
fb.LastBuildDate.Should().Be("2025-02-02");
fb.HasError.Should().BeTrue();
fb.IsLoading.Should().BeTrue();
fb.ArticleCount.Should().Be(1);
fb.UnreadCount.Should().Be(0);
// feed-c meta and counts
var fc = result.Feeds["feed-c"];
fc.Uid.Should().Be("feed-c");
fc.Url.Should().Be("http://feed/c");
fc.Title.Should().Be("Feed C");
fc.LastBuildDate.Should().Be("2025-03-03");
fc.HasError.Should().BeFalse();
fc.IsLoading.Should().BeFalse();
fc.ArticleCount.Should().Be(0);
fc.UnreadCount.Should().Be(0);
// articles flattened correctly (3 + 1)
result.Articles.Count.Should().Be(4);
result.Articles.Any(a => a.Feed == "feed-c").Should().BeFalse(); // none from 'c'
// total unread
result.UnreadCount.Should().Be(2);
}
[Fact]
public void GIVEN_ClientArticleFieldsNulls_WHEN_CreateRssList_THEN_NullsPropagateWithoutNormalization()
{
// arrange: provide nulls in fields (the implementation uses '!' but does not normalize)
var items = new Dictionary<string, RssItem>
{
["feed-null"] = new RssItem(
articles: new[]
{
new Lantean.QBitTorrentClient.Models.RssArticle(
category: null, comments: null, date: null,
description: null, id: null, link: null,
thumbnail: null, title: null,
torrentURL: null, isRead: false),
},
hasError: false,
isLoading: false,
lastBuildDate: null,
title: null,
uid: "uid-null",
url: "http://feed/null"),
};
// act
var result = _target.CreateRssList(items);
// assert feed meta
result.Feeds.Count.Should().Be(1);
var f = result.Feeds["feed-null"];
f.Uid.Should().Be("uid-null");
f.Url.Should().Be("http://feed/null");
f.Title.Should().BeNull();
f.LastBuildDate.Should().BeNull();
f.ArticleCount.Should().Be(1);
f.UnreadCount.Should().Be(1);
// assert article null propagation (no normalization to empty string)
var art = result.Articles.Single();
art.Feed.Should().Be("feed-null"); // dictionary key is used as article.Feed
art.Category.Should().BeNull();
art.Comments.Should().BeNull();
art.Date.Should().BeNull();
art.Description.Should().BeNull();
art.Id.Should().BeNull();
art.Link.Should().BeNull();
art.Thumbnail.Should().BeNull();
art.Title.Should().BeNull();
art.TorrentURL.Should().BeNull();
art.IsRead.Should().BeFalse();
}
[Fact]
public void GIVEN_UidDiffersFromDictionaryKey_WHEN_CreateRssList_THEN_ArticleFeedUsesKey_And_FeedUidUsesItemUid()
{
// arrange: key != uid
var items = new Dictionary<string, RssItem>
{
["dict-key"] = new RssItem(
articles: new[]
{
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "x", comments: "c", date: "d",
description: "desc", id: "id1", link: "l",
thumbnail: "t", title: "title",
torrentURL: "u", isRead: true),
},
hasError: false,
isLoading: false,
lastBuildDate: "lb",
title: "T",
uid: "uid-different",
url: "http://u"),
};
// act
var result = _target.CreateRssList(items);
// assert: feed uid == item.Uid; article.Feed == dictionary key
var feed = result.Feeds["dict-key"];
feed.Uid.Should().Be("uid-different");
var art = result.Articles.Single();
art.Feed.Should().Be("dict-key");
}
[Fact]
public void GIVEN_RssListWithUnread_WHEN_MarkAllUnreadAsRead_THEN_AllFeedUnreadZero_And_TotalZero()
{
// arrange
var items = new Dictionary<string, RssItem>
{
["fa"] = new RssItem(
articles: new[]
{
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "c", comments: "c", date: "d1",
description: "d", id: "a1", link: "l",
thumbnail: "t", title: "t",
torrentURL: "u", isRead: false),
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "c", comments: "c", date: "d2",
description: "d", id: "a2", link: "l",
thumbnail: "t", title: "t",
torrentURL: "u", isRead: false),
},
hasError: false, isLoading: false,
lastBuildDate: "lb", title: "TA", uid: "fa", url: "http://fa"),
["fb"] = new RssItem(
articles: new[]
{
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "c", comments: "c", date: "d3",
description: "d", id: "b1", link: "l",
thumbnail: "t", title: "t",
torrentURL: "u", isRead: true),
},
hasError: false, isLoading: false,
lastBuildDate: "lb", title: "TB", uid: "fb", url: "http://fb"),
};
var list = _target.CreateRssList(items);
list.UnreadCount.Should().Be(2);
// act
list.MarkAllUnreadAsRead();
// assert
list.UnreadCount.Should().Be(0);
foreach (var f in list.Feeds.Values)
{
f.UnreadCount.Should().Be(0);
}
}
[Fact]
public void GIVEN_RssListWithMultipleFeeds_WHEN_MarkAsUnread_ForSpecificFeed_THEN_OnlyThatFeedZeroed()
{
// arrange
var items = new Dictionary<string, RssItem>
{
["fa"] = new RssItem(
articles: new[]
{
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "c", comments: "c", date: "d1",
description: "d", id: "a1", link: "l",
thumbnail: "t", title: "t",
torrentURL: "u", isRead: false),
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "c", comments: "c", date: "d2",
description: "d", id: "a2", link: "l",
thumbnail: "t", title: "t",
torrentURL: "u", isRead: false),
},
hasError: false, isLoading: false,
lastBuildDate: "lb", title: "TA", uid: "fa", url: "http://fa"),
["fb"] = new RssItem(
articles: new[]
{
new Lantean.QBitTorrentClient.Models.RssArticle(
category: "c", comments: "c", date: "d3",
description: "d", id: "b1", link: "l",
thumbnail: "t", title: "t",
torrentURL: "u", isRead: false),
},
hasError: false, isLoading: false,
lastBuildDate: "lb", title: "TB", uid: "fb", url: "http://fb"),
};
var list = _target.CreateRssList(items);
list.UnreadCount.Should().Be(3);
list.Feeds["fa"].UnreadCount.Should().Be(2);
list.Feeds["fb"].UnreadCount.Should().Be(1);
// act
list.MarkAsUnread("fa"); // per implementation: zeroes the feed's unread count
// assert
list.Feeds["fa"].UnreadCount.Should().Be(0);
list.Feeds["fb"].UnreadCount.Should().Be(1);
list.UnreadCount.Should().Be(1);
}
}
}

View File

@@ -0,0 +1,394 @@
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
using Lantean.QBTMud.Models;
using Lantean.QBTMud.Services;
using MudPriority = Lantean.QBTMud.Models.Priority;
using QbtPriority = Lantean.QBitTorrentClient.Models.Priority;
namespace Lantean.QBTMud.Test.Services
{
public class TorrentDataManagerContentsListTests
{
private readonly TorrentDataManager _target = new TorrentDataManager();
// ---------------------------
// CreateContentsList tests
// ---------------------------
[Fact]
public void GIVEN_NoFiles_WHEN_CreateContentsList_THEN_ReturnsEmpty()
{
// arrange
var files = Array.Empty<FileData>();
// act
var result = _target.CreateContentsList(files);
// assert
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public void GIVEN_SingleRootFile_WHEN_CreateContentsList_THEN_CreatesSingleFileNode()
{
// arrange
var files = new[]
{
new FileData(
index: 5,
name: "file1.mkv",
size: 100,
progress: 0.5f,
priority: QbtPriority.Normal,
isSeed: false,
pieceRange: Array.Empty<int>(),
availability: 3.2f)
};
// act
var result = _target.CreateContentsList(files);
// assert
result.Count.Should().Be(1);
result.ContainsKey("file1.mkv").Should().BeTrue();
var file = result["file1.mkv"];
file.IsFolder.Should().BeFalse();
file.Name.Should().Be("file1.mkv");
file.DisplayName.Should().Be("file1.mkv");
file.Index.Should().Be(5);
file.Path.Should().Be(""); // root
file.Level.Should().Be(0);
file.Size.Should().Be(100);
file.Progress.Should().Be(0.5f);
file.Availability.Should().Be(3.2f);
file.Priority.Should().Be(MudPriority.Normal);
}
[Fact]
public void GIVEN_NestedFiles_AND_UnwantedFolder_WHEN_CreateContentsList_THEN_Skips_Unwanted_And_Aggregates()
{
// arrange
var files = new[]
{
// ".unwanted" folder is skipped as a directory, but the file remains under "a"
new FileData(10, "a/.unwanted/skip.bin", 10, 0.8f, QbtPriority.Normal, false, Array.Empty<int>(), 2.0f),
new FileData(11, "a/b/c1.txt", 30, 0.4f, QbtPriority.High, false, Array.Empty<int>(), 1.0f),
new FileData(12, "a/b/c2.txt", 70, 0.9f, QbtPriority.DoNotDownload, false, Array.Empty<int>(), 1.5f),
};
// act
var result = _target.CreateContentsList(files);
// assert: keys present
result.ContainsKey("a").Should().BeTrue();
result.ContainsKey("a/b").Should().BeTrue();
result.ContainsKey("a/.unwanted/skip.bin").Should().BeTrue();
result.ContainsKey("a/b/c1.txt").Should().BeTrue();
result.ContainsKey("a/b/c2.txt").Should().BeTrue();
// NOTE: CreateContentsList aggregates using TOTAL size as denominator (not "active" size).
// For "a/b":
// size = 30 + 70 = 100
// progressSum = 0.4*30 (DND child excluded from sum) = 12
// availabilitySum = 1.0*30 = 30
// => progress = 12 / 100 = 0.12
// => availability = 30 / 100 = 0.3
// priority = Mixed (High vs DoNotDownload)
var ab = result["a/b"];
ab.IsFolder.Should().BeTrue();
ab.Level.Should().Be(1);
ab.Size.Should().Be(100);
ab.Progress.Should().BeApproximately(0.12f, 1e-6f);
ab.Availability.Should().BeApproximately(0.3f, 1e-6f);
ab.Priority.Should().Be(MudPriority.Mixed);
// For "a":
// children: "a/.unwanted/skip.bin" (size=10, p=0.8, avail=2.0, Normal)
// "a/b" (size=100, p=0.12, avail=0.3, Mixed)
// size = 110
// progressSum = 0.8*10 + 0.12*100 = 8 + 12 = 20 => progress = 20/110 ≈ 0.181818...
// availabilitySum = 2.0*10 + 0.3*100 = 20 + 30 = 50 => availability = 50/110 ≈ 0.454545...
// priority = Mixed (Normal vs Mixed)
var a = result["a"];
a.IsFolder.Should().BeTrue();
a.Level.Should().Be(0);
a.Size.Should().Be(110);
a.Progress.Should().BeApproximately(20f / 110f, 1e-6f);
a.Availability.Should().BeApproximately(50f / 110f, 1e-6f);
a.Priority.Should().Be(MudPriority.Mixed);
// folder indices are less than min file index (10); deeper folder created later => smaller index
a.Index.Should().BeLessThan(10);
ab.Index.Should().BeLessThan(10);
ab.Index.Should().BeLessThan(a.Index);
}
[Fact]
public void GIVEN_AllChildrenDoNotDownload_WHEN_CreateContentsList_THEN_FolderProgressAndAvailabilityAreZero_AndPriorityDND()
{
// arrange
var files = new[]
{
new FileData(4, "d/x.bin", 50, 0.5f, QbtPriority.DoNotDownload, false, Array.Empty<int>(), 0.9f),
new FileData(5, "d/y.bin", 50, 1.2f, QbtPriority.DoNotDownload, false, Array.Empty<int>(), 1.1f)
};
// act
var result = _target.CreateContentsList(files);
// assert
result.ContainsKey("d").Should().BeTrue();
var d = result["d"];
d.IsFolder.Should().BeTrue();
d.Size.Should().Be(100);
d.Progress.Should().Be(0f); // activeSize == 0
d.Availability.Should().Be(0f); // activeSize == 0
d.Priority.Should().Be(MudPriority.DoNotDownload);
}
// ---------------------------
// MergeContentsList tests
// ---------------------------
[Fact]
public void GIVEN_NoFiles_AND_EmptyContents_WHEN_MergeContentsList_THEN_ReturnsFalseAndNoChange()
{
// arrange
var files = Array.Empty<FileData>();
var contents = new Dictionary<string, ContentItem>();
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeFalse();
contents.Count.Should().Be(0);
}
[Fact]
public void GIVEN_NoFiles_AND_ExistingContents_WHEN_MergeContentsList_THEN_ClearsAndReturnsTrue()
{
// arrange
var contents = new Dictionary<string, ContentItem>
{
["folder"] = new ContentItem("folder", "folder", -1, MudPriority.Normal, 0, 0, 0, true, 0),
["folder/file.txt"] = new ContentItem("folder/file.txt", "file.txt", 10, MudPriority.Normal, 0.5f, 100, 1.0f, false, 1)
};
// act
var changed = _target.MergeContentsList(Array.Empty<FileData>(), contents);
// assert
changed.Should().BeTrue();
contents.Count.Should().Be(0);
}
[Fact]
public void GIVEN_NewFilesOnly_WHEN_MergeContentsList_THEN_AddsFilesAndDirectoriesAndAggregates()
{
// arrange
var files = new[]
{
new FileData(7, "root1.txt", 10, 0.6f, QbtPriority.Normal, false, Array.Empty<int>(), 2.0f),
new FileData(8, "folder1/file1.bin", 30, 0.4f, QbtPriority.High, false, Array.Empty<int>(), 1.0f),
new FileData(9, "folder1/file2.bin", 70, 0.9f, QbtPriority.DoNotDownload, false, Array.Empty<int>(), 1.5f)
};
var contents = new Dictionary<string, ContentItem>();
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
// keys present
contents.ContainsKey("root1.txt").Should().BeTrue();
contents.ContainsKey("folder1").Should().BeTrue();
contents.ContainsKey("folder1/file1.bin").Should().BeTrue();
contents.ContainsKey("folder1/file2.bin").Should().BeTrue();
// directory indexes should be < min file index (7)
contents["folder1"].Index.Should().BeLessThan(7);
// aggregation on folder1 (same math as earlier)
var folder = contents["folder1"];
folder.IsFolder.Should().BeTrue();
folder.Size.Should().Be(100);
folder.Progress.Should().BeApproximately(0.4f, 1e-6f);
folder.Availability.Should().BeApproximately(1.0f, 1e-6f);
folder.Priority.Should().Be(MudPriority.Mixed);
}
[Fact]
public void GIVEN_ExistingFile_WHEN_MergeContentsList_WithChanges_THEN_UpdatesInPlaceAndReturnsTrue()
{
// arrange
var contents = new Dictionary<string, ContentItem>
{
// pre-existing directory with expected rollup for the prior file state
["folder"] = new ContentItem("folder", "folder", -100, MudPriority.Normal, 0.50f, 100, 1.20f, true, 0),
// existing file which will be updated (progress delta > tolerance, size changed, availability delta > tolerance)
["folder/file.txt"] = new ContentItem("folder/file.txt", "file.txt", 10, MudPriority.Normal, 0.50f, 100, 1.20f, false, 1),
// an extra entry that should be removed (not seen)
["old.bin"] = new ContentItem("old.bin", "old.bin", 5, MudPriority.Normal, 0.1f, 10, 1.0f, false, 0),
};
var files = new[]
{
// same path but changed values
new FileData(10, "folder/file.txt", 120, 0.5003f /* > tol from 0.50 by 0.0003 */, QbtPriority.Normal, false, Array.Empty<int>(), 1.2003f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
contents.ContainsKey("folder/file.txt").Should().BeTrue();
contents.ContainsKey("folder").Should().BeTrue();
contents.ContainsKey("old.bin").Should().BeFalse(); // removed
var file = contents["folder/file.txt"];
file.Size.Should().Be(120);
file.Progress.Should().Be(0.5003f);
file.Availability.Should().Be(1.2003f);
// folder roll-up should update too:
var folder = contents["folder"];
folder.Size.Should().Be(120);
folder.Progress.Should().BeApproximately(0.5003f, 1e-6f);
folder.Availability.Should().BeApproximately(1.2003f, 1e-6f);
}
[Fact]
public void GIVEN_ExistingItemsWithNoMaterialChangeWithinTolerance_WHEN_MergeContentsList_THEN_ReturnsFalse()
{
// arrange
// file defines a folder rollup: size 100, progress 0.5000, availability 1.2000, Normal
var contents = new Dictionary<string, ContentItem>
{
["folder"] = new ContentItem("folder", "folder", -1, MudPriority.Normal, 0.5000f, 100, 1.2000f, true, 0),
["folder/file.txt"] = new ContentItem("folder/file.txt", "file.txt", 10, MudPriority.Normal, 0.5000f, 100, 1.2000f, false, 1)
};
var files = new[]
{
// diffs are within tolerance (0.0001f) -> should NOT update
new FileData(10, "folder/file.txt", 100, 0.50005f, QbtPriority.Normal, false, Array.Empty<int>(), 1.20005f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeFalse();
// nothing should have moved
contents["folder/file.txt"].Progress.Should().Be(0.5000f);
contents["folder/file.txt"].Availability.Should().Be(1.2000f);
contents["folder/file.txt"].Size.Should().Be(100);
contents["folder"].Progress.Should().Be(0.5000f);
contents["folder"].Availability.Should().Be(1.2000f);
contents["folder"].Size.Should().Be(100);
}
[Fact]
public void GIVEN_FileUnderUnwantedDirectory_WHEN_MergeContentsList_THEN_CreatesParentButSkipsUnwantedFolder()
{
// arrange
var contents = new Dictionary<string, ContentItem>();
var files = new[]
{
new FileData(20, "x/.unwanted/y.bin", 10, 0.8f, QbtPriority.Normal, false, Array.Empty<int>(), 2.0f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
contents.ContainsKey("x").Should().BeTrue();
contents.ContainsKey("x/.unwanted/y.bin").Should().BeTrue();
contents.Keys.Any(k => k.Contains(".unwanted") && k.EndsWith('/')).Should().BeFalse(); // no explicit ".unwanted" directory key
}
[Fact]
public void GIVEN_ProgressAboveOne_WHEN_MergeContentsList_THEN_DirectoryProgressIsClampedToOne()
{
// arrange
var contents = new Dictionary<string, ContentItem>();
var files = new[]
{
new FileData(2, "c/fileA", 10, 2.5f /* > 1 */, QbtPriority.Normal, false, Array.Empty<int>(), 3.0f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
var dir = contents["c"];
dir.Progress.Should().Be(1.0f); // clamped
dir.Size.Should().Be(10);
}
[Fact]
public void GIVEN_ProgressBelowZero_WHEN_MergeContentsList_THEN_DirectoryProgressIsClampedToZero()
{
// arrange
var contents = new Dictionary<string, ContentItem>();
var files = new[]
{
new FileData(3, "d/fileB", 10, -0.5f /* < 0 */, QbtPriority.Normal, false, Array.Empty<int>(), 3.0f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
var dir = contents["d"];
dir.Progress.Should().Be(0.0f); // clamped
dir.Size.Should().Be(10);
}
[Fact]
public void GIVEN_ExistingDirectories_THEN_NewFolderIndicesAreLessThanMinOfExistingAndIncoming()
{
// arrange
// existing directory with index -2 and an existing independent file with index 50
var contents = new Dictionary<string, ContentItem>
{
["exist"] = new ContentItem("exist", "exist", -2, MudPriority.Normal, 0f, 0L, 0f, true, 0),
["exist/keep.txt"] = new ContentItem("exist/keep.txt", "keep.txt", 50, MudPriority.Normal, 0.1f, 1L, 0.2f, false, 1)
};
// incoming files have min index 5
var files = new[]
{
new FileData(5, "new/f1", 10, 0.3f, QbtPriority.Normal, false, Array.Empty<int>(), 1.0f),
new FileData(9, "new/f2", 15, 0.6f, QbtPriority.Normal, false, Array.Empty<int>(), 1.0f),
new FileData(11, "exist/keep.txt", 1, 0.1f, QbtPriority.Normal, false, Array.Empty<int>(), 0.2f) // matches existing
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
contents.ContainsKey("new").Should().BeTrue();
var minExistingIndex = contents.Values.Where(c => !c.IsFolder).Select(c => c.Index).DefaultIfEmpty(int.MaxValue).Min();
var newFolder = contents["new"];
newFolder.IsFolder.Should().BeTrue();
// index is computed from nextFolderIndex = Min(minExistingIndex, minFileIndex) - 1
// minExistingIndex (files) = min(50, 11) = 11 → min with minFileIndex(=5) => 5, so folder index < 5
newFolder.Index.Should().BeLessThan(5);
}
}
}

View File

@@ -0,0 +1,552 @@
using AwesomeAssertions;
using Lantean.QBTMud.Helpers;
using Lantean.QBTMud.Models;
using Lantean.QBTMud.Services;
using Client = Lantean.QBitTorrentClient.Models;
namespace Lantean.QBTMud.Test.Services
{
public class TorrentDataManagerMainDataTests
{
private readonly TorrentDataManager _target = new TorrentDataManager();
// -------------------- CreateMainData --------------------
[Fact]
public void GIVEN_EmptyInput_WHEN_CreateMainData_THEN_EmptyCollections_And_DefaultStates()
{
var client = new Client.MainData(
responseId: 1,
fullUpdate: true,
torrents: null,
torrentsRemoved: null,
categories: null,
categoriesRemoved: null,
tags: null,
tagsRemoved: null,
trackers: new Dictionary<string, IReadOnlyList<string>>(),
trackersRemoved: null,
serverState: null);
var result = _target.CreateMainData(client);
result.Torrents.Should().BeEmpty();
result.Tags.Should().BeEmpty();
result.Categories.Should().BeEmpty();
result.Trackers.Should().BeEmpty();
// Default from app GlobalTransferInfo() is "Unknown"
result.ServerState.ConnectionStatus.Should().Be("Unknown");
result.ServerState.UseSubcategories.Should().BeFalse();
result.TagState.Should().ContainKeys(FilterHelper.TAG_ALL, FilterHelper.TAG_UNTAGGED);
result.TagState[FilterHelper.TAG_ALL].Should().BeEmpty();
result.TagState[FilterHelper.TAG_UNTAGGED].Should().BeEmpty();
result.CategoriesState.Should().ContainKeys(FilterHelper.CATEGORY_ALL, FilterHelper.CATEGORY_UNCATEGORIZED);
result.CategoriesState[FilterHelper.CATEGORY_ALL].Should().BeEmpty();
result.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].Should().BeEmpty();
foreach (var s in Enum.GetValues<Status>())
{
result.StatusState.Should().ContainKey(s.ToString());
result.StatusState[s.ToString()].Should().BeEmpty();
}
result.TrackersState.Should().ContainKeys(FilterHelper.TRACKER_ALL, FilterHelper.TRACKER_TRACKERLESS);
result.TrackersState[FilterHelper.TRACKER_ALL].Should().BeEmpty();
result.TrackersState[FilterHelper.TRACKER_TRACKERLESS].Should().BeEmpty();
}
[Fact]
public void GIVEN_PopulatedInput_WHEN_CreateMainData_THEN_Maps_All_And_Builds_FilterStates()
{
var hash = "abc123";
var clientTorrent = new Client.Torrent
{
Name = "Movie A",
State = "downloading",
UploadSpeed = 0,
Category = "Movies/HD",
Tags = new[] { " tagA\tignored", "", "tagB" },
Tracker = "udp://tracker1",
AddedOn = 111,
AmountLeft = 1,
AutomaticTorrentManagement = true,
Availability = 1.0f,
Completed = 0,
CompletionOn = 0,
ContentPath = "/content",
DownloadLimit = 0,
DownloadSpeed = 1000,
Downloaded = 200,
DownloadedSession = 50,
EstimatedTimeOfArrival = 100,
FirstLastPiecePriority = false,
ForceStart = false,
InfoHashV1 = "v1",
InfoHashV2 = "v2",
LastActivity = 1,
MagnetUri = "magnet:?xt",
MaxRatio = 9.9f,
MaxSeedingTime = 0,
NumberComplete = 1,
NumberIncomplete = 1,
NumberLeeches = 0,
NumberSeeds = 5,
Priority = 1,
Progress = 0.2f,
Ratio = 0.1f,
RatioLimit = 0,
SavePath = "/save",
SeedingTime = 0,
SeedingTimeLimit = 0,
SeenComplete = 0,
SequentialDownload = false,
Size = 1000,
SuperSeeding = false,
TimeActive = 10,
TotalSize = 1000,
UploadLimit = 0,
Uploaded = 0,
UploadedSession = 0,
Reannounce = 0,
InactiveSeedingTimeLimit = 0,
MaxInactiveSeedingTime = 0,
Popularity = 0,
DownloadPath = "/dl",
RootPath = "/root",
IsPrivate = false,
ShareLimitAction = Client.ShareLimitAction.Default,
Comment = "c"
};
var torrents = new Dictionary<string, Client.Torrent> { [hash] = clientTorrent };
var categories = new Dictionary<string, Client.Category>
{
["Movies"] = new Client.Category("Movies", "/movies", downloadPath: null),
["Movies/HD"] = new Client.Category("Movies/HD", "/movies/hd", downloadPath: null)
};
var trackers = new Dictionary<string, IReadOnlyList<string>>
{
["udp://tracker1"] = new List<string> { hash }
};
var clientServer = new Client.ServerState(
allTimeDownloaded: 10,
allTimeUploaded: 20,
averageTimeQueue: 30,
connectionStatus: "connected",
dHTNodes: 2,
downloadInfoData: 3,
downloadInfoSpeed: 4,
downloadRateLimit: 5,
freeSpaceOnDisk: 6,
globalRatio: 7.5f,
queuedIOJobs: 8,
queuing: true,
readCacheHits: 9.1f,
readCacheOverload: 10.2f,
refreshInterval: 11,
totalBuffersSize: 12,
totalPeerConnections: 13,
totalQueuedSize: 14,
totalWastedSession: 15,
uploadInfoData: 16,
uploadInfoSpeed: 17,
uploadRateLimit: 18,
useAltSpeedLimits: false,
useSubcategories: true,
writeCacheOverload: 19.3f,
lastExternalAddressV4: "1.2.3.4",
lastExternalAddressV6: "2001::1");
var client = new Client.MainData(
responseId: 2,
fullUpdate: true,
torrents: torrents,
torrentsRemoved: null,
categories: categories,
categoriesRemoved: null,
tags: new[] { " tagA", "tagA", "tagB", "" },
tagsRemoved: null,
trackers: trackers,
trackersRemoved: null,
serverState: clientServer);
var result = _target.CreateMainData(client);
result.Torrents.Should().ContainKey(hash);
var mapped = result.Torrents[hash];
mapped.Name.Should().Be("Movie A");
mapped.Category.Should().Be("Movies/HD");
mapped.Tags.Should().BeEquivalentTo(new[] { "tagA", "tagB" }, o => o.WithoutStrictOrdering());
mapped.Tracker.Should().Be("udp://tracker1");
result.TagState[FilterHelper.TAG_ALL].Should().Contain(hash);
result.TagState[FilterHelper.TAG_UNTAGGED].Should().NotContain(hash);
result.TagState["tagA"].Should().Contain(hash);
result.TagState["tagB"].Should().Contain(hash);
result.CategoriesState[FilterHelper.CATEGORY_ALL].Should().Contain(hash);
result.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].Should().NotContain(hash);
result.CategoriesState["Movies/HD"].Should().Contain(hash);
result.CategoriesState["Movies"].Should().Contain(hash);
result.TrackersState[FilterHelper.TRACKER_ALL].Should().Contain(hash);
result.TrackersState[FilterHelper.TRACKER_TRACKERLESS].Should().NotContain(hash);
result.TrackersState["udp://tracker1"].Should().Contain(hash);
result.StatusState[nameof(Status.Downloading)].Should().Contain(hash);
result.StatusState[nameof(Status.Active)].Should().Contain(hash);
result.StatusState[nameof(Status.Inactive)].Should().NotContain(hash);
result.StatusState[nameof(Status.Stalled)].Should().NotContain(hash);
result.ServerState.ConnectionStatus.Should().Be("connected");
result.ServerState.UseSubcategories.Should().BeTrue();
result.ServerState.LastExternalAddressV4.Should().Be("1.2.3.4");
result.ServerState.LastExternalAddressV6.Should().Be("2001::1");
result.ServerState.GlobalRatio.Should().Be(7.5f);
}
// -------------------- MergeMainData: removals --------------------
[Fact]
public void GIVEN_ExistingData_WHEN_Merge_Removals_THEN_ItemsAndStateAreRemoved_And_Flagged()
{
var hash = "h1";
var clientTorrent = new Client.Torrent
{
Name = "T1",
State = "downloading",
UploadSpeed = 0,
Category = "Cat/Sub",
Tags = new[] { "x", "y" },
Tracker = "udp://t1"
};
var client = new Client.MainData(
responseId: 1,
fullUpdate: true,
torrents: new Dictionary<string, Client.Torrent> { [hash] = clientTorrent },
torrentsRemoved: null,
categories: new Dictionary<string, Client.Category>
{
["Cat/Sub"] = new Client.Category("Cat/Sub", "/cat/sub", null)
},
categoriesRemoved: null,
tags: new[] { "x", "y" },
tagsRemoved: null,
trackers: new Dictionary<string, IReadOnlyList<string>> { ["udp://t1"] = new List<string> { hash } },
trackersRemoved: null,
serverState: new Client.ServerState(
allTimeDownloaded: 0,
allTimeUploaded: 0,
averageTimeQueue: 0,
connectionStatus: "connected",
dHTNodes: 0,
downloadInfoData: 0,
downloadInfoSpeed: 0,
downloadRateLimit: 0,
freeSpaceOnDisk: 0,
globalRatio: 0f,
queuedIOJobs: 0,
queuing: false,
readCacheHits: 0f,
readCacheOverload: 0f,
refreshInterval: 0,
totalBuffersSize: 0,
totalPeerConnections: 0,
totalQueuedSize: 0,
totalWastedSession: 0,
uploadInfoData: 0,
uploadInfoSpeed: 0,
uploadRateLimit: 0,
useAltSpeedLimits: false,
useSubcategories: true,
writeCacheOverload: 0f,
lastExternalAddressV4: "4",
lastExternalAddressV6: "6"));
var existing = _target.CreateMainData(client);
var delta = new Client.MainData(
responseId: 2,
fullUpdate: false,
torrents: null,
torrentsRemoved: new[] { hash },
categories: null,
categoriesRemoved: new[] { "Cat/Sub" },
tags: null,
tagsRemoved: new[] { "x" },
trackers: new Dictionary<string, IReadOnlyList<string>>(),
trackersRemoved: new[] { "udp://t1" },
serverState: null);
var changed = _target.MergeMainData(delta, existing, out var filterChanged);
changed.Should().BeTrue();
filterChanged.Should().BeTrue();
existing.Torrents.Should().NotContainKey(hash);
existing.Categories.Should().NotContainKey("Cat/Sub");
existing.Trackers.Should().NotContainKey("udp://t1");
existing.TagState.Should().NotContainKey("x");
existing.TagState[FilterHelper.TAG_ALL].Should().NotContain(hash);
existing.CategoriesState[FilterHelper.CATEGORY_ALL].Should().NotContain(hash);
foreach (var kv in existing.StatusState)
{
kv.Value.Should().NotContain(hash);
}
existing.TrackersState[FilterHelper.TRACKER_ALL].Should().NotContain(hash);
}
// -------------------- MergeMainData: additions (from empty) --------------------
[Fact]
public void GIVEN_EmptyExisting_WHEN_Merge_Additions_THEN_TorrentAndStatesAdded_And_Flagged()
{
var existing = _target.CreateMainData(
new Client.MainData(0, true, null, null, null, null, null, null,
new Dictionary<string, IReadOnlyList<string>>(), null, null));
var hash = "z1";
var addTorrent = new Client.Torrent
{
Name = "Zed",
State = "downloading",
UploadSpeed = 0,
Category = "",
Tags = Array.Empty<string>(),
Tracker = ""
};
var delta = new Client.MainData(
responseId: 1,
fullUpdate: false,
torrents: new Dictionary<string, Client.Torrent> { [hash] = addTorrent },
torrentsRemoved: null,
categories: new Dictionary<string, Client.Category>(),
categoriesRemoved: null,
tags: Array.Empty<string>(),
tagsRemoved: null,
trackers: new Dictionary<string, IReadOnlyList<string>>(),
trackersRemoved: null,
serverState: null);
var changed = _target.MergeMainData(delta, existing, out var filterChanged);
changed.Should().BeTrue();
filterChanged.Should().BeTrue();
existing.Torrents.Should().ContainKey(hash);
existing.TagState[FilterHelper.TAG_ALL].Should().Contain(hash);
existing.TagState[FilterHelper.TAG_UNTAGGED].Should().Contain(hash);
existing.CategoriesState[FilterHelper.CATEGORY_ALL].Should().Contain(hash);
existing.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].Should().Contain(hash);
existing.TrackersState[FilterHelper.TRACKER_ALL].Should().Contain(hash);
existing.TrackersState[FilterHelper.TRACKER_TRACKERLESS].Should().Contain(hash);
existing.StatusState[nameof(Status.Downloading)].Should().Contain(hash);
existing.StatusState[nameof(Status.Active)].Should().Contain(hash);
}
// -------------------- MergeMainData: updating an existing torrent (filter-affecting changes) --------------------
[Fact]
public void GIVEN_ExistingTorrent_WHEN_UpdateCategoryTagsStateTrackerAndSpeed_THEN_FilterSetsAdjusted()
{
var hash = "h2";
var start = new Client.MainData(
responseId: 1,
fullUpdate: true,
torrents: new Dictionary<string, Client.Torrent>
{
[hash] = new Client.Torrent
{
Name = "A",
State = "stalledDL",
UploadSpeed = 0,
Category = "",
Tags = Array.Empty<string>(),
Tracker = ""
}
},
torrentsRemoved: null,
categories: new Dictionary<string, Client.Category>(),
categoriesRemoved: null,
tags: Array.Empty<string>(),
tagsRemoved: null,
trackers: new Dictionary<string, IReadOnlyList<string>>(),
trackersRemoved: null,
serverState: new Client.ServerState(
allTimeDownloaded: 0,
allTimeUploaded: 0,
averageTimeQueue: 0,
connectionStatus: "connected",
dHTNodes: 0,
downloadInfoData: 0,
downloadInfoSpeed: 0,
downloadRateLimit: 0,
freeSpaceOnDisk: 0,
globalRatio: 0f,
queuedIOJobs: 0,
queuing: false,
readCacheHits: 0f,
readCacheOverload: 0f,
refreshInterval: 0,
totalBuffersSize: 0,
totalPeerConnections: 0,
totalQueuedSize: 0,
totalWastedSession: 0,
uploadInfoData: 0,
uploadInfoSpeed: 0,
uploadRateLimit: 0,
useAltSpeedLimits: false,
useSubcategories: true,
writeCacheOverload: 0f));
var list = _target.CreateMainData(start);
list.ServerState.UseSubcategories = true;
var update = new Client.MainData(
responseId: 2,
fullUpdate: false,
torrents: new Dictionary<string, Client.Torrent>
{
[hash] = new Client.Torrent
{
Name = "A",
State = "stalledDL",
UploadSpeed = 10,
Category = "Cat/Sub",
Tags = new[] { " x\tid " },
Tracker = "udp://zzz"
}
},
torrentsRemoved: null,
categories: new Dictionary<string, Client.Category>(),
categoriesRemoved: null,
tags: new[] { " x\tgarbage " },
tagsRemoved: null,
trackers: new Dictionary<string, IReadOnlyList<string>> { ["udp://zzz"] = new List<string> { hash } },
trackersRemoved: null,
serverState: null);
var changed = _target.MergeMainData(update, list, out var filterChanged);
changed.Should().BeTrue();
filterChanged.Should().BeTrue();
list.TagState[FilterHelper.TAG_UNTAGGED].Should().NotContain(hash);
list.TagState["x"].Should().Contain(hash);
list.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].Should().NotContain(hash);
list.CategoriesState["Cat/Sub"].Should().Contain(hash);
list.CategoriesState["Cat"].Should().Contain(hash);
list.TrackersState[FilterHelper.TRACKER_TRACKERLESS].Should().NotContain(hash);
list.TrackersState["udp://zzz"].Should().Contain(hash);
list.StatusState[nameof(Status.Inactive)].Should().NotContain(hash);
list.StatusState[nameof(Status.Active)].Should().Contain(hash);
}
// -------------------- MergeMainData: trackers & categories dictionary update paths --------------------
[Fact]
public void GIVEN_ExistingTrackersAndCategories_WHEN_SequenceChangesOrSavePathChanges_THEN_DataChangedTrue()
{
var h = "a";
var start = new Client.MainData(
responseId: 1,
fullUpdate: true,
torrents: new Dictionary<string, Client.Torrent> { [h] = new Client.Torrent { Name = "N", State = "downloading", UploadSpeed = 0, Category = "C", Tags = Array.Empty<string>(), Tracker = "t1" } },
torrentsRemoved: null,
categories: new Dictionary<string, Client.Category> { ["C"] = new Client.Category("C", "/a", null) },
categoriesRemoved: null,
tags: Array.Empty<string>(),
tagsRemoved: null,
trackers: new Dictionary<string, IReadOnlyList<string>> { ["t1"] = new List<string> { h } },
trackersRemoved: null,
serverState: null);
var list = _target.CreateMainData(start);
var delta = new Client.MainData(
responseId: 2,
fullUpdate: false,
torrents: null,
torrentsRemoved: null,
categories: new Dictionary<string, Client.Category> { ["C"] = new Client.Category("C", "/b", null) },
categoriesRemoved: null,
tags: null,
tagsRemoved: null,
trackers: new Dictionary<string, IReadOnlyList<string>> { ["t1"] = new List<string> { h, "other" } },
trackersRemoved: null,
serverState: null);
var changed = _target.MergeMainData(delta, list, out var filterChanged);
changed.Should().BeTrue();
filterChanged.Should().BeFalse();
list.Trackers["t1"].Should().Equal(new[] { h, "other" });
list.Categories["C"].SavePath.Should().Be("/b");
}
// -------------------- MergeMainData: ServerState update --------------------
[Fact]
public void GIVEN_Existing_WHEN_ServerStateFieldsChange_THEN_DataChangedTrue_And_ValuesUpdated()
{
var existing = _target.CreateMainData(
new Client.MainData(0, true, null, null, null, null, null, null,
new Dictionary<string, IReadOnlyList<string>>(), null,
new Client.ServerState(
allTimeDownloaded: 1, allTimeUploaded: 2, averageTimeQueue: 3,
connectionStatus: "connected", dHTNodes: 4, downloadInfoData: 5,
downloadInfoSpeed: 6, downloadRateLimit: 7, freeSpaceOnDisk: 8, globalRatio: 9.0f,
queuedIOJobs: 10, queuing: true, readCacheHits: 11.0f, readCacheOverload: 12.0f,
refreshInterval: 13, totalBuffersSize: 14, totalPeerConnections: 15, totalQueuedSize: 16,
totalWastedSession: 17, uploadInfoData: 18, uploadInfoSpeed: 19, uploadRateLimit: 20,
useAltSpeedLimits: false, useSubcategories: false, writeCacheOverload: 21.0f,
lastExternalAddressV4: "4", lastExternalAddressV6: "6")));
var delta = new Client.MainData(
responseId: 1,
fullUpdate: false,
torrents: null, torrentsRemoved: null,
categories: null, categoriesRemoved: null,
tags: null, tagsRemoved: null,
trackers: new Dictionary<string, IReadOnlyList<string>>(), trackersRemoved: null,
serverState: new Client.ServerState(
allTimeDownloaded: 100, allTimeUploaded: 200, averageTimeQueue: 300,
connectionStatus: "stopped", dHTNodes: 40, downloadInfoData: 50,
downloadInfoSpeed: 60, downloadRateLimit: 70, freeSpaceOnDisk: 80, globalRatio: 1.5f,
queuedIOJobs: 1000, queuing: false, readCacheHits: 0.5f, readCacheOverload: 0.2f,
refreshInterval: 99, totalBuffersSize: 77, totalPeerConnections: 88, totalQueuedSize: 66,
totalWastedSession: 55, uploadInfoData: 44, uploadInfoSpeed: 33, uploadRateLimit: 22,
useAltSpeedLimits: true, useSubcategories: true, writeCacheOverload: 0.1f,
lastExternalAddressV4: "8.8.8.8", lastExternalAddressV6: "fe80::1"));
var changed = _target.MergeMainData(delta, existing, out var filterChanged);
changed.Should().BeTrue();
filterChanged.Should().BeFalse();
var s = existing.ServerState;
s.ConnectionStatus.Should().Be("stopped");
s.DHTNodes.Should().Be(40);
s.DownloadInfoSpeed.Should().Be(60);
s.UploadRateLimit.Should().Be(22);
s.UseSubcategories.Should().BeTrue();
s.LastExternalAddressV4.Should().Be("8.8.8.8");
s.LastExternalAddressV6.Should().Be("fe80::1");
}
}
}

View File

@@ -1,33 +1,50 @@
<MudGrid>
@using Lantean.QBitTorrentClient.Models
<MudGrid>
<MudItem xs="12">
<MudSwitch Label="Additional Options" @bind-Value="Expanded" LabelPlacement="Placement.End" />
</MudItem>
</MudGrid>
<MudCollapse Expanded="Expanded">
<MudGrid>
<MudGrid Class="mt-2">
<MudItem xs="12">
<MudSelect Label="Torrent Management Mode" @bind-Value="TorrentManagementMode" Variant="Variant.Outlined">
<MudSelectItem Value="false">Manual</MudSelectItem>
<MudSelectItem Value="true">Automatic</MudSelectItem>
<MudSelect T="bool" Label="Torrent management mode" Value="@TorrentManagementMode" ValueChanged="@SetTorrentManagementMode" Variant="Variant.Outlined">
<MudSelectItem Value="@false">Manual</MudSelectItem>
<MudSelectItem Value="@true">Automatic</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField T="string" Label="Save files to location" Value="@SavePath" ValueChanged="@SavePathChanged" Variant="Variant.Outlined" Disabled="@TorrentManagementMode" />
</MudItem>
<MudItem xs="12" sm="6">
<FieldSwitch Label="Use incomplete save path" Value="@UseDownloadPath" ValueChanged="@SetUseDownloadPath" Disabled="@TorrentManagementMode" />
</MudItem>
<MudItem xs="12">
<MudTextField Label="Save files to location" @bind-Value="SavePath" Variant="Variant.Outlined"></MudTextField>
<MudTextField T="string" Label="Incomplete save path" Value="@DownloadPath" ValueChanged="@DownloadPathChanged" Variant="Variant.Outlined" Disabled="@DownloadPathDisabled" />
</MudItem>
@if (ShowCookieOption)
{
<MudItem xs="12">
<MudTextField Label="Cookie" @bind-Value="Cookie" Variant="Variant.Outlined"></MudTextField>
<MudTextField Label="Cookie" @bind-Value="Cookie" Variant="Variant.Outlined" />
</MudItem>
}
<MudItem xs="12">
<MudTextField Label="Rename" @bind-Value="RenameTorrent" Variant="Variant.Outlined"></MudTextField>
<MudTextField Label="Rename" @bind-Value="RenameTorrent" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12">
<MudSelect Label="Category" @bind-Value="Category" Variant="Variant.Outlined">
@foreach (var category in Categories)
<MudSelect T="string" Label="Category" Value="@Category" ValueChanged="@CategoryChanged" Variant="Variant.Outlined" Clearable="true">
<MudSelectItem Value="@string.Empty">None</MudSelectItem>
@foreach (var category in CategoryOptions)
{
<MudSelectItem Value="category">@category</MudSelectItem>
<MudSelectItem Value="@category.Name">@category.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudSelect T="string" Label="Tags" Variant="Variant.Outlined" MultiSelection="true" SelectedValues="@SelectedTags" SelectedValuesChanged="@SelectedTagsChanged" Disabled="@(AvailableTags.Count == 0)">
@foreach (var tag in AvailableTags)
{
<MudSelectItem Value="@tag">@tag</MudSelectItem>
}
</MudSelect>
</MudItem>
@@ -38,7 +55,7 @@
<FieldSwitch Label="Add to top of queue" @bind-Value="AddToTopOfQueue" />
</MudItem>
<MudItem xs="12">
<MudSelect Label="Stop condition" @bind-Value="StopCondition" Variant="Variant.Outlined">
<MudSelect T="string" Label="Stop condition" Value="@StopCondition" ValueChanged="@StopConditionChanged" Variant="Variant.Outlined">
<MudSelectItem Value="@("None")">None</MudSelectItem>
<MudSelectItem Value="@("MetadataReceived")">Metadata received</MudSelectItem>
<MudSelectItem Value="@("FilesChecked")">Files checked</MudSelectItem>
@@ -47,22 +64,58 @@
<MudItem xs="12">
<FieldSwitch Label="Skip hash check" @bind-Value="SkipHashCheck" />
</MudItem>
<MudSelect Label="Content layout" @bind-Value="ContentLayout" Variant="Variant.Outlined">
<MudSelectItem Value="@("Original")">Original</MudSelectItem>
<MudSelectItem Value="@("Subfolder")">Create subfolder</MudSelectItem>
<MudSelectItem Value="@("NoSubfolder")">Don't create subfolder'</MudSelectItem>
</MudSelect>
<MudItem xs="12">
<FieldSwitch Label="Download in sequentual order" @bind-Value="DownloadInSequentialOrder" />
<MudSelect T="string" Label="Content layout" Value="@ContentLayout" ValueChanged="@ContentLayoutChanged" Variant="Variant.Outlined">
<MudSelectItem Value="@("Original")">Original</MudSelectItem>
<MudSelectItem Value="@("Subfolder")">Create subfolder</MudSelectItem>
<MudSelectItem Value="@("NoSubfolder")">Don't create subfolder</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Download in sequential order" @bind-Value="DownloadInSequentialOrder" />
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Download first and last pieces first" @bind-Value="DownloadFirstAndLastPiecesFirst" />
</MudItem>
<MudItem xs="12">
<MudItem xs="12" sm="6">
<MudNumericField Label="Limit download rate" @bind-Value="DownloadLimit" Variant="Variant.Outlined" Min="0" />
</MudItem>
<MudItem xs="12">
<MudItem xs="12" sm="6">
<MudNumericField Label="Limit upload rate" @bind-Value="UploadLimit" Variant="Variant.Outlined" Min="0" />
</MudItem>
<MudItem xs="12">
<MudSelect T="ShareLimitMode" Label="Share limit preset" Value="@SelectedShareLimitMode" ValueChanged="@ShareLimitModeChanged" Variant="Variant.Outlined">
<MudSelectItem Value="@ShareLimitMode.Global">Use global share limit</MudSelectItem>
<MudSelectItem Value="@ShareLimitMode.NoLimit">Set no share limit</MudSelectItem>
<MudSelectItem Value="@ShareLimitMode.Custom">Set custom share limit</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="4">
<FieldSwitch Label="Ratio" Value="@RatioLimitEnabled" ValueChanged="@RatioLimitEnabledChanged" Disabled="@(!IsCustomShareLimit)" />
</MudItem>
<MudItem xs="12" sm="8">
<MudNumericField T="float" Label="Ratio limit" Value="@RatioLimit" ValueChanged="@RatioLimitChanged" Disabled="@(!RatioLimitEnabled || !IsCustomShareLimit)" Min="0" Step="0.1f" Format="F2" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="4">
<FieldSwitch Label="Total minutes" Value="@SeedingTimeLimitEnabled" ValueChanged="@SeedingTimeLimitEnabledChanged" Disabled="@(!IsCustomShareLimit)" />
</MudItem>
<MudItem xs="12" sm="8">
<MudNumericField T="int" Label="Total minutes" Value="@SeedingTimeLimit" ValueChanged="@SeedingTimeLimitChanged" Disabled="@(!SeedingTimeLimitEnabled || !IsCustomShareLimit)" Min="1" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="4">
<FieldSwitch Label="Inactive minutes" Value="@InactiveSeedingTimeLimitEnabled" ValueChanged="@InactiveSeedingTimeLimitEnabledChanged" Disabled="@(!IsCustomShareLimit)" />
</MudItem>
<MudItem xs="12" sm="8">
<MudNumericField T="int" Label="Inactive minutes" Value="@InactiveSeedingTimeLimit" ValueChanged="@InactiveSeedingTimeLimitChanged" Disabled="@(!InactiveSeedingTimeLimitEnabled || !IsCustomShareLimit)" Min="1" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12">
<MudSelect T="ShareLimitAction" Label="Action when limit is reached" Value="@SelectedShareLimitAction" ValueChanged="@ShareLimitActionChanged" Disabled="@(!IsCustomShareLimit)" Variant="Variant.Outlined">
<MudSelectItem Value="@ShareLimitAction.Default">Default</MudSelectItem>
<MudSelectItem Value="@ShareLimitAction.Stop">Stop torrent</MudSelectItem>
<MudSelectItem Value="@ShareLimitAction.Remove">Remove torrent</MudSelectItem>
<MudSelectItem Value="@ShareLimitAction.RemoveWithContent">Remove torrent and data</MudSelectItem>
<MudSelectItem Value="@ShareLimitAction.EnableSuperSeeding">Enable super seeding</MudSelectItem>
</MudSelect>
</MudItem>
</MudGrid>
</MudCollapse>

View File

@@ -1,4 +1,5 @@
using Lantean.QBitTorrentClient;
using Lantean.QBitTorrentClient.Models;
using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components;
@@ -6,6 +7,15 @@ namespace Lantean.QBTMud.Components.Dialogs
{
public partial class AddTorrentOptions
{
private readonly List<CategoryOption> _categoryOptions = new();
private readonly Dictionary<string, CategoryOption> _categoryLookup = new(StringComparer.Ordinal);
private string _manualSavePath = string.Empty;
private bool _manualUseDownloadPath;
private string _manualDownloadPath = string.Empty;
private string _defaultSavePath = string.Empty;
private string _defaultDownloadPath = string.Empty;
private bool _defaultDownloadPathEnabled;
[Inject]
protected IApiClient ApiClient { get; set; } = default!;
@@ -16,15 +26,25 @@ namespace Lantean.QBTMud.Components.Dialogs
protected bool TorrentManagementMode { get; set; }
protected string SavePath { get; set; } = default!;
protected string SavePath { get; set; } = string.Empty;
protected string DownloadPath { get; set; } = string.Empty;
protected bool UseDownloadPath { get; set; }
protected bool DownloadPathDisabled => TorrentManagementMode || !UseDownloadPath;
protected string? Cookie { get; set; }
protected string? RenameTorrent { get; set; }
protected IEnumerable<string> Categories { get; set; } = [];
protected IReadOnlyList<CategoryOption> CategoryOptions => _categoryOptions;
protected string? Category { get; set; }
protected string? Category { get; set; } = string.Empty;
protected List<string> AvailableTags { get; private set; } = [];
protected HashSet<string> SelectedTags { get; private set; } = new(StringComparer.Ordinal);
protected bool StartTorrent { get; set; } = true;
@@ -32,41 +52,232 @@ namespace Lantean.QBTMud.Components.Dialogs
protected string StopCondition { get; set; } = "None";
protected bool SkipHashCheck { get; set; } = false;
protected bool SkipHashCheck { get; set; }
protected string ContentLayout { get; set; } = "Original";
protected bool DownloadInSequentialOrder { get; set; } = false;
protected bool DownloadInSequentialOrder { get; set; }
protected bool DownloadFirstAndLastPiecesFirst { get; set; } = false;
protected bool DownloadFirstAndLastPiecesFirst { get; set; }
protected long DownloadLimit { get; set; }
protected long UploadLimit { get; set; }
protected ShareLimitMode SelectedShareLimitMode { get; set; } = ShareLimitMode.Global;
protected bool RatioLimitEnabled { get; set; }
protected float RatioLimit { get; set; } = 1.0f;
protected bool SeedingTimeLimitEnabled { get; set; }
protected int SeedingTimeLimit { get; set; } = 1440;
protected bool InactiveSeedingTimeLimitEnabled { get; set; }
protected int InactiveSeedingTimeLimit { get; set; } = 1440;
protected ShareLimitAction SelectedShareLimitAction { get; set; } = ShareLimitAction.Default;
protected bool IsCustomShareLimit => SelectedShareLimitMode == ShareLimitMode.Custom;
protected override async Task OnInitializedAsync()
{
var categories = await ApiClient.GetAllCategories();
Categories = categories.Select(c => c.Key).ToList();
foreach (var (name, value) in categories.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
var option = new CategoryOption(name, value.SavePath, value.DownloadPath);
_categoryOptions.Add(option);
_categoryLookup[name] = option;
}
var tags = await ApiClient.GetAllTags();
AvailableTags = tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList();
var preferences = await ApiClient.GetApplicationPreferences();
TorrentManagementMode = preferences.AutoTmmEnabled;
SavePath = preferences.SavePath;
_defaultSavePath = preferences.SavePath ?? string.Empty;
_manualSavePath = _defaultSavePath;
SavePath = _defaultSavePath;
_defaultDownloadPath = preferences.TempPath ?? string.Empty;
_defaultDownloadPathEnabled = preferences.TempPathEnabled;
_manualDownloadPath = _defaultDownloadPath;
_manualUseDownloadPath = preferences.TempPathEnabled;
UseDownloadPath = _manualUseDownloadPath;
DownloadPath = UseDownloadPath ? _manualDownloadPath : string.Empty;
StartTorrent = !preferences.AddStoppedEnabled;
AddToTopOfQueue = preferences.AddToTopOfQueue;
StopCondition = preferences.TorrentStopCondition;
ContentLayout = preferences.TorrentContentLayout;
RatioLimitEnabled = preferences.MaxRatioEnabled;
RatioLimit = preferences.MaxRatio;
SeedingTimeLimitEnabled = preferences.MaxSeedingTimeEnabled;
if (preferences.MaxSeedingTimeEnabled)
{
SeedingTimeLimit = preferences.MaxSeedingTime;
}
InactiveSeedingTimeLimitEnabled = preferences.MaxInactiveSeedingTimeEnabled;
if (preferences.MaxInactiveSeedingTimeEnabled)
{
InactiveSeedingTimeLimit = preferences.MaxInactiveSeedingTime;
}
SelectedShareLimitAction = MapShareLimitAction(preferences.MaxRatioAct);
if (TorrentManagementMode)
{
ApplyAutomaticPaths();
}
}
protected void SetTorrentManagementMode(bool value)
{
if (TorrentManagementMode == value)
{
return;
}
TorrentManagementMode = value;
if (TorrentManagementMode)
{
ApplyAutomaticPaths();
}
else
{
RestoreManualPaths();
}
}
protected void SavePathChanged(string value)
{
SavePath = value;
if (!TorrentManagementMode)
{
_manualSavePath = value;
}
}
protected void SetUseDownloadPath(bool value)
{
if (TorrentManagementMode)
{
return;
}
_manualUseDownloadPath = value;
UseDownloadPath = value;
if (value)
{
if (string.IsNullOrWhiteSpace(_manualDownloadPath))
{
_manualDownloadPath = string.IsNullOrWhiteSpace(_defaultDownloadPath) ? string.Empty : _defaultDownloadPath;
}
DownloadPath = _manualDownloadPath;
}
else
{
_manualDownloadPath = DownloadPath;
DownloadPath = string.Empty;
}
}
protected void DownloadPathChanged(string value)
{
DownloadPath = value;
if (!TorrentManagementMode && UseDownloadPath)
{
_manualDownloadPath = value;
}
}
protected void CategoryChanged(string? value)
{
Category = string.IsNullOrWhiteSpace(value) ? null : value;
if (TorrentManagementMode)
{
ApplyAutomaticPaths();
}
}
protected void SelectedTagsChanged(IEnumerable<string> tags)
{
SelectedTags = tags is null
? new HashSet<string>(StringComparer.Ordinal)
: new HashSet<string>(tags, StringComparer.Ordinal);
}
protected void StopConditionChanged(string value)
{
StopCondition = value;
}
protected void ContentLayoutChanged(string value)
{
ContentLayout = value;
}
protected void ShareLimitModeChanged(ShareLimitMode mode)
{
SelectedShareLimitMode = mode;
if (mode != ShareLimitMode.Custom)
{
RatioLimitEnabled = false;
SeedingTimeLimitEnabled = false;
InactiveSeedingTimeLimitEnabled = false;
SelectedShareLimitAction = ShareLimitAction.Default;
}
}
protected void RatioLimitEnabledChanged(bool value)
{
RatioLimitEnabled = value;
}
protected void RatioLimitChanged(float value)
{
RatioLimit = value;
}
protected void SeedingTimeLimitEnabledChanged(bool value)
{
SeedingTimeLimitEnabled = value;
}
protected void SeedingTimeLimitChanged(int value)
{
SeedingTimeLimit = value;
}
protected void InactiveSeedingTimeLimitEnabledChanged(bool value)
{
InactiveSeedingTimeLimitEnabled = value;
}
protected void InactiveSeedingTimeLimitChanged(int value)
{
InactiveSeedingTimeLimit = value;
}
protected void ShareLimitActionChanged(ShareLimitAction value)
{
SelectedShareLimitAction = value;
}
public TorrentOptions GetTorrentOptions()
{
return new TorrentOptions(
var options = new TorrentOptions(
TorrentManagementMode,
SavePath,
_manualSavePath,
Cookie,
RenameTorrent,
Category,
string.IsNullOrWhiteSpace(Category) ? null : Category,
StartTorrent,
AddToTopOfQueue,
StopCondition,
@@ -76,6 +287,154 @@ namespace Lantean.QBTMud.Components.Dialogs
DownloadFirstAndLastPiecesFirst,
DownloadLimit,
UploadLimit);
options.UseDownloadPath = TorrentManagementMode ? null : UseDownloadPath;
options.DownloadPath = (!TorrentManagementMode && UseDownloadPath) ? DownloadPath : null;
options.Tags = SelectedTags.Count > 0 ? SelectedTags.ToArray() : null;
switch (SelectedShareLimitMode)
{
case ShareLimitMode.Global:
options.RatioLimit = Limits.GlobalLimit;
options.SeedingTimeLimit = Limits.GlobalLimit;
options.InactiveSeedingTimeLimit = Limits.GlobalLimit;
options.ShareLimitAction = ShareLimitAction.Default.ToString();
break;
case ShareLimitMode.NoLimit:
options.RatioLimit = Limits.NoLimit;
options.SeedingTimeLimit = Limits.NoLimit;
options.InactiveSeedingTimeLimit = Limits.NoLimit;
options.ShareLimitAction = ShareLimitAction.Default.ToString();
break;
case ShareLimitMode.Custom:
options.RatioLimit = RatioLimitEnabled ? RatioLimit : Limits.NoLimit;
options.SeedingTimeLimit = SeedingTimeLimitEnabled ? SeedingTimeLimit : Limits.NoLimit;
options.InactiveSeedingTimeLimit = InactiveSeedingTimeLimitEnabled ? InactiveSeedingTimeLimit : Limits.NoLimit;
options.ShareLimitAction = SelectedShareLimitAction.ToString();
break;
}
return options;
}
private void ApplyAutomaticPaths()
{
SavePath = ResolveAutomaticSavePath();
var (enabled, path) = ResolveAutomaticDownloadPath();
UseDownloadPath = enabled;
DownloadPath = enabled ? path ?? string.Empty : string.Empty;
}
private void RestoreManualPaths()
{
SavePath = _manualSavePath;
UseDownloadPath = _manualUseDownloadPath;
DownloadPath = _manualUseDownloadPath ? _manualDownloadPath : string.Empty;
}
private string ResolveAutomaticSavePath()
{
var category = GetSelectedCategory();
if (category is null)
{
return _defaultSavePath;
}
if (!string.IsNullOrWhiteSpace(category.SavePath))
{
return category.SavePath!;
}
if (!string.IsNullOrWhiteSpace(_defaultSavePath) && !string.IsNullOrWhiteSpace(category.Name))
{
return Path.Combine(_defaultSavePath, category.Name);
}
return _defaultSavePath;
}
private (bool Enabled, string? Path) ResolveAutomaticDownloadPath()
{
var category = GetSelectedCategory();
if (category is null)
{
if (!_defaultDownloadPathEnabled)
{
return (false, string.Empty);
}
return (true, _defaultDownloadPath);
}
if (category.DownloadPath is null)
{
if (!_defaultDownloadPathEnabled)
{
return (false, string.Empty);
}
return (true, ComposeDefaultDownloadPath(category.Name));
}
if (!category.DownloadPath.Enabled)
{
return (false, string.Empty);
}
if (!string.IsNullOrWhiteSpace(category.DownloadPath.Path))
{
return (true, category.DownloadPath.Path);
}
return (true, ComposeDefaultDownloadPath(category.Name));
}
private string ComposeDefaultDownloadPath(string categoryName)
{
if (string.IsNullOrWhiteSpace(_defaultDownloadPath))
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(categoryName))
{
return _defaultDownloadPath;
}
return Path.Combine(_defaultDownloadPath, categoryName);
}
private CategoryOption? GetSelectedCategory()
{
if (string.IsNullOrWhiteSpace(Category))
{
return null;
}
return _categoryLookup.TryGetValue(Category, out var option) ? option : null;
}
private static ShareLimitAction MapShareLimitAction(int preferenceValue)
{
return preferenceValue switch
{
0 => ShareLimitAction.Stop,
1 => ShareLimitAction.Remove,
2 => ShareLimitAction.RemoveWithContent,
3 => ShareLimitAction.EnableSuperSeeding,
_ => ShareLimitAction.Default
};
}
protected enum ShareLimitMode
{
Global,
NoLimit,
Custom
}
protected sealed record CategoryOption(string Name, string? SavePath, DownloadPathOption? DownloadPath);
}
}

View File

@@ -24,7 +24,7 @@ namespace Lantean.QBTMud.Components.Dialogs
protected IApiClient ApiClient { get; set; } = default!;
[Inject]
protected IDataManager DataManager { get; set; } = default!;
protected ITorrentDataManager DataManager { get; set; } = default!;
[Inject]
protected ILocalStorageService LocalStorage { get; set; } = default!;

View File

@@ -1,4 +1,5 @@
@inherits SubmittableDialog
@inherits SubmittableDialog
@using Lantean.QBitTorrentClient.Models
<MudDialog>
<DialogContent>
@@ -34,6 +35,15 @@
<MudItem xs="9">
<MudNumericField T="int" Value="InactiveMinutes" ValueChanged="InactiveMinutesChanged" Disabled="@(!(CustomEnabled && InactiveMinutesEnabled))" Min="1" Max="1024000" Variant="Variant.Outlined" Adornment="Adornment.End" AdornmentText="minutes" />
</MudItem>
<MudItem xs="12">
<MudSelect T="ShareLimitAction" Label="Action when limit is reached" Value="SelectedShareLimitAction" ValueChanged="ShareLimitActionChanged" Disabled="@(!CustomEnabled)" Variant="Variant.Outlined">
<MudSelectItem Value="ShareLimitAction.Default">Default</MudSelectItem>
<MudSelectItem Value="ShareLimitAction.Stop">Stop torrent</MudSelectItem>
<MudSelectItem Value="ShareLimitAction.Remove">Remove torrent</MudSelectItem>
<MudSelectItem Value="ShareLimitAction.RemoveWithContent">Remove torrent and data</MudSelectItem>
<MudSelectItem Value="ShareLimitAction.EnableSuperSeeding">Enable super seeding</MudSelectItem>
</MudSelect>
</MudItem>
</MudGrid>
</DialogContent>
<DialogActions>

View File

@@ -1,4 +1,7 @@
using Lantean.QBitTorrentClient;
using System;
using System.Collections.Generic;
using Lantean.QBitTorrentClient;
using Lantean.QBitTorrentClient.Models;
using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components;
using MudBlazor;
@@ -16,6 +19,9 @@ namespace Lantean.QBTMud.Components.Dialogs
[Parameter]
public ShareRatioMax? Value { get; set; }
[Parameter]
public ShareRatioMax? CurrentValue { get; set; }
[Parameter]
public bool Disabled { get; set; }
@@ -33,6 +39,8 @@ namespace Lantean.QBTMud.Components.Dialogs
protected int InactiveMinutes { get; set; }
protected ShareLimitAction SelectedShareLimitAction { get; set; } = ShareLimitAction.Default;
protected bool CustomEnabled => ShareRatioType == 0;
protected void RatioEnabledChanged(bool value)
@@ -65,40 +73,75 @@ namespace Lantean.QBTMud.Components.Dialogs
InactiveMinutes = value;
}
protected void ShareLimitActionChanged(ShareLimitAction value)
{
SelectedShareLimitAction = value;
}
protected override void OnParametersSet()
{
if (Value is null || Value.RatioLimit == Limits.GlobalLimit && Value.SeedingTimeLimit == Limits.GlobalLimit && Value.InactiveSeedingTimeLimit == Limits.GlobalLimit)
RatioEnabled = false;
TotalMinutesEnabled = false;
InactiveMinutesEnabled = false;
var baseline = Value ?? CurrentValue;
SelectedShareLimitAction = baseline?.ShareLimitAction ?? ShareLimitAction.Default;
if (baseline is null || baseline.RatioLimit == Limits.GlobalLimit && baseline.SeedingTimeLimit == Limits.GlobalLimit && baseline.InactiveSeedingTimeLimit == Limits.GlobalLimit)
{
ShareRatioType = Limits.GlobalLimit;
return;
}
else if (Value.MaxRatio == Limits.NoLimit && Value.MaxSeedingTime == Limits.NoLimit && Value.MaxInactiveSeedingTime == Limits.NoLimit)
if (baseline.MaxRatio == Limits.NoLimit && baseline.MaxSeedingTime == Limits.NoLimit && baseline.MaxInactiveSeedingTime == Limits.NoLimit)
{
ShareRatioType = Limits.NoLimit;
return;
}
ShareRatioType = 0;
if (baseline.RatioLimit >= 0)
{
RatioEnabled = true;
Ratio = baseline.RatioLimit;
}
else
{
ShareRatioType = 0;
if (Value.RatioLimit >= 0)
{
RatioEnabled = true;
Ratio = Value.RatioLimit;
}
if (Value.SeedingTimeLimit >= 0)
{
TotalMinutesEnabled = true;
TotalMinutes = (int)Value.SeedingTimeLimit;
}
if (Value.InactiveSeedingTimeLimit >= 0)
{
InactiveMinutesEnabled = true;
InactiveMinutes = (int)Value.InactiveSeedingTimeLimit;
}
Ratio = 0;
}
if (baseline.SeedingTimeLimit >= 0)
{
TotalMinutesEnabled = true;
TotalMinutes = (int)baseline.SeedingTimeLimit;
}
else
{
TotalMinutes = 0;
}
if (baseline.InactiveSeedingTimeLimit >= 0)
{
InactiveMinutesEnabled = true;
InactiveMinutes = (int)baseline.InactiveSeedingTimeLimit;
}
else
{
InactiveMinutes = 0;
}
}
protected void ShareRatioTypeChanged(int value)
{
ShareRatioType = value;
if (!CustomEnabled)
{
RatioEnabled = false;
TotalMinutesEnabled = false;
InactiveMinutesEnabled = false;
SelectedShareLimitAction = ShareLimitAction.Default;
}
}
protected void Cancel()
@@ -112,16 +155,19 @@ namespace Lantean.QBTMud.Components.Dialogs
if (ShareRatioType == Limits.GlobalLimit)
{
result.RatioLimit = result.SeedingTimeLimit = result.InactiveSeedingTimeLimit = Limits.GlobalLimit;
result.ShareLimitAction = ShareLimitAction.Default;
}
else if (ShareRatioType == Limits.NoLimit)
{
result.RatioLimit = result.SeedingTimeLimit = result.InactiveSeedingTimeLimit = Limits.NoLimit;
result.ShareLimitAction = ShareLimitAction.Default;
}
else
{
result.RatioLimit = RatioEnabled ? Ratio : Limits.NoLimit;
result.SeedingTimeLimit = TotalMinutesEnabled ? TotalMinutes : Limits.NoLimit;
result.InactiveSeedingTimeLimit = InactiveMinutesEnabled ? InactiveMinutes : Limits.NoLimit;
result.ShareLimitAction = SelectedShareLimitAction;
}
MudDialog.Close(DialogResult.Ok(result));
}

View File

@@ -50,7 +50,7 @@ namespace Lantean.QBTMud.Components
protected ILocalStorageService LocalStorage { get; set; } = default!;
[Inject]
protected IDataManager DataManager { get; set; } = default!;
protected ITorrentDataManager DataManager { get; set; } = default!;
protected HashSet<string> ExpandedNodes { get; set; } = [];

View File

@@ -371,7 +371,7 @@ namespace Lantean.QBTMud.Components
{
var torrents = GetAffectedTorrentHashes(type);
await DialogService.InvokeDeleteTorrentDialog(ApiClient, [.. torrents]);
await DialogService.InvokeDeleteTorrentDialog(ApiClient, Preferences?.ConfirmTorrentDeletion == true, [.. torrents]);
}
private Dictionary<string, int> GetTags()

View File

@@ -26,7 +26,7 @@ namespace Lantean.QBTMud.Components
protected IApiClient ApiClient { get; set; } = default!;
[Inject]
protected IDataManager DataManager { get; set; } = default!;
protected ITorrentDataManager DataManager { get; set; } = default!;
protected IReadOnlyList<PieceState> Pieces { get; set; } = [];

View File

@@ -68,6 +68,21 @@
</MudCardContent>
</MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.subtitle2">Confirmation</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Class="pt-0">
<MudGrid>
<MudItem xs="12">
<FieldSwitch Label="Confirm torrent recheck" Value="ConfirmTorrentRecheck" ValueChanged="ConfirmTorrentRecheckChanged" />
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4 mt-4">
<MudCardHeader>
<CardHeaderContent>

View File

@@ -16,6 +16,8 @@ namespace Lantean.QBTMud.Components.Options
protected int SaveResumeDataInterval { get; private set; }
protected int TorrentFileSizeLimit { get; private set; }
protected bool RecheckCompletedTorrents { get; private set; }
protected bool ConfirmTorrentRecheck { get; private set; }
protected string? AppInstanceName { get; private set; }
protected int RefreshInterval { get; private set; }
protected bool ResolvePeerCountries { get; private set; }
@@ -97,6 +99,7 @@ namespace Lantean.QBTMud.Components.Options
SaveResumeDataInterval = Preferences.SaveResumeDataInterval;
TorrentFileSizeLimit = Preferences.TorrentFileSizeLimit / 1024 / 1024;
RecheckCompletedTorrents = Preferences.RecheckCompletedTorrents;
ConfirmTorrentRecheck = Preferences.ConfirmTorrentRecheck;
AppInstanceName = Preferences.AppInstanceName;
RefreshInterval = Preferences.RefreshInterval;
ResolvePeerCountries = Preferences.ResolvePeerCountries;
@@ -209,6 +212,13 @@ namespace Lantean.QBTMud.Components.Options
await PreferencesChanged.InvokeAsync(UpdatePreferences);
}
protected async Task ConfirmTorrentRecheckChanged(bool value)
{
ConfirmTorrentRecheck = value;
UpdatePreferences.ConfirmTorrentRecheck = value;
await PreferencesChanged.InvokeAsync(UpdatePreferences);
}
protected async Task AppInstanceNameChanged(string value)
{
AppInstanceName = value;

View File

@@ -17,6 +17,24 @@
</MudCardContent>
</MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.subtitle2">Transfer List</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Class="pt-0">
<MudGrid>
<MudItem xs="12">
<FieldSwitch Label="Confirm when deleting torrents" Value="ConfirmTorrentDeletion" ValueChanged="ConfirmTorrentDeletionChanged" />
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Show external IP in status bar" Value="StatusBarExternalIp" ValueChanged="StatusBarExternalIpChanged" />
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardHeader>
<CardHeaderContent>

View File

@@ -4,6 +4,10 @@ namespace Lantean.QBTMud.Components.Options
{
public partial class BehaviourOptions : Options
{
protected bool ConfirmTorrentDeletion { get; set; }
protected bool StatusBarExternalIp { get; set; }
protected bool FileLogEnabled { get; set; }
protected string? FileLogPath { get; set; }
@@ -27,6 +31,8 @@ namespace Lantean.QBTMud.Components.Options
return false;
}
ConfirmTorrentDeletion = Preferences.ConfirmTorrentDeletion;
StatusBarExternalIp = Preferences.StatusBarExternalIp;
FileLogEnabled = Preferences.FileLogEnabled;
FileLogPath = Preferences.FileLogPath;
FileLogBackupEnabled = Preferences.FileLogBackupEnabled;
@@ -39,6 +45,20 @@ namespace Lantean.QBTMud.Components.Options
return true;
}
protected async Task ConfirmTorrentDeletionChanged(bool value)
{
ConfirmTorrentDeletion = value;
UpdatePreferences.ConfirmTorrentDeletion = value;
await PreferencesChanged.InvokeAsync(UpdatePreferences);
}
protected async Task StatusBarExternalIpChanged(bool value)
{
StatusBarExternalIp = value;
UpdatePreferences.StatusBarExternalIp = value;
await PreferencesChanged.InvokeAsync(UpdatePreferences);
}
protected async Task FileLogEnabledChanged(bool value)
{
FileLogEnabled = value;

View File

@@ -40,7 +40,7 @@ namespace Lantean.QBTMud.Components
protected IApiClient ApiClient { get; set; } = default!;
[Inject]
protected IDataManager DataManager { get; set; } = default!;
protected IPeerDataManager PeerDataManager { get; set; } = default!;
protected PeerList? PeerList { get; set; }
@@ -78,11 +78,11 @@ namespace Lantean.QBTMud.Components
var peers = await ApiClient.GetTorrentPeersData(Hash, _requestId);
if (PeerList is null || peers.FullUpdate)
{
PeerList = DataManager.CreatePeerList(peers);
PeerList = PeerDataManager.CreatePeerList(peers);
}
else
{
DataManager.MergeTorrentPeers(peers, PeerList);
PeerDataManager.MergeTorrentPeers(peers, PeerList);
}
_requestId = peers.RequestId;
@@ -200,11 +200,11 @@ namespace Lantean.QBTMud.Components
}
if (PeerList is null || peers.FullUpdate)
{
PeerList = DataManager.CreatePeerList(peers);
PeerList = PeerDataManager.CreatePeerList(peers);
}
else
{
DataManager.MergeTorrentPeers(peers, PeerList);
PeerDataManager.MergeTorrentPeers(peers, PeerList);
}
_requestId = peers.RequestId;

View File

@@ -7,7 +7,6 @@ using Lantean.QBTMud.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
using System.Linq;
namespace Lantean.QBTMud.Components
{
@@ -30,7 +29,7 @@ namespace Lantean.QBTMud.Components
public ISnackbar Snackbar { get; set; } = default!;
[Inject]
public IDataManager DataManager { get; set; } = default!;
public ITorrentDataManager DataManager { get; set; } = default!;
[Inject]
public IJSRuntime JSRuntime { get; set; } = default!;
@@ -105,6 +104,8 @@ namespace Lantean.QBTMud.Components
new("copyHashv2", "Info hash v2", Icons.Material.Filled.Tag, Color.Info, CreateCallback(() => Copy(t => t.InfoHashV2))),
new("copyMagnet", "Magnet link", Icons.Material.Filled.TextFields, Color.Info, CreateCallback(() => Copy(t => t.MagnetUri))),
new("copyId", "Torrent ID", Icons.Material.Filled.TextFields, Color.Info, CreateCallback(() => Copy(t => t.Hash))),
new("copyComment", "Comment", Icons.Material.Filled.TextFields, Color.Info, CreateCallback(() => Copy(t => t.Comment))),
new("copyContentPath", "Content path", Icons.Material.Filled.TextFields, Color.Info, CreateCallback(() => Copy(t => t.ContentPath))),
]),
new("export", "Export", Icons.Material.Filled.SaveAlt, Color.Info, CreateCallback(Export)),
];
@@ -162,7 +163,7 @@ namespace Lantean.QBTMud.Components
protected async Task Remove()
{
var deleted = await DialogService.InvokeDeleteTorrentDialog(ApiClient, Hashes.ToArray());
var deleted = await DialogService.InvokeDeleteTorrentDialog(ApiClient, Preferences?.ConfirmTorrentDeletion == true, Hashes.ToArray());
if (deleted)
{
@@ -258,7 +259,7 @@ namespace Lantean.QBTMud.Components
protected async Task ForceRecheck()
{
await ApiClient.RecheckTorrents(null, Hashes.ToArray());
await DialogService.ForceRecheckAsync(ApiClient, Hashes, Preferences?.ConfirmTorrentRecheck == true);
}
protected async Task ForceReannounce()

View File

@@ -42,7 +42,7 @@ namespace Lantean.QBTMud.Components
protected IApiClient ApiClient { get; set; } = default!;
[Inject]
protected IDataManager DataManager { get; set; } = default!;
protected ITorrentDataManager DataManager { get; set; } = default!;
protected IReadOnlyList<TorrentTracker>? TrackerList { get; set; }

View File

@@ -25,7 +25,7 @@ namespace Lantean.QBTMud.Components
protected IApiClient ApiClient { get; set; } = default!;
[Inject]
protected IDataManager DataManager { get; set; } = default!;
protected ITorrentDataManager DataManager { get; set; } = default!;
protected IReadOnlyList<WebSeed>? WebSeeds { get; set; }

View File

@@ -1,9 +1,9 @@
using Lantean.QBitTorrentClient;
using Lantean.QBitTorrentClient;
using ShareLimitAction = Lantean.QBitTorrentClient.Models.ShareLimitAction;
using Lantean.QBTMud.Components.Dialogs;
using Lantean.QBTMud.Filter;
using Lantean.QBTMud.Models;
using MudBlazor;
using System.Linq;
namespace Lantean.QBTMud.Helpers
{
@@ -76,12 +76,18 @@ namespace Lantean.QBTMud.Helpers
addTorrentParams.ContentLayout = Enum.Parse<QBitTorrentClient.Models.TorrentContentLayout>(options.ContentLayout);
}
addTorrentParams.DownloadLimit = options.DownloadLimit;
addTorrentParams.DownloadPath = options.DownloadPath;
if (!string.IsNullOrWhiteSpace(options.DownloadPath))
{
addTorrentParams.DownloadPath = options.DownloadPath;
}
addTorrentParams.FirstLastPiecePriority = options.DownloadFirstAndLastPiecesFirst;
addTorrentParams.InactiveSeedingTimeLimit = options.InactiveSeedingTimeLimit;
addTorrentParams.RatioLimit = options.RatioLimit;
addTorrentParams.RenameTorrent = options.RenameTorrent;
addTorrentParams.SavePath = options.SavePath;
if (!options.TorrentManagementMode)
{
addTorrentParams.SavePath = options.SavePath;
}
addTorrentParams.SeedingTimeLimit = options.SeedingTimeLimit;
addTorrentParams.SequentialDownload = options.DownloadInSequentialOrder;
if (!string.IsNullOrEmpty(options.ShareLimitAction))
@@ -96,7 +102,10 @@ namespace Lantean.QBTMud.Helpers
addTorrentParams.Stopped = !options.StartTorrent;
addTorrentParams.Tags = options.Tags;
addTorrentParams.UploadLimit = options.UploadLimit;
addTorrentParams.UseDownloadPath = options.UseDownloadPath;
if (options.UseDownloadPath.HasValue)
{
addTorrentParams.UseDownloadPath = options.UseDownloadPath;
}
return addTorrentParams;
}
@@ -122,7 +131,7 @@ namespace Lantean.QBTMud.Helpers
_ = await apiClient.AddTorrent(addTorrentParams);
}
public static async Task<bool> InvokeDeleteTorrentDialog(this IDialogService dialogService, IApiClient apiClient, params string[] hashes)
public static async Task<bool> InvokeDeleteTorrentDialog(this IDialogService dialogService, IApiClient apiClient, bool confirmTorrentDeletion, params string[] hashes)
{
if (hashes.Length == 0)
{
@@ -134,6 +143,12 @@ namespace Lantean.QBTMud.Helpers
{ nameof(DeleteDialog.Count), hashes.Length }
};
if (!confirmTorrentDeletion)
{
await apiClient.DeleteTorrents(hashes: hashes, deleteFiles: false);
return true;
}
var reference = await dialogService.ShowAsync<DeleteDialog>($"Remove torrent{(hashes.Length == 1 ? "" : "s")}?", parameters, ConfirmDialogOptions);
var dialogResult = await reference.Result;
if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
@@ -146,6 +161,28 @@ namespace Lantean.QBTMud.Helpers
return true;
}
public static async Task ForceRecheckAsync(this IDialogService dialogService, IApiClient apiClient, IEnumerable<string> hashes, bool confirmTorrentRecheck)
{
var hashArray = hashes?.ToArray() ?? [];
if (hashArray.Length == 0)
{
return;
}
if (confirmTorrentRecheck)
{
var content = $"Are you sure you want to recheck the selected torrent{(hashArray.Length == 1 ? "" : "s")}?";
var confirmed = await dialogService.ShowConfirmDialog("Force recheck", content);
if (!confirmed)
{
return;
}
}
await apiClient.RecheckTorrents(null, hashArray);
}
public static async Task InvokeDownloadRateDialog(this IDialogService dialogService, IApiClient apiClient, long rate, IEnumerable<string> hashes)
{
Func<long, string> valueDisplayFunc = v => v == Limits.NoLimit ? "∞" : v.ToString();
@@ -213,21 +250,30 @@ namespace Lantean.QBTMud.Helpers
public static async Task InvokeShareRatioDialog(this IDialogService dialogService, IApiClient apiClient, IEnumerable<Torrent> torrents)
{
var torrentShareRatios = torrents.Select(t => new ShareRatioMax
var torrentList = torrents.ToList();
if (torrentList.Count == 0)
{
return;
}
var shareRatioValues = torrentList.Select(t => new ShareRatioMax
{
InactiveSeedingTimeLimit = t.InactiveSeedingTimeLimit,
MaxInactiveSeedingTime = t.InactiveSeedingTimeLimit,
MaxInactiveSeedingTime = t.MaxInactiveSeedingTime,
MaxRatio = t.MaxRatio,
MaxSeedingTime = t.MaxSeedingTime,
RatioLimit = t.RatioLimit,
SeedingTimeLimit = t.SeedingTimeLimit,
});
ShareLimitAction = t.ShareLimitAction,
}).ToList();
var torrentsHaveSameShareRatio = torrentShareRatios.Distinct().Count() == 1;
var referenceValue = shareRatioValues[0];
var torrentsHaveSameShareRatio = shareRatioValues.Distinct().Count() == 1;
var parameters = new DialogParameters
{
{ nameof(ShareRatioDialog.Value), torrentsHaveSameShareRatio ? torrentShareRatios.FirstOrDefault() : null },
{ nameof(ShareRatioDialog.Value), torrentsHaveSameShareRatio ? referenceValue : null },
{ nameof(ShareRatioDialog.CurrentValue), referenceValue },
};
var result = await dialogService.ShowAsync<ShareRatioDialog>("Share ratio", parameters, FormDialogOptions);
@@ -239,7 +285,7 @@ namespace Lantean.QBTMud.Helpers
var shareRatio = (ShareRatio)dialogResult.Data;
await apiClient.SetTorrentShareLimit(shareRatio.RatioLimit, shareRatio.SeedingTimeLimit, shareRatio.InactiveSeedingTimeLimit, hashes: torrents.Select(t => t.Hash).ToArray());
await apiClient.SetTorrentShareLimit(shareRatio.RatioLimit, shareRatio.SeedingTimeLimit, shareRatio.InactiveSeedingTimeLimit, shareRatio.ShareLimitAction ?? ShareLimitAction.Default, hashes: torrentList.Select(t => t.Hash).ToArray());
}
public static async Task InvokeStringFieldDialog(this IDialogService dialogService, string title, string label, string? value, Func<string, Task> onSuccess)
@@ -433,3 +479,5 @@ namespace Lantean.QBTMud.Helpers
}
}
}

View File

@@ -1,5 +1,6 @@
using ByteSizeLib;
using Lantean.QBTMud.Models;
using Lantean.QBitTorrentClient;
using MudBlazor;
using System.Diagnostics.CodeAnalysis;
using System.Text;
@@ -415,5 +416,25 @@ namespace Lantean.QBTMud.Helpers
_ => (Icons.Material.Filled.QuestionMark, Color.Inherit),
};
}
public static string Bool(bool value, string trueText = "Yes", string falseText = "No")
{
return value ? trueText : falseText;
}
public static string RatioLimit(float value)
{
if (value == Limits.GlobalLimit)
{
return "Global";
}
if (value <= Limits.NoLimit)
{
return "∞";
}
return value.ToString("0.00");
}
}
}

View File

@@ -4,23 +4,27 @@
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CompressionEnabled>false</CompressionEnabled>
<LangVersion>12</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CompressionEnabled>false</CompressionEnabled>
<LangVersion>12</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="ByteSize" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.10" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="MudBlazor" Version="8.13.0" />
<PackageReference Include="MudBlazor.ThemeManager" Version="3.0.0" />
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="ByteSize" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.10" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="MudBlazor" Version="8.13.0" />
<PackageReference Include="MudBlazor.ThemeManager" Version="3.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lantean.QBitTorrentClient\Lantean.QBitTorrentClient.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Lantean.QBTMud.Test" />
</ItemGroup>
</Project>

View File

@@ -33,6 +33,14 @@
}
<MudSpacer />
<MudText Class="mx-2 mb-1 d-none d-sm-flex">@DisplayHelpers.Size(MainData?.ServerState.FreeSpaceOnDisk, "Free space: ")</MudText>
@{
var externalIpLabel = Preferences?.StatusBarExternalIp == true ? BuildExternalIpLabel(MainData?.ServerState) : null;
}
@if (!string.IsNullOrEmpty(externalIpLabel))
{
<MudDivider Vertical="true" Class="d-none d-sm-flex" />
<MudText Class="mx-2 mb-1 d-none d-sm-flex">@externalIpLabel</MudText>
}
<MudDivider Vertical="true" Class="d-none d-sm-flex" />
<MudText Class="mx-2 mb-1 d-none d-sm-flex">DHT @(MainData?.ServerState.DHTNodes ?? 0) nodes</MudText>
<MudDivider Vertical="true" Class="d-none d-sm-flex" />

View File

@@ -21,7 +21,7 @@ namespace Lantean.QBTMud.Layout
protected IApiClient ApiClient { get; set; } = default!;
[Inject]
protected IDataManager DataManager { get; set; } = default!;
protected ITorrentDataManager DataManager { get; set; } = default!;
[Inject]
protected NavigationManager NavigationManager { get; set; } = default!;
@@ -201,6 +201,32 @@ namespace Lantean.QBTMud.Layout
};
}
private static string? BuildExternalIpLabel(ServerState? serverState)
{
if (serverState is null)
{
return null;
}
var v4 = serverState.LastExternalAddressV4;
var v6 = serverState.LastExternalAddressV6;
var hasV4 = !string.IsNullOrWhiteSpace(v4);
var hasV6 = !string.IsNullOrWhiteSpace(v6);
if (!hasV4 && !hasV6)
{
return "External IP: N/A";
}
if (hasV4 && hasV6)
{
return $"External IPs: {v4}, {v6}";
}
var address = hasV4 ? v4 : v6;
return $"External IP: {address}";
}
private void OnCategoryChanged(string category)
{
if (Category == category)

View File

@@ -27,7 +27,17 @@
long uploadRateLimit,
bool useAltSpeedLimits,
bool useSubcategories,
float writeCacheOverload) : base(connectionStatus, dHTNodes, downloadInfoData, downloadInfoSpeed, downloadRateLimit, uploadInfoData, uploadInfoSpeed, uploadRateLimit)
float writeCacheOverload,
string lastExternalAddressV4,
string lastExternalAddressV6) : base(
connectionStatus,
dHTNodes,
downloadInfoData,
downloadInfoSpeed,
downloadRateLimit,
uploadInfoData,
uploadInfoSpeed,
uploadRateLimit)
{
AllTimeDownloaded = allTimeDownloaded;
AllTimeUploaded = allTimeUploaded;
@@ -46,6 +56,8 @@
UseAltSpeedLimits = useAltSpeedLimits;
UseSubcategories = useSubcategories;
WriteCacheOverload = writeCacheOverload;
LastExternalAddressV4 = lastExternalAddressV4;
LastExternalAddressV6 = lastExternalAddressV6;
}
public ServerState()
@@ -85,5 +97,9 @@
public bool UseSubcategories { get; set; }
public float WriteCacheOverload { get; set; }
public string LastExternalAddressV4 { get; set; } = string.Empty;
public string LastExternalAddressV6 { get; set; } = string.Empty;
}
}

View File

@@ -1,10 +1,13 @@
namespace Lantean.QBTMud.Models
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBTMud.Models
{
public record ShareRatio
{
public float RatioLimit { get; set; }
public float SeedingTimeLimit { get; set; }
public float InactiveSeedingTimeLimit { get; set; }
public ShareLimitAction? ShareLimitAction { get; set; }
}
public record ShareRatioMax : ShareRatio

View File

@@ -6,7 +6,6 @@
Downloading,
Seeding,
Completed,
Paused,
Stopped,
Active,
Inactive,

View File

@@ -1,4 +1,9 @@
namespace Lantean.QBTMud.Models
using System;
using System.Collections.Generic;
using System.Linq;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBTMud.Models
{
public class Torrent
{
@@ -52,7 +57,13 @@
long uploadSpeed,
long reannounce,
float inactiveSeedingTimeLimit,
float maxInactiveSeedingTime)
float maxInactiveSeedingTime,
float popularity,
string downloadPath,
string rootPath,
bool isPrivate,
ShareLimitAction shareLimitAction,
string comment)
{
Hash = hash;
AddedOn = addedOn;
@@ -104,21 +115,31 @@
Reannounce = reannounce;
InactiveSeedingTimeLimit = inactiveSeedingTimeLimit;
MaxInactiveSeedingTime = maxInactiveSeedingTime;
Popularity = popularity;
DownloadPath = downloadPath;
RootPath = rootPath;
IsPrivate = isPrivate;
ShareLimitAction = shareLimitAction;
Comment = comment;
}
protected Torrent()
{
Hash = "";
Category = "";
ContentPath = "";
InfoHashV1 = "";
InfoHashV2 = "";
MagnetUri = "";
Name = "";
SavePath = "";
State = "";
Tags = [];
Tracker = "";
Hash = string.Empty;
Category = string.Empty;
ContentPath = string.Empty;
InfoHashV1 = string.Empty;
InfoHashV2 = string.Empty;
MagnetUri = string.Empty;
Name = string.Empty;
SavePath = string.Empty;
DownloadPath = string.Empty;
RootPath = string.Empty;
State = string.Empty;
Tags = new List<string>();
Tracker = string.Empty;
ShareLimitAction = ShareLimitAction.Default;
Comment = string.Empty;
}
public string Hash { get; }
@@ -183,8 +204,14 @@
public float RatioLimit { get; set; }
public float Popularity { get; set; }
public string SavePath { get; set; }
public string DownloadPath { get; set; }
public string RootPath { get; set; }
public long SeedingTime { get; set; }
public int SeedingTimeLimit { get; set; }
@@ -221,6 +248,12 @@
public float MaxInactiveSeedingTime { get; set; }
public bool IsPrivate { get; set; }
public ShareLimitAction ShareLimitAction { get; set; }
public string Comment { get; set; }
public override bool Equals(object? obj)
{
if (obj is null)

View File

@@ -24,7 +24,7 @@ namespace Lantean.QBTMud.Pages
protected IApiClient ApiClient { get; set; } = default!;
[Inject]
protected IDataManager DataManager { get; set; } = default!;
protected IPreferencesDataManager PreferencesDataManager { get; set; } = default!;
[CascadingParameter(Name = "DrawerOpen")]
public bool DrawerOpen { get; set; }
@@ -61,7 +61,7 @@ namespace Lantean.QBTMud.Pages
protected void PreferencesChanged(UpdatePreferences preferences)
{
UpdatePreferences = DataManager.MergePreferences(UpdatePreferences, preferences);
UpdatePreferences = PreferencesDataManager.MergePreferences(UpdatePreferences, preferences);
}
protected async Task ValidateExit(LocationChangingContext context)

View File

@@ -24,7 +24,7 @@ namespace Lantean.QBTMud.Pages
protected NavigationManager NavigationManager { get; set; } = default!;
[Inject]
protected IDataManager DataManager { get; set; } = default!;
protected IRssDataManager RssDataManager { get; set; } = default!;
[CascadingParameter]
public MainData? MainData { get; set; }
@@ -115,7 +115,7 @@ namespace Lantean.QBTMud.Pages
private async Task GetRssList()
{
var items = await ApiClient.GetAllRssItems(true);
RssList = DataManager.CreateRssList(items);
RssList = RssDataManager.CreateRssList(items);
}
protected async Task DownloadItem(string? url)

View File

@@ -295,6 +295,7 @@ namespace Lantean.QBTMud.Pages
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Up Speed", t => t.UploadSpeed, t => DisplayHelpers.Speed(t.UploadSpeed)),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("ETA", t => t.EstimatedTimeOfArrival, t => DisplayHelpers.Duration(t.EstimatedTimeOfArrival)),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Ratio", t => t.Ratio, t => t.Ratio.ToString("0.00")),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Popularity", t => t.Popularity, t => t.Popularity.ToString("0.00")),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Category", t => t.Category),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Tags", t => t.Tags, t => string.Join(", ", t.Tags)),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Added On", t => t.AddedOn, t => DisplayHelpers.DateTime(t.AddedOn)),
@@ -310,11 +311,15 @@ namespace Lantean.QBTMud.Pages
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Time Active", t => t.TimeActive, t => DisplayHelpers.Duration(t.TimeActive), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Save path", t => t.SavePath, enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Completed", t => t.Completed, t => DisplayHelpers.Size(t.Completed), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Ratio Limit", t => t.RatioLimit, t => t.Ratio.ToString("0.00"), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Ratio Limit", t => t.RatioLimit, t => DisplayHelpers.RatioLimit(t.RatioLimit), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Last Seen Complete", t => t.SeenComplete, t => DisplayHelpers.DateTime(t.SeenComplete), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Last Activity", t => t.LastActivity, t => DisplayHelpers.DateTime(t.LastActivity), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Availability", t => t.Availability, t => t.Availability.ToString("0.##"), enabled: false),
//ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Reannounce In", t => t.Reannounce, enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Incomplete Save Path", t => t.DownloadPath, t => DisplayHelpers.EmptyIfNull(t.DownloadPath), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Info Hash v1", t => t.InfoHashV1, t => DisplayHelpers.EmptyIfNull(t.InfoHashV1), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Info Hash v2", t => t.InfoHashV2, t => DisplayHelpers.EmptyIfNull(t.InfoHashV2), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Reannounce In", t => t.Reannounce, t => DisplayHelpers.Duration(t.Reannounce), enabled: false),
ColumnDefinitionHelper.CreateColumnDefinition<Torrent>("Private", t => t.IsPrivate, t => DisplayHelpers.Bool(t.IsPrivate), enabled: false),
];
public async ValueTask DisposeAsync()

View File

@@ -41,7 +41,11 @@ namespace Lantean.QBTMud
builder.Services.AddScoped<ApiClient>();
builder.Services.AddScoped<IApiClient, ApiClient>();
builder.Services.AddSingleton<IDataManager, DataManager>();
builder.Services.AddSingleton<ITorrentDataManager, TorrentDataManager>();
builder.Services.AddSingleton<IPeerDataManager, PeerDataManager>();
builder.Services.AddSingleton<IPreferencesDataManager, PreferencesDataManager>();
builder.Services.AddSingleton<IRssDataManager, RssDataManager>();
builder.Services.AddBlazoredLocalStorage();
builder.Services.AddSingleton<IClipboardService, ClipboardService>();
builder.Services.AddTransient<IKeyboardService, KeyboardService>();

View File

@@ -0,0 +1,11 @@
using Lantean.QBTMud.Models;
namespace Lantean.QBTMud.Services
{
public interface IPeerDataManager
{
PeerList CreatePeerList(QBitTorrentClient.Models.TorrentPeers torrentPeers);
void MergeTorrentPeers(QBitTorrentClient.Models.TorrentPeers torrentPeers, PeerList peerList);
}
}

View File

@@ -0,0 +1,7 @@
namespace Lantean.QBTMud.Services
{
public interface IPreferencesDataManager
{
QBitTorrentClient.Models.UpdatePreferences MergePreferences(QBitTorrentClient.Models.UpdatePreferences? original, QBitTorrentClient.Models.UpdatePreferences changed);
}
}

View File

@@ -0,0 +1,9 @@
using Lantean.QBTMud.Models;
namespace Lantean.QBTMud.Services
{
public interface IRssDataManager
{
RssList CreateRssList(IReadOnlyDictionary<string, QBitTorrentClient.Models.RssItem> rssItems);
}
}

View File

@@ -2,7 +2,7 @@
namespace Lantean.QBTMud.Services
{
public interface IDataManager
public interface ITorrentDataManager
{
MainData CreateMainData(QBitTorrentClient.Models.MainData mainData);
@@ -10,16 +10,8 @@ namespace Lantean.QBTMud.Services
bool MergeMainData(QBitTorrentClient.Models.MainData mainData, MainData torrentList, out bool filterChanged);
PeerList CreatePeerList(QBitTorrentClient.Models.TorrentPeers torrentPeers);
void MergeTorrentPeers(QBitTorrentClient.Models.TorrentPeers torrentPeers, PeerList peerList);
Dictionary<string, ContentItem> CreateContentsList(IReadOnlyList<QBitTorrentClient.Models.FileData> files);
bool MergeContentsList(IReadOnlyList<QBitTorrentClient.Models.FileData> files, Dictionary<string, ContentItem> contents);
QBitTorrentClient.Models.UpdatePreferences MergePreferences(QBitTorrentClient.Models.UpdatePreferences? original, QBitTorrentClient.Models.UpdatePreferences changed);
RssList CreateRssList(IReadOnlyDictionary<string, QBitTorrentClient.Models.RssItem> rssItems);
}
}

View File

@@ -0,0 +1,94 @@
using Lantean.QBTMud.Models;
namespace Lantean.QBTMud.Services
{
public class PeerDataManager : IPeerDataManager
{
public PeerList CreatePeerList(QBitTorrentClient.Models.TorrentPeers torrentPeers)
{
var peers = new Dictionary<string, Peer>();
if (torrentPeers.Peers is not null)
{
foreach (var (key, peer) in torrentPeers.Peers)
{
var newPeer = CreatePeer(key, peer);
peers[key] = newPeer;
}
}
var peerList = new PeerList(peers);
return peerList;
}
public void MergeTorrentPeers(QBitTorrentClient.Models.TorrentPeers torrentPeers, PeerList peerList)
{
if (torrentPeers.PeersRemoved is not null)
{
foreach (var key in torrentPeers.PeersRemoved)
{
peerList.Peers.Remove(key);
}
}
if (torrentPeers.Peers is not null)
{
foreach (var (key, peer) in torrentPeers.Peers)
{
if (!peerList.Peers.TryGetValue(key, out var existingPeer))
{
var newPeer = CreatePeer(key, peer);
peerList.Peers.Add(key, newPeer);
}
else
{
UpdatePeer(existingPeer, peer);
}
}
}
}
private static Peer CreatePeer(string key, QBitTorrentClient.Models.Peer peer)
{
return new Peer(
key,
peer.Client!,
peer.ClientId!,
peer.Connection!,
peer.Country,
peer.CountryCode,
peer.Downloaded.GetValueOrDefault(),
peer.DownloadSpeed.GetValueOrDefault(),
peer.Files!,
peer.Flags!,
peer.FlagsDescription!,
peer.IPAddress!,
peer.Port.GetValueOrDefault(),
peer.Progress.GetValueOrDefault(),
peer.Relevance.GetValueOrDefault(),
peer.Uploaded.GetValueOrDefault(),
peer.UploadSpeed.GetValueOrDefault());
}
private static void UpdatePeer(Peer existingPeer, QBitTorrentClient.Models.Peer peer)
{
existingPeer.Client = peer.Client ?? existingPeer.Client;
existingPeer.ClientId = peer.ClientId ?? existingPeer.ClientId;
existingPeer.Connection = peer.Connection ?? existingPeer.Connection;
existingPeer.Country = peer.Country ?? existingPeer.Country;
existingPeer.CountryCode = peer.CountryCode ?? existingPeer.CountryCode;
existingPeer.Downloaded = peer.Downloaded ?? existingPeer.Downloaded;
existingPeer.DownloadSpeed = peer.DownloadSpeed ?? existingPeer.DownloadSpeed;
existingPeer.Files = peer.Files ?? existingPeer.Files;
existingPeer.Flags = peer.Flags ?? existingPeer.Flags;
existingPeer.FlagsDescription = peer.FlagsDescription ?? existingPeer.FlagsDescription;
existingPeer.IPAddress = peer.IPAddress ?? existingPeer.IPAddress;
existingPeer.Port = peer.Port ?? existingPeer.Port;
existingPeer.Progress = peer.Progress ?? existingPeer.Progress;
existingPeer.Relevance = peer.Relevance ?? existingPeer.Relevance;
existingPeer.Uploaded = peer.Uploaded ?? existingPeer.Uploaded;
existingPeer.UploadSpeed = peer.UploadSpeed ?? existingPeer.UploadSpeed;
}
}
}

View File

@@ -0,0 +1,459 @@
namespace Lantean.QBTMud.Services
{
public class PreferencesDataManager : IPreferencesDataManager
{
public QBitTorrentClient.Models.UpdatePreferences MergePreferences(
QBitTorrentClient.Models.UpdatePreferences? original,
QBitTorrentClient.Models.UpdatePreferences changed)
{
if (original is null)
{
original = new QBitTorrentClient.Models.UpdatePreferences
{
AddToTopOfQueue = changed.AddToTopOfQueue,
AddStoppedEnabled = changed.AddStoppedEnabled,
AddTrackers = changed.AddTrackers,
AddTrackersEnabled = changed.AddTrackersEnabled,
AltDlLimit = changed.AltDlLimit,
AltUpLimit = changed.AltUpLimit,
AlternativeWebuiEnabled = changed.AlternativeWebuiEnabled,
AlternativeWebuiPath = changed.AlternativeWebuiPath,
AnnounceIp = changed.AnnounceIp,
AnnouncePort = changed.AnnouncePort,
AnnounceToAllTiers = changed.AnnounceToAllTiers,
AnnounceToAllTrackers = changed.AnnounceToAllTrackers,
AnonymousMode = changed.AnonymousMode,
AppInstanceName = changed.AppInstanceName,
AsyncIoThreads = changed.AsyncIoThreads,
AutoDeleteMode = changed.AutoDeleteMode,
AutoTmmEnabled = changed.AutoTmmEnabled,
AutorunEnabled = changed.AutorunEnabled,
AutorunOnTorrentAddedEnabled = changed.AutorunOnTorrentAddedEnabled,
AutorunOnTorrentAddedProgram = changed.AutorunOnTorrentAddedProgram,
AutorunProgram = changed.AutorunProgram,
BannedIPs = changed.BannedIPs,
BdecodeDepthLimit = changed.BdecodeDepthLimit,
BdecodeTokenLimit = changed.BdecodeTokenLimit,
BittorrentProtocol = changed.BittorrentProtocol,
BlockPeersOnPrivilegedPorts = changed.BlockPeersOnPrivilegedPorts,
BypassAuthSubnetWhitelist = changed.BypassAuthSubnetWhitelist,
BypassAuthSubnetWhitelistEnabled = changed.BypassAuthSubnetWhitelistEnabled,
BypassLocalAuth = changed.BypassLocalAuth,
CategoryChangedTmmEnabled = changed.CategoryChangedTmmEnabled,
CheckingMemoryUse = changed.CheckingMemoryUse,
ConnectionSpeed = changed.ConnectionSpeed,
CurrentInterfaceAddress = changed.CurrentInterfaceAddress,
CurrentInterfaceName = changed.CurrentInterfaceName,
CurrentNetworkInterface = changed.CurrentNetworkInterface,
Dht = changed.Dht,
DhtBootstrapNodes = changed.DhtBootstrapNodes,
DiskCache = changed.DiskCache,
DiskCacheTtl = changed.DiskCacheTtl,
DiskIoReadMode = changed.DiskIoReadMode,
DiskIoType = changed.DiskIoType,
DiskIoWriteMode = changed.DiskIoWriteMode,
DiskQueueSize = changed.DiskQueueSize,
DlLimit = changed.DlLimit,
DontCountSlowTorrents = changed.DontCountSlowTorrents,
DyndnsDomain = changed.DyndnsDomain,
DyndnsEnabled = changed.DyndnsEnabled,
DyndnsPassword = changed.DyndnsPassword,
DyndnsService = changed.DyndnsService,
DyndnsUsername = changed.DyndnsUsername,
EmbeddedTrackerPort = changed.EmbeddedTrackerPort,
EmbeddedTrackerPortForwarding = changed.EmbeddedTrackerPortForwarding,
EnableCoalesceReadWrite = changed.EnableCoalesceReadWrite,
EnableEmbeddedTracker = changed.EnableEmbeddedTracker,
EnableMultiConnectionsFromSameIp = changed.EnableMultiConnectionsFromSameIp,
EnablePieceExtentAffinity = changed.EnablePieceExtentAffinity,
EnableUploadSuggestions = changed.EnableUploadSuggestions,
Encryption = changed.Encryption,
ExcludedFileNames = changed.ExcludedFileNames,
ExcludedFileNamesEnabled = changed.ExcludedFileNamesEnabled,
ExportDir = changed.ExportDir,
ExportDirFin = changed.ExportDirFin,
FileLogAge = changed.FileLogAge,
FileLogAgeType = changed.FileLogAgeType,
FileLogBackupEnabled = changed.FileLogBackupEnabled,
FileLogDeleteOld = changed.FileLogDeleteOld,
FileLogEnabled = changed.FileLogEnabled,
FileLogMaxSize = changed.FileLogMaxSize,
FileLogPath = changed.FileLogPath,
FilePoolSize = changed.FilePoolSize,
HashingThreads = changed.HashingThreads,
I2pAddress = changed.I2pAddress,
I2pEnabled = changed.I2pEnabled,
I2pInboundLength = changed.I2pInboundLength,
I2pInboundQuantity = changed.I2pInboundQuantity,
I2pMixedMode = changed.I2pMixedMode,
I2pOutboundLength = changed.I2pOutboundLength,
I2pOutboundQuantity = changed.I2pOutboundQuantity,
I2pPort = changed.I2pPort,
IdnSupportEnabled = changed.IdnSupportEnabled,
IncompleteFilesExt = changed.IncompleteFilesExt,
UseUnwantedFolder = changed.UseUnwantedFolder,
IpFilterEnabled = changed.IpFilterEnabled,
IpFilterPath = changed.IpFilterPath,
IpFilterTrackers = changed.IpFilterTrackers,
LimitLanPeers = changed.LimitLanPeers,
LimitTcpOverhead = changed.LimitTcpOverhead,
LimitUtpRate = changed.LimitUtpRate,
ListenPort = changed.ListenPort,
SslEnabled = changed.SslEnabled,
SslListenPort = changed.SslListenPort,
Locale = changed.Locale,
Lsd = changed.Lsd,
MailNotificationAuthEnabled = changed.MailNotificationAuthEnabled,
MailNotificationEmail = changed.MailNotificationEmail,
MailNotificationEnabled = changed.MailNotificationEnabled,
MailNotificationPassword = changed.MailNotificationPassword,
MailNotificationSender = changed.MailNotificationSender,
MailNotificationSmtp = changed.MailNotificationSmtp,
MailNotificationSslEnabled = changed.MailNotificationSslEnabled,
MailNotificationUsername = changed.MailNotificationUsername,
MarkOfTheWeb = changed.MarkOfTheWeb,
MaxActiveCheckingTorrents = changed.MaxActiveCheckingTorrents,
MaxActiveDownloads = changed.MaxActiveDownloads,
MaxActiveTorrents = changed.MaxActiveTorrents,
MaxActiveUploads = changed.MaxActiveUploads,
MaxConcurrentHttpAnnounces = changed.MaxConcurrentHttpAnnounces,
MaxConnec = changed.MaxConnec,
MaxConnecPerTorrent = changed.MaxConnecPerTorrent,
MaxInactiveSeedingTime = changed.MaxInactiveSeedingTime,
MaxInactiveSeedingTimeEnabled = changed.MaxInactiveSeedingTimeEnabled,
MaxRatio = changed.MaxRatio,
MaxRatioAct = changed.MaxRatioAct,
MaxRatioEnabled = changed.MaxRatioEnabled,
MaxSeedingTime = changed.MaxSeedingTime,
MaxSeedingTimeEnabled = changed.MaxSeedingTimeEnabled,
MaxUploads = changed.MaxUploads,
MaxUploadsPerTorrent = changed.MaxUploadsPerTorrent,
MemoryWorkingSetLimit = changed.MemoryWorkingSetLimit,
MergeTrackers = changed.MergeTrackers,
OutgoingPortsMax = changed.OutgoingPortsMax,
OutgoingPortsMin = changed.OutgoingPortsMin,
PeerTos = changed.PeerTos,
PeerTurnover = changed.PeerTurnover,
PeerTurnoverCutoff = changed.PeerTurnoverCutoff,
PeerTurnoverInterval = changed.PeerTurnoverInterval,
PerformanceWarning = changed.PerformanceWarning,
Pex = changed.Pex,
PreallocateAll = changed.PreallocateAll,
ProxyAuthEnabled = changed.ProxyAuthEnabled,
ProxyBittorrent = changed.ProxyBittorrent,
ProxyHostnameLookup = changed.ProxyHostnameLookup,
ProxyIp = changed.ProxyIp,
ProxyMisc = changed.ProxyMisc,
ProxyPassword = changed.ProxyPassword,
ProxyPeerConnections = changed.ProxyPeerConnections,
ProxyPort = changed.ProxyPort,
ProxyRss = changed.ProxyRss,
ProxyType = changed.ProxyType,
ProxyUsername = changed.ProxyUsername,
PythonExecutablePath = changed.PythonExecutablePath,
QueueingEnabled = changed.QueueingEnabled,
RandomPort = changed.RandomPort,
ReannounceWhenAddressChanged = changed.ReannounceWhenAddressChanged,
RecheckCompletedTorrents = changed.RecheckCompletedTorrents,
RefreshInterval = changed.RefreshInterval,
RequestQueueSize = changed.RequestQueueSize,
ResolvePeerCountries = changed.ResolvePeerCountries,
ResumeDataStorageType = changed.ResumeDataStorageType,
RssAutoDownloadingEnabled = changed.RssAutoDownloadingEnabled,
RssDownloadRepackProperEpisodes = changed.RssDownloadRepackProperEpisodes,
RssFetchDelay = changed.RssFetchDelay,
RssMaxArticlesPerFeed = changed.RssMaxArticlesPerFeed,
RssProcessingEnabled = changed.RssProcessingEnabled,
RssRefreshInterval = changed.RssRefreshInterval,
RssSmartEpisodeFilters = changed.RssSmartEpisodeFilters,
SavePath = changed.SavePath,
SavePathChangedTmmEnabled = changed.SavePathChangedTmmEnabled,
SaveResumeDataInterval = changed.SaveResumeDataInterval,
SaveStatisticsInterval = changed.SaveStatisticsInterval,
ScanDirs = changed.ScanDirs,
ScheduleFromHour = changed.ScheduleFromHour,
ScheduleFromMin = changed.ScheduleFromMin,
ScheduleToHour = changed.ScheduleToHour,
ScheduleToMin = changed.ScheduleToMin,
SchedulerDays = changed.SchedulerDays,
SchedulerEnabled = changed.SchedulerEnabled,
SendBufferLowWatermark = changed.SendBufferLowWatermark,
SendBufferWatermark = changed.SendBufferWatermark,
SendBufferWatermarkFactor = changed.SendBufferWatermarkFactor,
SlowTorrentDlRateThreshold = changed.SlowTorrentDlRateThreshold,
SlowTorrentInactiveTimer = changed.SlowTorrentInactiveTimer,
SlowTorrentUlRateThreshold = changed.SlowTorrentUlRateThreshold,
SocketBacklogSize = changed.SocketBacklogSize,
SocketReceiveBufferSize = changed.SocketReceiveBufferSize,
SocketSendBufferSize = changed.SocketSendBufferSize,
SsrfMitigation = changed.SsrfMitigation,
StopTrackerTimeout = changed.StopTrackerTimeout,
TempPath = changed.TempPath,
TempPathEnabled = changed.TempPathEnabled,
TorrentChangedTmmEnabled = changed.TorrentChangedTmmEnabled,
TorrentContentLayout = changed.TorrentContentLayout,
TorrentContentRemoveOption = changed.TorrentContentRemoveOption,
TorrentFileSizeLimit = changed.TorrentFileSizeLimit,
TorrentStopCondition = changed.TorrentStopCondition,
UpLimit = changed.UpLimit,
UploadChokingAlgorithm = changed.UploadChokingAlgorithm,
UploadSlotsBehavior = changed.UploadSlotsBehavior,
Upnp = changed.Upnp,
UpnpLeaseDuration = changed.UpnpLeaseDuration,
UseCategoryPathsInManualMode = changed.UseCategoryPathsInManualMode,
UseHttps = changed.UseHttps,
IgnoreSslErrors = changed.IgnoreSslErrors,
UseSubcategories = changed.UseSubcategories,
UtpTcpMixedMode = changed.UtpTcpMixedMode,
ValidateHttpsTrackerCertificate = changed.ValidateHttpsTrackerCertificate,
WebUiAddress = changed.WebUiAddress,
WebUiApiKey = changed.WebUiApiKey,
WebUiBanDuration = changed.WebUiBanDuration,
WebUiClickjackingProtectionEnabled = changed.WebUiClickjackingProtectionEnabled,
WebUiCsrfProtectionEnabled = changed.WebUiCsrfProtectionEnabled,
WebUiCustomHttpHeaders = changed.WebUiCustomHttpHeaders,
WebUiDomainList = changed.WebUiDomainList,
WebUiHostHeaderValidationEnabled = changed.WebUiHostHeaderValidationEnabled,
WebUiHttpsCertPath = changed.WebUiHttpsCertPath,
WebUiHttpsKeyPath = changed.WebUiHttpsKeyPath,
WebUiMaxAuthFailCount = changed.WebUiMaxAuthFailCount,
WebUiPort = changed.WebUiPort,
WebUiReverseProxiesList = changed.WebUiReverseProxiesList,
WebUiReverseProxyEnabled = changed.WebUiReverseProxyEnabled,
WebUiSecureCookieEnabled = changed.WebUiSecureCookieEnabled,
WebUiSessionTimeout = changed.WebUiSessionTimeout,
WebUiUpnp = changed.WebUiUpnp,
WebUiUseCustomHttpHeadersEnabled = changed.WebUiUseCustomHttpHeadersEnabled,
WebUiUsername = changed.WebUiUsername,
WebUiPassword = changed.WebUiPassword,
ConfirmTorrentDeletion = changed.ConfirmTorrentDeletion,
ConfirmTorrentRecheck = changed.ConfirmTorrentRecheck,
StatusBarExternalIp = changed.StatusBarExternalIp
};
return original;
}
original.AddToTopOfQueue = changed.AddToTopOfQueue ?? original.AddToTopOfQueue;
original.AddStoppedEnabled = changed.AddStoppedEnabled ?? original.AddStoppedEnabled;
original.AddTrackers = changed.AddTrackers ?? original.AddTrackers;
original.AddTrackersEnabled = changed.AddTrackersEnabled ?? original.AddTrackersEnabled;
original.AltDlLimit = changed.AltDlLimit ?? original.AltDlLimit;
original.AltUpLimit = changed.AltUpLimit ?? original.AltUpLimit;
original.AlternativeWebuiEnabled = changed.AlternativeWebuiEnabled ?? original.AlternativeWebuiEnabled;
original.AlternativeWebuiPath = changed.AlternativeWebuiPath ?? original.AlternativeWebuiPath;
original.AnnounceIp = changed.AnnounceIp ?? original.AnnounceIp;
original.AnnouncePort = changed.AnnouncePort ?? original.AnnouncePort;
original.AnnounceToAllTiers = changed.AnnounceToAllTiers ?? original.AnnounceToAllTiers;
original.AnnounceToAllTrackers = changed.AnnounceToAllTrackers ?? original.AnnounceToAllTrackers;
original.AnonymousMode = changed.AnonymousMode ?? original.AnonymousMode;
original.AppInstanceName = changed.AppInstanceName ?? original.AppInstanceName;
original.AsyncIoThreads = changed.AsyncIoThreads ?? original.AsyncIoThreads;
original.AutoDeleteMode = changed.AutoDeleteMode ?? original.AutoDeleteMode;
original.AutoTmmEnabled = changed.AutoTmmEnabled ?? original.AutoTmmEnabled;
original.AutorunEnabled = changed.AutorunEnabled ?? original.AutorunEnabled;
original.AutorunOnTorrentAddedEnabled = changed.AutorunOnTorrentAddedEnabled ?? original.AutorunOnTorrentAddedEnabled;
original.AutorunOnTorrentAddedProgram = changed.AutorunOnTorrentAddedProgram ?? original.AutorunOnTorrentAddedProgram;
original.AutorunProgram = changed.AutorunProgram ?? original.AutorunProgram;
original.BannedIPs = changed.BannedIPs ?? original.BannedIPs;
original.BdecodeDepthLimit = changed.BdecodeDepthLimit ?? original.BdecodeDepthLimit;
original.BdecodeTokenLimit = changed.BdecodeTokenLimit ?? original.BdecodeTokenLimit;
original.BittorrentProtocol = changed.BittorrentProtocol ?? original.BittorrentProtocol;
original.BlockPeersOnPrivilegedPorts = changed.BlockPeersOnPrivilegedPorts ?? original.BlockPeersOnPrivilegedPorts;
original.BypassAuthSubnetWhitelist = changed.BypassAuthSubnetWhitelist ?? original.BypassAuthSubnetWhitelist;
original.BypassAuthSubnetWhitelistEnabled = changed.BypassAuthSubnetWhitelistEnabled ?? original.BypassAuthSubnetWhitelistEnabled;
original.BypassLocalAuth = changed.BypassLocalAuth ?? original.BypassLocalAuth;
original.CategoryChangedTmmEnabled = changed.CategoryChangedTmmEnabled ?? original.CategoryChangedTmmEnabled;
original.CheckingMemoryUse = changed.CheckingMemoryUse ?? original.CheckingMemoryUse;
original.ConnectionSpeed = changed.ConnectionSpeed ?? original.ConnectionSpeed;
original.CurrentInterfaceAddress = changed.CurrentInterfaceAddress ?? original.CurrentInterfaceAddress;
original.CurrentInterfaceName = changed.CurrentInterfaceName ?? original.CurrentInterfaceName;
original.CurrentNetworkInterface = changed.CurrentNetworkInterface ?? original.CurrentNetworkInterface;
original.Dht = changed.Dht ?? original.Dht;
original.DhtBootstrapNodes = changed.DhtBootstrapNodes ?? original.DhtBootstrapNodes;
original.DiskCache = changed.DiskCache ?? original.DiskCache;
original.DiskCacheTtl = changed.DiskCacheTtl ?? original.DiskCacheTtl;
original.DiskIoReadMode = changed.DiskIoReadMode ?? original.DiskIoReadMode;
original.DiskIoType = changed.DiskIoType ?? original.DiskIoType;
original.DiskIoWriteMode = changed.DiskIoWriteMode ?? original.DiskIoWriteMode;
original.DiskQueueSize = changed.DiskQueueSize ?? original.DiskQueueSize;
original.DlLimit = changed.DlLimit ?? original.DlLimit;
original.DontCountSlowTorrents = changed.DontCountSlowTorrents ?? original.DontCountSlowTorrents;
original.DyndnsDomain = changed.DyndnsDomain ?? original.DyndnsDomain;
original.DyndnsEnabled = changed.DyndnsEnabled ?? original.DyndnsEnabled;
original.DyndnsPassword = changed.DyndnsPassword ?? original.DyndnsPassword;
original.DyndnsService = changed.DyndnsService ?? original.DyndnsService;
original.DyndnsUsername = changed.DyndnsUsername ?? original.DyndnsUsername;
original.EmbeddedTrackerPort = changed.EmbeddedTrackerPort ?? original.EmbeddedTrackerPort;
original.EmbeddedTrackerPortForwarding = changed.EmbeddedTrackerPortForwarding ?? original.EmbeddedTrackerPortForwarding;
original.EnableCoalesceReadWrite = changed.EnableCoalesceReadWrite ?? original.EnableCoalesceReadWrite;
original.EnableEmbeddedTracker = changed.EnableEmbeddedTracker ?? original.EnableEmbeddedTracker;
original.EnableMultiConnectionsFromSameIp = changed.EnableMultiConnectionsFromSameIp ?? original.EnableMultiConnectionsFromSameIp;
original.EnablePieceExtentAffinity = changed.EnablePieceExtentAffinity ?? original.EnablePieceExtentAffinity;
original.EnableUploadSuggestions = changed.EnableUploadSuggestions ?? original.EnableUploadSuggestions;
original.Encryption = changed.Encryption ?? original.Encryption;
original.ExcludedFileNames = changed.ExcludedFileNames ?? original.ExcludedFileNames;
original.ExcludedFileNamesEnabled = changed.ExcludedFileNamesEnabled ?? original.ExcludedFileNamesEnabled;
original.ExportDir = changed.ExportDir ?? original.ExportDir;
original.ExportDirFin = changed.ExportDirFin ?? original.ExportDirFin;
original.FileLogAge = changed.FileLogAge ?? original.FileLogAge;
original.FileLogAgeType = changed.FileLogAgeType ?? original.FileLogAgeType;
original.FileLogBackupEnabled = changed.FileLogBackupEnabled ?? original.FileLogBackupEnabled;
original.FileLogDeleteOld = changed.FileLogDeleteOld ?? original.FileLogDeleteOld;
original.FileLogEnabled = changed.FileLogEnabled ?? original.FileLogEnabled;
original.FileLogMaxSize = changed.FileLogMaxSize ?? original.FileLogMaxSize;
original.FileLogPath = changed.FileLogPath ?? original.FileLogPath;
original.FilePoolSize = changed.FilePoolSize ?? original.FilePoolSize;
original.HashingThreads = changed.HashingThreads ?? original.HashingThreads;
original.I2pAddress = changed.I2pAddress ?? original.I2pAddress;
original.I2pEnabled = changed.I2pEnabled ?? original.I2pEnabled;
original.I2pInboundLength = changed.I2pInboundLength ?? original.I2pInboundLength;
original.I2pInboundQuantity = changed.I2pInboundQuantity ?? original.I2pInboundQuantity;
original.I2pMixedMode = changed.I2pMixedMode ?? original.I2pMixedMode;
original.I2pOutboundLength = changed.I2pOutboundLength ?? original.I2pOutboundLength;
original.I2pOutboundQuantity = changed.I2pOutboundQuantity ?? original.I2pOutboundQuantity;
original.I2pPort = changed.I2pPort ?? original.I2pPort;
original.IdnSupportEnabled = changed.IdnSupportEnabled ?? original.IdnSupportEnabled;
original.IncompleteFilesExt = changed.IncompleteFilesExt ?? original.IncompleteFilesExt;
original.UseUnwantedFolder = changed.UseUnwantedFolder ?? original.UseUnwantedFolder;
original.IpFilterEnabled = changed.IpFilterEnabled ?? original.IpFilterEnabled;
original.IpFilterPath = changed.IpFilterPath ?? original.IpFilterPath;
original.IpFilterTrackers = changed.IpFilterTrackers ?? original.IpFilterTrackers;
original.LimitLanPeers = changed.LimitLanPeers ?? original.LimitLanPeers;
original.LimitTcpOverhead = changed.LimitTcpOverhead ?? original.LimitTcpOverhead;
original.LimitUtpRate = changed.LimitUtpRate ?? original.LimitUtpRate;
original.ListenPort = changed.ListenPort ?? original.ListenPort;
original.SslEnabled = changed.SslEnabled ?? original.SslEnabled;
original.SslListenPort = changed.SslListenPort ?? original.SslListenPort;
original.Locale = changed.Locale ?? original.Locale;
original.Lsd = changed.Lsd ?? original.Lsd;
original.MailNotificationAuthEnabled = changed.MailNotificationAuthEnabled ?? original.MailNotificationAuthEnabled;
original.MailNotificationEmail = changed.MailNotificationEmail ?? original.MailNotificationEmail;
original.MailNotificationEnabled = changed.MailNotificationEnabled ?? original.MailNotificationEnabled;
original.MailNotificationPassword = changed.MailNotificationPassword ?? original.MailNotificationPassword;
original.MailNotificationSender = changed.MailNotificationSender ?? original.MailNotificationSender;
original.MailNotificationSmtp = changed.MailNotificationSmtp ?? original.MailNotificationSmtp;
original.MailNotificationSslEnabled = changed.MailNotificationSslEnabled ?? original.MailNotificationSslEnabled;
original.MailNotificationUsername = changed.MailNotificationUsername ?? original.MailNotificationUsername;
original.MarkOfTheWeb = changed.MarkOfTheWeb ?? original.MarkOfTheWeb;
original.MaxActiveCheckingTorrents = changed.MaxActiveCheckingTorrents ?? original.MaxActiveCheckingTorrents;
original.MaxActiveDownloads = changed.MaxActiveDownloads ?? original.MaxActiveDownloads;
original.MaxActiveTorrents = changed.MaxActiveTorrents ?? original.MaxActiveTorrents;
original.MaxActiveUploads = changed.MaxActiveUploads ?? original.MaxActiveUploads;
original.MaxConcurrentHttpAnnounces = changed.MaxConcurrentHttpAnnounces ?? original.MaxConcurrentHttpAnnounces;
original.MaxConnec = changed.MaxConnec ?? original.MaxConnec;
original.MaxConnecPerTorrent = changed.MaxConnecPerTorrent ?? original.MaxConnecPerTorrent;
original.MaxInactiveSeedingTime = changed.MaxInactiveSeedingTime ?? original.MaxInactiveSeedingTime;
original.MaxInactiveSeedingTimeEnabled = changed.MaxInactiveSeedingTimeEnabled ?? original.MaxInactiveSeedingTimeEnabled;
original.MaxRatio = changed.MaxRatio ?? original.MaxRatio;
original.MaxRatioAct = changed.MaxRatioAct ?? original.MaxRatioAct;
original.MaxRatioEnabled = changed.MaxRatioEnabled ?? original.MaxRatioEnabled;
original.MaxSeedingTime = changed.MaxSeedingTime ?? original.MaxSeedingTime;
original.MaxSeedingTimeEnabled = changed.MaxSeedingTimeEnabled ?? original.MaxSeedingTimeEnabled;
original.MaxUploads = changed.MaxUploads ?? original.MaxUploads;
original.MaxUploadsPerTorrent = changed.MaxUploadsPerTorrent ?? original.MaxUploadsPerTorrent;
original.MemoryWorkingSetLimit = changed.MemoryWorkingSetLimit ?? original.MemoryWorkingSetLimit;
original.MergeTrackers = changed.MergeTrackers ?? original.MergeTrackers;
original.OutgoingPortsMax = changed.OutgoingPortsMax ?? original.OutgoingPortsMax;
original.OutgoingPortsMin = changed.OutgoingPortsMin ?? original.OutgoingPortsMin;
original.PeerTos = changed.PeerTos ?? original.PeerTos;
original.PeerTurnover = changed.PeerTurnover ?? original.PeerTurnover;
original.PeerTurnoverCutoff = changed.PeerTurnoverCutoff ?? original.PeerTurnoverCutoff;
original.PeerTurnoverInterval = changed.PeerTurnoverInterval ?? original.PeerTurnoverInterval;
original.PerformanceWarning = changed.PerformanceWarning ?? original.PerformanceWarning;
original.Pex = changed.Pex ?? original.Pex;
original.PreallocateAll = changed.PreallocateAll ?? original.PreallocateAll;
original.ProxyAuthEnabled = changed.ProxyAuthEnabled ?? original.ProxyAuthEnabled;
original.ProxyBittorrent = changed.ProxyBittorrent ?? original.ProxyBittorrent;
original.ProxyHostnameLookup = changed.ProxyHostnameLookup ?? original.ProxyHostnameLookup;
original.ProxyIp = changed.ProxyIp ?? original.ProxyIp;
original.ProxyMisc = changed.ProxyMisc ?? original.ProxyMisc;
original.ProxyPassword = changed.ProxyPassword ?? original.ProxyPassword;
original.ProxyPeerConnections = changed.ProxyPeerConnections ?? original.ProxyPeerConnections;
original.ProxyPort = changed.ProxyPort ?? original.ProxyPort;
original.ProxyRss = changed.ProxyRss ?? original.ProxyRss;
original.ProxyType = changed.ProxyType ?? original.ProxyType;
original.ProxyUsername = changed.ProxyUsername ?? original.ProxyUsername;
original.PythonExecutablePath = changed.PythonExecutablePath ?? original.PythonExecutablePath;
original.QueueingEnabled = changed.QueueingEnabled ?? original.QueueingEnabled;
original.RandomPort = changed.RandomPort ?? original.RandomPort;
original.ReannounceWhenAddressChanged = changed.ReannounceWhenAddressChanged ?? original.ReannounceWhenAddressChanged;
original.RecheckCompletedTorrents = changed.RecheckCompletedTorrents ?? original.RecheckCompletedTorrents;
original.RefreshInterval = changed.RefreshInterval ?? original.RefreshInterval;
original.RequestQueueSize = changed.RequestQueueSize ?? original.RequestQueueSize;
original.ResolvePeerCountries = changed.ResolvePeerCountries ?? original.ResolvePeerCountries;
original.ResumeDataStorageType = changed.ResumeDataStorageType ?? original.ResumeDataStorageType;
original.RssAutoDownloadingEnabled = changed.RssAutoDownloadingEnabled ?? original.RssAutoDownloadingEnabled;
original.RssDownloadRepackProperEpisodes = changed.RssDownloadRepackProperEpisodes ?? original.RssDownloadRepackProperEpisodes;
original.RssFetchDelay = changed.RssFetchDelay ?? original.RssFetchDelay;
original.RssMaxArticlesPerFeed = changed.RssMaxArticlesPerFeed ?? original.RssMaxArticlesPerFeed;
original.RssProcessingEnabled = changed.RssProcessingEnabled ?? original.RssProcessingEnabled;
original.RssRefreshInterval = changed.RssRefreshInterval ?? original.RssRefreshInterval;
original.RssSmartEpisodeFilters = changed.RssSmartEpisodeFilters ?? original.RssSmartEpisodeFilters;
original.SavePath = changed.SavePath ?? original.SavePath;
original.SavePathChangedTmmEnabled = changed.SavePathChangedTmmEnabled ?? original.SavePathChangedTmmEnabled;
original.SaveResumeDataInterval = changed.SaveResumeDataInterval ?? original.SaveResumeDataInterval;
original.SaveStatisticsInterval = changed.SaveStatisticsInterval ?? original.SaveStatisticsInterval;
original.ScanDirs = changed.ScanDirs ?? original.ScanDirs;
original.ScheduleFromHour = changed.ScheduleFromHour ?? original.ScheduleFromHour;
original.ScheduleFromMin = changed.ScheduleFromMin ?? original.ScheduleFromMin;
original.ScheduleToHour = changed.ScheduleToHour ?? original.ScheduleToHour;
original.ScheduleToMin = changed.ScheduleToMin ?? original.ScheduleToMin;
original.SchedulerDays = changed.SchedulerDays ?? original.SchedulerDays;
original.SchedulerEnabled = changed.SchedulerEnabled ?? original.SchedulerEnabled;
original.SendBufferLowWatermark = changed.SendBufferLowWatermark ?? original.SendBufferLowWatermark;
original.SendBufferWatermark = changed.SendBufferWatermark ?? original.SendBufferWatermark;
original.SendBufferWatermarkFactor = changed.SendBufferWatermarkFactor ?? original.SendBufferWatermarkFactor;
original.SlowTorrentDlRateThreshold = changed.SlowTorrentDlRateThreshold ?? original.SlowTorrentDlRateThreshold;
original.SlowTorrentInactiveTimer = changed.SlowTorrentInactiveTimer ?? original.SlowTorrentInactiveTimer;
original.SlowTorrentUlRateThreshold = changed.SlowTorrentUlRateThreshold ?? original.SlowTorrentUlRateThreshold;
original.SocketBacklogSize = changed.SocketBacklogSize ?? original.SocketBacklogSize;
original.SocketReceiveBufferSize = changed.SocketReceiveBufferSize ?? original.SocketReceiveBufferSize;
original.SocketSendBufferSize = changed.SocketSendBufferSize ?? original.SocketSendBufferSize;
original.SsrfMitigation = changed.SsrfMitigation ?? original.SsrfMitigation;
original.StopTrackerTimeout = changed.StopTrackerTimeout ?? original.StopTrackerTimeout;
original.TempPath = changed.TempPath ?? original.TempPath;
original.TempPathEnabled = changed.TempPathEnabled ?? original.TempPathEnabled;
original.TorrentChangedTmmEnabled = changed.TorrentChangedTmmEnabled ?? original.TorrentChangedTmmEnabled;
original.TorrentContentLayout = changed.TorrentContentLayout ?? original.TorrentContentLayout;
original.TorrentContentRemoveOption = changed.TorrentContentRemoveOption ?? original.TorrentContentRemoveOption;
original.TorrentFileSizeLimit = changed.TorrentFileSizeLimit ?? original.TorrentFileSizeLimit;
original.TorrentStopCondition = changed.TorrentStopCondition ?? original.TorrentStopCondition;
original.UpLimit = changed.UpLimit ?? original.UpLimit;
original.UploadChokingAlgorithm = changed.UploadChokingAlgorithm ?? original.UploadChokingAlgorithm;
original.UploadSlotsBehavior = changed.UploadSlotsBehavior ?? original.UploadSlotsBehavior;
original.Upnp = changed.Upnp ?? original.Upnp;
original.UpnpLeaseDuration = changed.UpnpLeaseDuration ?? original.UpnpLeaseDuration;
original.UseCategoryPathsInManualMode = changed.UseCategoryPathsInManualMode ?? original.UseCategoryPathsInManualMode;
original.UseHttps = changed.UseHttps ?? original.UseHttps;
original.IgnoreSslErrors = changed.IgnoreSslErrors ?? original.IgnoreSslErrors;
original.UseSubcategories = changed.UseSubcategories ?? original.UseSubcategories;
original.UtpTcpMixedMode = changed.UtpTcpMixedMode ?? original.UtpTcpMixedMode;
original.ValidateHttpsTrackerCertificate = changed.ValidateHttpsTrackerCertificate ?? original.ValidateHttpsTrackerCertificate;
original.WebUiAddress = changed.WebUiAddress ?? original.WebUiAddress;
original.WebUiApiKey = changed.WebUiApiKey ?? original.WebUiApiKey;
original.WebUiBanDuration = changed.WebUiBanDuration ?? original.WebUiBanDuration;
original.WebUiClickjackingProtectionEnabled = changed.WebUiClickjackingProtectionEnabled ?? original.WebUiClickjackingProtectionEnabled;
original.WebUiCsrfProtectionEnabled = changed.WebUiCsrfProtectionEnabled ?? original.WebUiCsrfProtectionEnabled;
original.WebUiCustomHttpHeaders = changed.WebUiCustomHttpHeaders ?? original.WebUiCustomHttpHeaders;
original.WebUiDomainList = changed.WebUiDomainList ?? original.WebUiDomainList;
original.WebUiHostHeaderValidationEnabled = changed.WebUiHostHeaderValidationEnabled ?? original.WebUiHostHeaderValidationEnabled;
original.WebUiHttpsCertPath = changed.WebUiHttpsCertPath ?? original.WebUiHttpsCertPath;
original.WebUiHttpsKeyPath = changed.WebUiHttpsKeyPath ?? original.WebUiHttpsKeyPath;
original.WebUiMaxAuthFailCount = changed.WebUiMaxAuthFailCount ?? original.WebUiMaxAuthFailCount;
original.WebUiPort = changed.WebUiPort ?? original.WebUiPort;
original.WebUiReverseProxiesList = changed.WebUiReverseProxiesList ?? original.WebUiReverseProxiesList;
original.WebUiReverseProxyEnabled = changed.WebUiReverseProxyEnabled ?? original.WebUiReverseProxyEnabled;
original.WebUiSecureCookieEnabled = changed.WebUiSecureCookieEnabled ?? original.WebUiSecureCookieEnabled;
original.WebUiSessionTimeout = changed.WebUiSessionTimeout ?? original.WebUiSessionTimeout;
original.WebUiUpnp = changed.WebUiUpnp ?? original.WebUiUpnp;
original.WebUiUseCustomHttpHeadersEnabled = changed.WebUiUseCustomHttpHeadersEnabled ?? original.WebUiUseCustomHttpHeadersEnabled;
original.WebUiUsername = changed.WebUiUsername ?? original.WebUiUsername;
original.WebUiPassword = changed.WebUiPassword ?? original.WebUiPassword;
original.ConfirmTorrentDeletion = changed.ConfirmTorrentDeletion ?? original.ConfirmTorrentDeletion;
original.ConfirmTorrentRecheck = changed.ConfirmTorrentRecheck ?? original.ConfirmTorrentRecheck;
original.StatusBarExternalIp = changed.StatusBarExternalIp ?? original.StatusBarExternalIp;
return original;
}
}
}

View File

@@ -0,0 +1,40 @@
using Lantean.QBTMud.Models;
namespace Lantean.QBTMud.Services
{
public class RssDataManager : IRssDataManager
{
public RssList CreateRssList(IReadOnlyDictionary<string, QBitTorrentClient.Models.RssItem> rssItems)
{
var articles = new List<RssArticle>();
var feeds = new Dictionary<string, RssFeed>();
foreach (var (key, rssItem) in rssItems)
{
feeds.Add(key, new RssFeed(rssItem.HasError, rssItem.IsLoading, rssItem.LastBuildDate, rssItem.Title, rssItem.Uid, rssItem.Url));
if (rssItem.Articles is null)
{
continue;
}
foreach (var rssArticle in rssItem.Articles)
{
var article = new RssArticle(
key,
rssArticle.Category,
rssArticle.Comments,
rssArticle.Date!,
rssArticle.Description,
rssArticle.Id!,
rssArticle.Link,
rssArticle.Thumbnail,
rssArticle.Title!,
rssArticle.TorrentURL!,
rssArticle.IsRead);
articles.Add(article);
}
}
return new RssList(feeds, articles);
}
}
}

View File

@@ -1,30 +1,13 @@
using Lantean.QBTMud.Helpers;
using Lantean.QBTMud.Helpers;
using Lantean.QBTMud.Models;
using ShareLimitAction = Lantean.QBitTorrentClient.Models.ShareLimitAction;
namespace Lantean.QBTMud.Services
{
public class DataManager : IDataManager
public class TorrentDataManager : ITorrentDataManager
{
private static Status[]? _statusArray = null;
public PeerList CreatePeerList(QBitTorrentClient.Models.TorrentPeers torrentPeers)
{
var peers = new Dictionary<string, Peer>();
if (torrentPeers.Peers is not null)
{
foreach (var (key, peer) in torrentPeers.Peers)
{
var newPeer = CreatePeer(key, peer);
peers[key] = newPeer;
}
}
var peerList = new PeerList(peers);
return peerList;
}
public MainData CreateMainData(QBitTorrentClient.Models.MainData mainData)
{
var torrents = new Dictionary<string, Torrent>(mainData.Torrents?.Count ?? 0);
@@ -145,7 +128,9 @@ namespace Lantean.QBTMud.Services
serverState.UploadRateLimit.GetValueOrDefault(),
serverState.UseAltSpeedLimits.GetValueOrDefault(),
serverState.UseSubcategories.GetValueOrDefault(),
serverState.WriteCacheOverload.GetValueOrDefault());
serverState.WriteCacheOverload.GetValueOrDefault(),
serverState.LastExternalAddressV4 ?? string.Empty,
serverState.LastExternalAddressV6 ?? string.Empty);
}
public bool MergeMainData(QBitTorrentClient.Models.MainData mainData, MainData torrentList, out bool filterChanged)
@@ -183,7 +168,6 @@ namespace Lantean.QBTMud.Services
{
filterChanged = true;
}
torrentList.TagState.Remove(normalizedTag);
}
}
@@ -207,9 +191,14 @@ namespace Lantean.QBTMud.Services
{
foreach (var hash in mainData.TorrentsRemoved)
{
if (torrentList.Torrents.Remove(hash))
if (torrentList.Torrents.TryGetValue(hash, out var existing))
{
RemoveTorrentFromStates(torrentList, hash);
var snapshot = CreateSnapshot(existing);
torrentList.Torrents.Remove(hash);
// remove from all filter sets using the captured snapshot
RemoveTorrentFromStates(torrentList, hash, snapshot);
dataChanged = true;
filterChanged = true;
}
@@ -352,9 +341,7 @@ namespace Lantean.QBTMud.Services
return _statusArray;
}
_statusArray = Enum.GetValues<Status>()
.Where(s => s != Status.Paused)
.ToArray();
_statusArray = Enum.GetValues<Status>();
return _statusArray;
}
@@ -367,15 +354,8 @@ namespace Lantean.QBTMud.Services
UpdateTrackerState(torrentList, updatedTorrent, hash, previousSnapshot.Tracker);
}
private static void RemoveTorrentFromStates(MainData torrentList, string hash)
private static void RemoveTorrentFromStates(MainData torrentList, string hash, TorrentSnapshot snapshot)
{
if (!torrentList.Torrents.TryGetValue(hash, out var torrent))
{
return;
}
var snapshot = CreateSnapshot(torrent);
torrentList.TagState[FilterHelper.TAG_ALL].Remove(hash);
UpdateTagStateForRemoval(torrentList, hash, snapshot.Tags);
@@ -553,83 +533,26 @@ namespace Lantean.QBTMud.Services
changed = true;
}
if (serverState.LastExternalAddressV4 is not null && existingServerState.LastExternalAddressV4 != serverState.LastExternalAddressV4)
{
existingServerState.LastExternalAddressV4 = serverState.LastExternalAddressV4;
changed = true;
}
if (serverState.LastExternalAddressV6 is not null && existingServerState.LastExternalAddressV6 != serverState.LastExternalAddressV6)
{
existingServerState.LastExternalAddressV6 = serverState.LastExternalAddressV6;
changed = true;
}
return changed;
}
public void MergeTorrentPeers(QBitTorrentClient.Models.TorrentPeers torrentPeers, PeerList peerList)
{
if (torrentPeers.PeersRemoved is not null)
{
foreach (var key in torrentPeers.PeersRemoved)
{
peerList.Peers.Remove(key);
}
}
if (torrentPeers.Peers is not null)
{
foreach (var (key, peer) in torrentPeers.Peers)
{
if (!peerList.Peers.TryGetValue(key, out var existingPeer))
{
var newPeer = CreatePeer(key, peer);
peerList.Peers.Add(key, newPeer);
}
else
{
UpdatePeer(existingPeer, peer);
}
}
}
}
private static void UpdatePeer(Peer existingPeer, QBitTorrentClient.Models.Peer peer)
{
existingPeer.Client = peer.Client ?? existingPeer.Client;
existingPeer.ClientId = peer.ClientId ?? existingPeer.ClientId;
existingPeer.Connection = peer.Connection ?? existingPeer.Connection;
existingPeer.Country = peer.Country ?? existingPeer.Country;
existingPeer.CountryCode = peer.CountryCode ?? existingPeer.CountryCode;
existingPeer.Downloaded = peer.Downloaded ?? existingPeer.Downloaded;
existingPeer.DownloadSpeed = peer.DownloadSpeed ?? existingPeer.DownloadSpeed;
existingPeer.Files = peer.Files ?? existingPeer.Files;
existingPeer.Flags = peer.Flags ?? existingPeer.Flags;
existingPeer.FlagsDescription = peer.FlagsDescription ?? existingPeer.FlagsDescription;
existingPeer.IPAddress = peer.IPAddress ?? existingPeer.IPAddress;
existingPeer.Port = peer.Port ?? existingPeer.Port;
existingPeer.Progress = peer.Progress ?? existingPeer.Progress;
existingPeer.Relevance = peer.Relevance ?? existingPeer.Relevance;
existingPeer.Uploaded = peer.Uploaded ?? existingPeer.Uploaded;
existingPeer.UploadSpeed = peer.UploadSpeed ?? existingPeer.UploadSpeed;
}
private static Category CreateCategory(QBitTorrentClient.Models.Category category)
{
return new Category(category.Name, category.SavePath!);
}
private static Peer CreatePeer(string key, QBitTorrentClient.Models.Peer peer)
{
return new Peer(
key,
peer.Client!,
peer.ClientId!,
peer.Connection!,
peer.Country,
peer.CountryCode,
peer.Downloaded.GetValueOrDefault(),
peer.DownloadSpeed.GetValueOrDefault(),
peer.Files!,
peer.Flags!,
peer.FlagsDescription!,
peer.IPAddress!,
peer.Port.GetValueOrDefault(),
peer.Progress.GetValueOrDefault(),
peer.Relevance.GetValueOrDefault(),
peer.Uploaded.GetValueOrDefault(),
peer.UploadSpeed.GetValueOrDefault());
}
public Torrent CreateTorrent(string hash, QBitTorrentClient.Models.Torrent torrent)
{
var normalizedTags = torrent.Tags?
@@ -688,10 +611,16 @@ namespace Lantean.QBTMud.Services
torrent.UploadSpeed.GetValueOrDefault(),
torrent.Reannounce ?? 0,
torrent.InactiveSeedingTimeLimit.GetValueOrDefault(),
torrent.MaxInactiveSeedingTime.GetValueOrDefault());
torrent.MaxInactiveSeedingTime.GetValueOrDefault(),
torrent.Popularity.GetValueOrDefault(),
torrent.DownloadPath ?? string.Empty,
torrent.RootPath ?? string.Empty,
torrent.IsPrivate.GetValueOrDefault(),
torrent.ShareLimitAction ?? ShareLimitAction.Default,
torrent.Comment ?? string.Empty);
}
private static string NormalizeTag(string? tag)
internal static string NormalizeTag(string? tag)
{
if (string.IsNullOrEmpty(tag))
{
@@ -704,7 +633,7 @@ namespace Lantean.QBTMud.Services
return normalized.Trim();
}
private static TorrentSnapshot CreateSnapshot(Torrent torrent)
internal static TorrentSnapshot CreateSnapshot(Torrent torrent)
{
return new TorrentSnapshot(
string.IsNullOrEmpty(torrent.Category) ? null : torrent.Category,
@@ -714,7 +643,7 @@ namespace Lantean.QBTMud.Services
torrent.UploadSpeed);
}
private readonly struct TorrentSnapshot
internal readonly struct TorrentSnapshot
{
public TorrentSnapshot(string? category, List<string> tags, string tracker, string state, long uploadSpeed)
{
@@ -736,7 +665,7 @@ namespace Lantean.QBTMud.Services
public long UploadSpeed { get; }
}
private static void UpdateTagStateForAddition(MainData torrentList, Torrent torrent, string hash)
internal static void UpdateTagStateForAddition(MainData torrentList, Torrent torrent, string hash)
{
if (torrent.Tags.Count == 0)
{
@@ -756,7 +685,7 @@ namespace Lantean.QBTMud.Services
}
}
private static void UpdateTagStateForUpdate(MainData torrentList, string hash, IReadOnlyList<string> previousTags, IList<string> newTags)
internal static void UpdateTagStateForUpdate(MainData torrentList, string hash, IReadOnlyList<string> previousTags, IList<string> newTags)
{
UpdateTagStateForRemoval(torrentList, hash, previousTags);
@@ -778,7 +707,7 @@ namespace Lantean.QBTMud.Services
}
}
private static void UpdateTagStateForRemoval(MainData torrentList, string hash, IReadOnlyList<string> previousTags)
internal static void UpdateTagStateForRemoval(MainData torrentList, string hash, IReadOnlyList<string> previousTags)
{
torrentList.TagState[FilterHelper.TAG_UNTAGGED].Remove(hash);
@@ -796,7 +725,7 @@ namespace Lantean.QBTMud.Services
}
}
private static void UpdateCategoryState(MainData torrentList, Torrent updatedTorrent, string hash, string? previousCategory)
internal static void UpdateCategoryState(MainData torrentList, Torrent updatedTorrent, string hash, string? previousCategory)
{
var useSubcategories = torrentList.ServerState.UseSubcategories;
@@ -827,7 +756,7 @@ namespace Lantean.QBTMud.Services
}
}
private static void UpdateCategoryStateForRemoval(MainData torrentList, string hash, string? previousCategory)
internal static void UpdateCategoryStateForRemoval(MainData torrentList, string hash, string? previousCategory)
{
if (string.IsNullOrEmpty(previousCategory))
{
@@ -844,7 +773,7 @@ namespace Lantean.QBTMud.Services
}
}
private static void UpdateStatusState(MainData torrentList, string hash, string previousState, long previousUploadSpeed, string newState, long newUploadSpeed)
internal static void UpdateStatusState(MainData torrentList, string hash, string previousState, long previousUploadSpeed, string newState, long newUploadSpeed)
{
foreach (var status in GetStatuses())
{
@@ -873,7 +802,7 @@ namespace Lantean.QBTMud.Services
}
}
private static void UpdateTrackerState(MainData torrentList, Torrent updatedTorrent, string hash, string? previousTracker)
internal static void UpdateTrackerState(MainData torrentList, Torrent updatedTorrent, string hash, string? previousTracker)
{
if (!string.IsNullOrEmpty(previousTracker))
{
@@ -898,7 +827,7 @@ namespace Lantean.QBTMud.Services
GetOrCreateTrackerSet(torrentList, tracker).Add(hash);
}
private static void UpdateTrackerStateForRemoval(MainData torrentList, string hash, string? previousTracker)
internal static void UpdateTrackerStateForRemoval(MainData torrentList, string hash, string? previousTracker)
{
if (string.IsNullOrEmpty(previousTracker))
{
@@ -912,7 +841,7 @@ namespace Lantean.QBTMud.Services
}
}
private static IEnumerable<string> EnumerateCategoryKeys(string category, bool useSubcategories)
internal static IEnumerable<string> EnumerateCategoryKeys(string category, bool useSubcategories)
{
if (string.IsNullOrEmpty(category))
{
@@ -940,7 +869,7 @@ namespace Lantean.QBTMud.Services
}
}
private static HashSet<string> GetOrCreateTagSet(MainData torrentList, string tag)
internal static HashSet<string> GetOrCreateTagSet(MainData torrentList, string tag)
{
if (!torrentList.TagState.TryGetValue(tag, out var set))
{
@@ -951,7 +880,7 @@ namespace Lantean.QBTMud.Services
return set;
}
private static HashSet<string> GetOrCreateCategorySet(MainData torrentList, string category)
internal static HashSet<string> GetOrCreateCategorySet(MainData torrentList, string category)
{
if (!torrentList.CategoriesState.TryGetValue(category, out var set))
{
@@ -962,7 +891,7 @@ namespace Lantean.QBTMud.Services
return set;
}
private static HashSet<string> GetOrCreateTrackerSet(MainData torrentList, string tracker)
internal static HashSet<string> GetOrCreateTrackerSet(MainData torrentList, string tracker)
{
if (!torrentList.TrackersState.TryGetValue(tracker, out var set))
{
@@ -973,7 +902,7 @@ namespace Lantean.QBTMud.Services
return set;
}
private static bool UpdateCategory(Category existingCategory, QBitTorrentClient.Models.Category category)
internal static bool UpdateCategory(Category existingCategory, QBitTorrentClient.Models.Category category)
{
if (category.SavePath is not null && existingCategory.SavePath != category.SavePath)
{
@@ -984,7 +913,7 @@ namespace Lantean.QBTMud.Services
return false;
}
private readonly struct TorrentUpdateResult
internal readonly struct TorrentUpdateResult
{
public TorrentUpdateResult(bool dataChanged, bool filterChanged)
{
@@ -997,7 +926,7 @@ namespace Lantean.QBTMud.Services
public bool FilterChanged { get; }
}
private static TorrentUpdateResult UpdateTorrent(Torrent existingTorrent, QBitTorrentClient.Models.Torrent torrent)
internal static TorrentUpdateResult UpdateTorrent(Torrent existingTorrent, QBitTorrentClient.Models.Torrent torrent)
{
var dataChanged = false;
var filterChanged = false;
@@ -1314,6 +1243,41 @@ namespace Lantean.QBTMud.Services
dataChanged = true;
}
if (torrent.Popularity.HasValue && existingTorrent.Popularity != torrent.Popularity.Value)
{
existingTorrent.Popularity = torrent.Popularity.Value;
dataChanged = true;
}
if (torrent.DownloadPath is not null && !string.Equals(existingTorrent.DownloadPath, torrent.DownloadPath, StringComparison.Ordinal))
{
existingTorrent.DownloadPath = torrent.DownloadPath;
dataChanged = true;
}
if (torrent.RootPath is not null && !string.Equals(existingTorrent.RootPath, torrent.RootPath, StringComparison.Ordinal))
{
existingTorrent.RootPath = torrent.RootPath;
dataChanged = true;
}
if (torrent.IsPrivate.HasValue && existingTorrent.IsPrivate != torrent.IsPrivate.Value)
{
existingTorrent.IsPrivate = torrent.IsPrivate.Value;
dataChanged = true;
}
if (torrent.ShareLimitAction.HasValue && existingTorrent.ShareLimitAction != torrent.ShareLimitAction.Value)
{
existingTorrent.ShareLimitAction = torrent.ShareLimitAction.Value;
dataChanged = true;
}
if (torrent.Comment is not null && !string.Equals(existingTorrent.Comment, torrent.Comment, StringComparison.Ordinal))
{
existingTorrent.Comment = torrent.Comment;
dataChanged = true;
}
return new TorrentUpdateResult(dataChanged, filterChanged);
}
@@ -1439,7 +1403,7 @@ namespace Lantean.QBTMud.Services
return result;
}
private static bool UpdateContentItem(ContentItem destination, ContentItem source)
internal static bool UpdateContentItem(ContentItem destination, ContentItem source)
{
const float floatTolerance = 0.0001f;
var changed = false;
@@ -1450,7 +1414,7 @@ namespace Lantean.QBTMud.Services
changed = true;
}
if (System.Math.Abs(destination.Progress - source.Progress) > floatTolerance)
if (Math.Abs(destination.Progress - source.Progress) > floatTolerance)
{
destination.Progress = source.Progress;
changed = true;
@@ -1462,7 +1426,7 @@ namespace Lantean.QBTMud.Services
changed = true;
}
if (System.Math.Abs(destination.Availability - source.Availability) > floatTolerance)
if (Math.Abs(destination.Availability - source.Availability) > floatTolerance)
{
destination.Availability = source.Availability;
changed = true;
@@ -1560,425 +1524,6 @@ namespace Lantean.QBTMud.Services
public Dictionary<string, ContentTreeNode> Children { get; }
}
public QBitTorrentClient.Models.UpdatePreferences MergePreferences(QBitTorrentClient.Models.UpdatePreferences? original, QBitTorrentClient.Models.UpdatePreferences changed)
{
if (original is null)
{
original = new QBitTorrentClient.Models.UpdatePreferences
{
AddToTopOfQueue = changed.AddToTopOfQueue,
AddTrackers = changed.AddTrackers,
AddTrackersEnabled = changed.AddTrackersEnabled,
AltDlLimit = changed.AltDlLimit,
AltUpLimit = changed.AltUpLimit,
AlternativeWebuiEnabled = changed.AlternativeWebuiEnabled,
AlternativeWebuiPath = changed.AlternativeWebuiPath,
AnnounceIp = changed.AnnounceIp,
AnnounceToAllTiers = changed.AnnounceToAllTiers,
AnnounceToAllTrackers = changed.AnnounceToAllTrackers,
AnonymousMode = changed.AnonymousMode,
AsyncIoThreads = changed.AsyncIoThreads,
AutoDeleteMode = changed.AutoDeleteMode,
AutoTmmEnabled = changed.AutoTmmEnabled,
AutorunEnabled = changed.AutorunEnabled,
AutorunOnTorrentAddedEnabled = changed.AutorunOnTorrentAddedEnabled,
AutorunOnTorrentAddedProgram = changed.AutorunOnTorrentAddedProgram,
AutorunProgram = changed.AutorunProgram,
BannedIPs = changed.BannedIPs,
BdecodeDepthLimit = changed.BdecodeDepthLimit,
BdecodeTokenLimit = changed.BdecodeTokenLimit,
BittorrentProtocol = changed.BittorrentProtocol,
BlockPeersOnPrivilegedPorts = changed.BlockPeersOnPrivilegedPorts,
BypassAuthSubnetWhitelist = changed.BypassAuthSubnetWhitelist,
BypassAuthSubnetWhitelistEnabled = changed.BypassAuthSubnetWhitelistEnabled,
BypassLocalAuth = changed.BypassLocalAuth,
CategoryChangedTmmEnabled = changed.CategoryChangedTmmEnabled,
CheckingMemoryUse = changed.CheckingMemoryUse,
ConnectionSpeed = changed.ConnectionSpeed,
CurrentInterfaceAddress = changed.CurrentInterfaceAddress,
CurrentInterfaceName = changed.CurrentInterfaceName,
CurrentNetworkInterface = changed.CurrentNetworkInterface,
Dht = changed.Dht,
DiskCache = changed.DiskCache,
DiskCacheTtl = changed.DiskCacheTtl,
DiskIoReadMode = changed.DiskIoReadMode,
DiskIoType = changed.DiskIoType,
DiskIoWriteMode = changed.DiskIoWriteMode,
DiskQueueSize = changed.DiskQueueSize,
DlLimit = changed.DlLimit,
DontCountSlowTorrents = changed.DontCountSlowTorrents,
DyndnsDomain = changed.DyndnsDomain,
DyndnsEnabled = changed.DyndnsEnabled,
DyndnsPassword = changed.DyndnsPassword,
DyndnsService = changed.DyndnsService,
DyndnsUsername = changed.DyndnsUsername,
EmbeddedTrackerPort = changed.EmbeddedTrackerPort,
EmbeddedTrackerPortForwarding = changed.EmbeddedTrackerPortForwarding,
EnableCoalesceReadWrite = changed.EnableCoalesceReadWrite,
EnableEmbeddedTracker = changed.EnableEmbeddedTracker,
EnableMultiConnectionsFromSameIp = changed.EnableMultiConnectionsFromSameIp,
EnablePieceExtentAffinity = changed.EnablePieceExtentAffinity,
EnableUploadSuggestions = changed.EnableUploadSuggestions,
Encryption = changed.Encryption,
ExcludedFileNames = changed.ExcludedFileNames,
ExcludedFileNamesEnabled = changed.ExcludedFileNamesEnabled,
ExportDir = changed.ExportDir,
ExportDirFin = changed.ExportDirFin,
FileLogAge = changed.FileLogAge,
FileLogAgeType = changed.FileLogAgeType,
FileLogBackupEnabled = changed.FileLogBackupEnabled,
FileLogDeleteOld = changed.FileLogDeleteOld,
FileLogEnabled = changed.FileLogEnabled,
FileLogMaxSize = changed.FileLogMaxSize,
FileLogPath = changed.FileLogPath,
FilePoolSize = changed.FilePoolSize,
HashingThreads = changed.HashingThreads,
I2pAddress = changed.I2pAddress,
I2pEnabled = changed.I2pEnabled,
I2pInboundLength = changed.I2pInboundLength,
I2pInboundQuantity = changed.I2pInboundQuantity,
I2pMixedMode = changed.I2pMixedMode,
I2pOutboundLength = changed.I2pOutboundLength,
I2pOutboundQuantity = changed.I2pOutboundQuantity,
I2pPort = changed.I2pPort,
IdnSupportEnabled = changed.IdnSupportEnabled,
IncompleteFilesExt = changed.IncompleteFilesExt,
IpFilterEnabled = changed.IpFilterEnabled,
IpFilterPath = changed.IpFilterPath,
IpFilterTrackers = changed.IpFilterTrackers,
LimitLanPeers = changed.LimitLanPeers,
LimitTcpOverhead = changed.LimitTcpOverhead,
LimitUtpRate = changed.LimitUtpRate,
ListenPort = changed.ListenPort,
Locale = changed.Locale,
Lsd = changed.Lsd,
MailNotificationAuthEnabled = changed.MailNotificationAuthEnabled,
MailNotificationEmail = changed.MailNotificationEmail,
MailNotificationEnabled = changed.MailNotificationEnabled,
MailNotificationPassword = changed.MailNotificationPassword,
MailNotificationSender = changed.MailNotificationSender,
MailNotificationSmtp = changed.MailNotificationSmtp,
MailNotificationSslEnabled = changed.MailNotificationSslEnabled,
MailNotificationUsername = changed.MailNotificationUsername,
MaxActiveCheckingTorrents = changed.MaxActiveCheckingTorrents,
MaxActiveDownloads = changed.MaxActiveDownloads,
MaxActiveTorrents = changed.MaxActiveTorrents,
MaxActiveUploads = changed.MaxActiveUploads,
MaxConcurrentHttpAnnounces = changed.MaxConcurrentHttpAnnounces,
MaxConnec = changed.MaxConnec,
MaxConnecPerTorrent = changed.MaxConnecPerTorrent,
MaxInactiveSeedingTime = changed.MaxInactiveSeedingTime,
MaxInactiveSeedingTimeEnabled = changed.MaxInactiveSeedingTimeEnabled,
MaxRatio = changed.MaxRatio,
MaxRatioAct = changed.MaxRatioAct,
MaxRatioEnabled = changed.MaxRatioEnabled,
MaxSeedingTime = changed.MaxSeedingTime,
MaxSeedingTimeEnabled = changed.MaxSeedingTimeEnabled,
MaxUploads = changed.MaxUploads,
MaxUploadsPerTorrent = changed.MaxUploadsPerTorrent,
MemoryWorkingSetLimit = changed.MemoryWorkingSetLimit,
MergeTrackers = changed.MergeTrackers,
OutgoingPortsMax = changed.OutgoingPortsMax,
OutgoingPortsMin = changed.OutgoingPortsMin,
PeerTos = changed.PeerTos,
PeerTurnover = changed.PeerTurnover,
PeerTurnoverCutoff = changed.PeerTurnoverCutoff,
PeerTurnoverInterval = changed.PeerTurnoverInterval,
PerformanceWarning = changed.PerformanceWarning,
Pex = changed.Pex,
PreallocateAll = changed.PreallocateAll,
ProxyAuthEnabled = changed.ProxyAuthEnabled,
ProxyBittorrent = changed.ProxyBittorrent,
ProxyHostnameLookup = changed.ProxyHostnameLookup,
ProxyIp = changed.ProxyIp,
ProxyMisc = changed.ProxyMisc,
ProxyPassword = changed.ProxyPassword,
ProxyPeerConnections = changed.ProxyPeerConnections,
ProxyPort = changed.ProxyPort,
ProxyRss = changed.ProxyRss,
ProxyType = changed.ProxyType,
ProxyUsername = changed.ProxyUsername,
QueueingEnabled = changed.QueueingEnabled,
RandomPort = changed.RandomPort,
ReannounceWhenAddressChanged = changed.ReannounceWhenAddressChanged,
RecheckCompletedTorrents = changed.RecheckCompletedTorrents,
RefreshInterval = changed.RefreshInterval,
RequestQueueSize = changed.RequestQueueSize,
ResolvePeerCountries = changed.ResolvePeerCountries,
ResumeDataStorageType = changed.ResumeDataStorageType,
RssAutoDownloadingEnabled = changed.RssAutoDownloadingEnabled,
RssDownloadRepackProperEpisodes = changed.RssDownloadRepackProperEpisodes,
RssMaxArticlesPerFeed = changed.RssMaxArticlesPerFeed,
RssProcessingEnabled = changed.RssProcessingEnabled,
RssRefreshInterval = changed.RssRefreshInterval,
RssSmartEpisodeFilters = changed.RssSmartEpisodeFilters,
SavePath = changed.SavePath,
SavePathChangedTmmEnabled = changed.SavePathChangedTmmEnabled,
SaveResumeDataInterval = changed.SaveResumeDataInterval,
ScanDirs = changed.ScanDirs,
ScheduleFromHour = changed.ScheduleFromHour,
ScheduleFromMin = changed.ScheduleFromMin,
ScheduleToHour = changed.ScheduleToHour,
ScheduleToMin = changed.ScheduleToMin,
SchedulerDays = changed.SchedulerDays,
SchedulerEnabled = changed.SchedulerEnabled,
SendBufferLowWatermark = changed.SendBufferLowWatermark,
SendBufferWatermark = changed.SendBufferWatermark,
SendBufferWatermarkFactor = changed.SendBufferWatermarkFactor,
SlowTorrentDlRateThreshold = changed.SlowTorrentDlRateThreshold,
SlowTorrentInactiveTimer = changed.SlowTorrentInactiveTimer,
SlowTorrentUlRateThreshold = changed.SlowTorrentUlRateThreshold,
SocketBacklogSize = changed.SocketBacklogSize,
SocketReceiveBufferSize = changed.SocketReceiveBufferSize,
SocketSendBufferSize = changed.SocketSendBufferSize,
SsrfMitigation = changed.SsrfMitigation,
AddStoppedEnabled = changed.AddStoppedEnabled,
StopTrackerTimeout = changed.StopTrackerTimeout,
TempPath = changed.TempPath,
TempPathEnabled = changed.TempPathEnabled,
TorrentChangedTmmEnabled = changed.TorrentChangedTmmEnabled,
TorrentContentLayout = changed.TorrentContentLayout,
TorrentFileSizeLimit = changed.TorrentFileSizeLimit,
TorrentStopCondition = changed.TorrentStopCondition,
UpLimit = changed.UpLimit,
UploadChokingAlgorithm = changed.UploadChokingAlgorithm,
UploadSlotsBehavior = changed.UploadSlotsBehavior,
Upnp = changed.Upnp,
UpnpLeaseDuration = changed.UpnpLeaseDuration,
UseCategoryPathsInManualMode = changed.UseCategoryPathsInManualMode,
UseHttps = changed.UseHttps,
UseSubcategories = changed.UseSubcategories,
UtpTcpMixedMode = changed.UtpTcpMixedMode,
ValidateHttpsTrackerCertificate = changed.ValidateHttpsTrackerCertificate,
WebUiAddress = changed.WebUiAddress,
WebUiBanDuration = changed.WebUiBanDuration,
WebUiClickjackingProtectionEnabled = changed.WebUiClickjackingProtectionEnabled,
WebUiCsrfProtectionEnabled = changed.WebUiCsrfProtectionEnabled,
WebUiCustomHttpHeaders = changed.WebUiCustomHttpHeaders,
WebUiDomainList = changed.WebUiDomainList,
WebUiHostHeaderValidationEnabled = changed.WebUiHostHeaderValidationEnabled,
WebUiHttpsCertPath = changed.WebUiHttpsCertPath,
WebUiHttpsKeyPath = changed.WebUiHttpsKeyPath,
WebUiMaxAuthFailCount = changed.WebUiMaxAuthFailCount,
WebUiPort = changed.WebUiPort,
WebUiReverseProxiesList = changed.WebUiReverseProxiesList,
WebUiReverseProxyEnabled = changed.WebUiReverseProxyEnabled,
WebUiSecureCookieEnabled = changed.WebUiSecureCookieEnabled,
WebUiSessionTimeout = changed.WebUiSessionTimeout,
WebUiUpnp = changed.WebUiUpnp,
WebUiUseCustomHttpHeadersEnabled = changed.WebUiUseCustomHttpHeadersEnabled,
WebUiUsername = changed.WebUiUsername
};
}
else
{
original.AddToTopOfQueue = changed.AddToTopOfQueue ?? original.AddToTopOfQueue;
original.AddTrackers = changed.AddTrackers ?? original.AddTrackers;
original.AddTrackersEnabled = changed.AddTrackersEnabled ?? original.AddTrackersEnabled;
original.AltDlLimit = changed.AltDlLimit ?? original.AltDlLimit;
original.AltUpLimit = changed.AltUpLimit ?? original.AltUpLimit;
original.AlternativeWebuiEnabled = changed.AlternativeWebuiEnabled ?? original.AlternativeWebuiEnabled;
original.AlternativeWebuiPath = changed.AlternativeWebuiPath ?? original.AlternativeWebuiPath;
original.AnnounceIp = changed.AnnounceIp ?? original.AnnounceIp;
original.AnnounceToAllTiers = changed.AnnounceToAllTiers ?? original.AnnounceToAllTiers;
original.AnnounceToAllTrackers = changed.AnnounceToAllTrackers ?? original.AnnounceToAllTrackers;
original.AnonymousMode = changed.AnonymousMode ?? original.AnonymousMode;
original.AsyncIoThreads = changed.AsyncIoThreads ?? original.AsyncIoThreads;
original.AutoDeleteMode = changed.AutoDeleteMode ?? original.AutoDeleteMode;
original.AutoTmmEnabled = changed.AutoTmmEnabled ?? original.AutoTmmEnabled;
original.AutorunEnabled = changed.AutorunEnabled ?? original.AutorunEnabled;
original.AutorunOnTorrentAddedEnabled = changed.AutorunOnTorrentAddedEnabled ?? original.AutorunOnTorrentAddedEnabled;
original.AutorunOnTorrentAddedProgram = changed.AutorunOnTorrentAddedProgram ?? original.AutorunOnTorrentAddedProgram;
original.AutorunProgram = changed.AutorunProgram ?? original.AutorunProgram;
original.BannedIPs = changed.BannedIPs ?? original.BannedIPs;
original.BdecodeDepthLimit = changed.BdecodeDepthLimit ?? original.BdecodeDepthLimit;
original.BdecodeTokenLimit = changed.BdecodeTokenLimit ?? original.BdecodeTokenLimit;
original.BittorrentProtocol = changed.BittorrentProtocol ?? original.BittorrentProtocol;
original.BlockPeersOnPrivilegedPorts = changed.BlockPeersOnPrivilegedPorts ?? original.BlockPeersOnPrivilegedPorts;
original.BypassAuthSubnetWhitelist = changed.BypassAuthSubnetWhitelist ?? original.BypassAuthSubnetWhitelist;
original.BypassAuthSubnetWhitelistEnabled = changed.BypassAuthSubnetWhitelistEnabled ?? original.BypassAuthSubnetWhitelistEnabled;
original.BypassLocalAuth = changed.BypassLocalAuth ?? original.BypassLocalAuth;
original.CategoryChangedTmmEnabled = changed.CategoryChangedTmmEnabled ?? original.CategoryChangedTmmEnabled;
original.CheckingMemoryUse = changed.CheckingMemoryUse ?? original.CheckingMemoryUse;
original.ConnectionSpeed = changed.ConnectionSpeed ?? original.ConnectionSpeed;
original.CurrentInterfaceAddress = changed.CurrentInterfaceAddress ?? original.CurrentInterfaceAddress;
original.CurrentInterfaceName = changed.CurrentInterfaceName ?? original.CurrentInterfaceName;
original.CurrentNetworkInterface = changed.CurrentNetworkInterface ?? original.CurrentNetworkInterface;
original.Dht = changed.Dht ?? original.Dht;
original.DiskCache = changed.DiskCache ?? original.DiskCache;
original.DiskCacheTtl = changed.DiskCacheTtl ?? original.DiskCacheTtl;
original.DiskIoReadMode = changed.DiskIoReadMode ?? original.DiskIoReadMode;
original.DiskIoType = changed.DiskIoType ?? original.DiskIoType;
original.DiskIoWriteMode = changed.DiskIoWriteMode ?? original.DiskIoWriteMode;
original.DiskQueueSize = changed.DiskQueueSize ?? original.DiskQueueSize;
original.DlLimit = changed.DlLimit ?? original.DlLimit;
original.DontCountSlowTorrents = changed.DontCountSlowTorrents ?? original.DontCountSlowTorrents;
original.DyndnsDomain = changed.DyndnsDomain ?? original.DyndnsDomain;
original.DyndnsEnabled = changed.DyndnsEnabled ?? original.DyndnsEnabled;
original.DyndnsPassword = changed.DyndnsPassword ?? original.DyndnsPassword;
original.DyndnsService = changed.DyndnsService ?? original.DyndnsService;
original.DyndnsUsername = changed.DyndnsUsername ?? original.DyndnsUsername;
original.EmbeddedTrackerPort = changed.EmbeddedTrackerPort ?? original.EmbeddedTrackerPort;
original.EmbeddedTrackerPortForwarding = changed.EmbeddedTrackerPortForwarding ?? original.EmbeddedTrackerPortForwarding;
original.EnableCoalesceReadWrite = changed.EnableCoalesceReadWrite ?? original.EnableCoalesceReadWrite;
original.EnableEmbeddedTracker = changed.EnableEmbeddedTracker ?? original.EnableEmbeddedTracker;
original.EnableMultiConnectionsFromSameIp = changed.EnableMultiConnectionsFromSameIp ?? original.EnableMultiConnectionsFromSameIp;
original.EnablePieceExtentAffinity = changed.EnablePieceExtentAffinity ?? original.EnablePieceExtentAffinity;
original.EnableUploadSuggestions = changed.EnableUploadSuggestions ?? original.EnableUploadSuggestions;
original.Encryption = changed.Encryption ?? original.Encryption;
original.ExcludedFileNames = changed.ExcludedFileNames ?? original.ExcludedFileNames;
original.ExcludedFileNamesEnabled = changed.ExcludedFileNamesEnabled ?? original.ExcludedFileNamesEnabled;
original.ExportDir = changed.ExportDir ?? original.ExportDir;
original.ExportDirFin = changed.ExportDirFin ?? original.ExportDirFin;
original.FileLogAge = changed.FileLogAge ?? original.FileLogAge;
original.FileLogAgeType = changed.FileLogAgeType ?? original.FileLogAgeType;
original.FileLogBackupEnabled = changed.FileLogBackupEnabled ?? original.FileLogBackupEnabled;
original.FileLogDeleteOld = changed.FileLogDeleteOld ?? original.FileLogDeleteOld;
original.FileLogEnabled = changed.FileLogEnabled ?? original.FileLogEnabled;
original.FileLogMaxSize = changed.FileLogMaxSize ?? original.FileLogMaxSize;
original.FileLogPath = changed.FileLogPath ?? original.FileLogPath;
original.FilePoolSize = changed.FilePoolSize ?? original.FilePoolSize;
original.HashingThreads = changed.HashingThreads ?? original.HashingThreads;
original.I2pAddress = changed.I2pAddress ?? original.I2pAddress;
original.I2pEnabled = changed.I2pEnabled ?? original.I2pEnabled;
original.I2pInboundLength = changed.I2pInboundLength ?? original.I2pInboundLength;
original.I2pInboundQuantity = changed.I2pInboundQuantity ?? original.I2pInboundQuantity;
original.I2pMixedMode = changed.I2pMixedMode ?? original.I2pMixedMode;
original.I2pOutboundLength = changed.I2pOutboundLength ?? original.I2pOutboundLength;
original.I2pOutboundQuantity = changed.I2pOutboundQuantity ?? original.I2pOutboundQuantity;
original.I2pPort = changed.I2pPort ?? original.I2pPort;
original.IdnSupportEnabled = changed.IdnSupportEnabled ?? original.IdnSupportEnabled;
original.IncompleteFilesExt = changed.IncompleteFilesExt ?? original.IncompleteFilesExt;
original.IpFilterEnabled = changed.IpFilterEnabled ?? original.IpFilterEnabled;
original.IpFilterPath = changed.IpFilterPath ?? original.IpFilterPath;
original.IpFilterTrackers = changed.IpFilterTrackers ?? original.IpFilterTrackers;
original.LimitLanPeers = changed.LimitLanPeers ?? original.LimitLanPeers;
original.LimitTcpOverhead = changed.LimitTcpOverhead ?? original.LimitTcpOverhead;
original.LimitUtpRate = changed.LimitUtpRate ?? original.LimitUtpRate;
original.ListenPort = changed.ListenPort ?? original.ListenPort;
original.Locale = changed.Locale ?? original.Locale;
original.Lsd = changed.Lsd ?? original.Lsd;
original.MailNotificationAuthEnabled = changed.MailNotificationAuthEnabled ?? original.MailNotificationAuthEnabled;
original.MailNotificationEmail = changed.MailNotificationEmail ?? original.MailNotificationEmail;
original.MailNotificationEnabled = changed.MailNotificationEnabled ?? original.MailNotificationEnabled;
original.MailNotificationPassword = changed.MailNotificationPassword ?? original.MailNotificationPassword;
original.MailNotificationSender = changed.MailNotificationSender ?? original.MailNotificationSender;
original.MailNotificationSmtp = changed.MailNotificationSmtp ?? original.MailNotificationSmtp;
original.MailNotificationSslEnabled = changed.MailNotificationSslEnabled ?? original.MailNotificationSslEnabled;
original.MailNotificationUsername = changed.MailNotificationUsername ?? original.MailNotificationUsername;
original.MaxActiveCheckingTorrents = changed.MaxActiveCheckingTorrents ?? original.MaxActiveCheckingTorrents;
original.MaxActiveDownloads = changed.MaxActiveDownloads ?? original.MaxActiveDownloads;
original.MaxActiveTorrents = changed.MaxActiveTorrents ?? original.MaxActiveTorrents;
original.MaxActiveUploads = changed.MaxActiveUploads ?? original.MaxActiveUploads;
original.MaxConcurrentHttpAnnounces = changed.MaxConcurrentHttpAnnounces ?? original.MaxConcurrentHttpAnnounces;
original.MaxConnec = changed.MaxConnec ?? original.MaxConnec;
original.MaxConnecPerTorrent = changed.MaxConnecPerTorrent ?? original.MaxConnecPerTorrent;
original.MaxInactiveSeedingTime = changed.MaxInactiveSeedingTime ?? original.MaxInactiveSeedingTime;
original.MaxInactiveSeedingTimeEnabled = changed.MaxInactiveSeedingTimeEnabled ?? original.MaxInactiveSeedingTimeEnabled;
original.MaxRatio = changed.MaxRatio ?? original.MaxRatio;
original.MaxRatioAct = changed.MaxRatioAct ?? original.MaxRatioAct;
original.MaxRatioEnabled = changed.MaxRatioEnabled ?? original.MaxRatioEnabled;
original.MaxSeedingTime = changed.MaxSeedingTime ?? original.MaxSeedingTime;
original.MaxSeedingTimeEnabled = changed.MaxSeedingTimeEnabled ?? original.MaxSeedingTimeEnabled;
original.MaxUploads = changed.MaxUploads ?? original.MaxUploads;
original.MaxUploadsPerTorrent = changed.MaxUploadsPerTorrent ?? original.MaxUploadsPerTorrent;
original.MemoryWorkingSetLimit = changed.MemoryWorkingSetLimit ?? original.MemoryWorkingSetLimit;
original.MergeTrackers = changed.MergeTrackers ?? original.MergeTrackers;
original.OutgoingPortsMax = changed.OutgoingPortsMax ?? original.OutgoingPortsMax;
original.OutgoingPortsMin = changed.OutgoingPortsMin ?? original.OutgoingPortsMin;
original.PeerTos = changed.PeerTos ?? original.PeerTos;
original.PeerTurnover = changed.PeerTurnover ?? original.PeerTurnover;
original.PeerTurnoverCutoff = changed.PeerTurnoverCutoff ?? original.PeerTurnoverCutoff;
original.PeerTurnoverInterval = changed.PeerTurnoverInterval ?? original.PeerTurnoverInterval;
original.PerformanceWarning = changed.PerformanceWarning ?? original.PerformanceWarning;
original.Pex = changed.Pex ?? original.Pex;
original.PreallocateAll = changed.PreallocateAll ?? original.PreallocateAll;
original.ProxyAuthEnabled = changed.ProxyAuthEnabled ?? original.ProxyAuthEnabled;
original.ProxyBittorrent = changed.ProxyBittorrent ?? original.ProxyBittorrent;
original.ProxyHostnameLookup = changed.ProxyHostnameLookup ?? original.ProxyHostnameLookup;
original.ProxyIp = changed.ProxyIp ?? original.ProxyIp;
original.ProxyMisc = changed.ProxyMisc ?? original.ProxyMisc;
original.ProxyPassword = changed.ProxyPassword ?? original.ProxyPassword;
original.ProxyPeerConnections = changed.ProxyPeerConnections ?? original.ProxyPeerConnections;
original.ProxyPort = changed.ProxyPort ?? original.ProxyPort;
original.ProxyRss = changed.ProxyRss ?? original.ProxyRss;
original.ProxyType = changed.ProxyType ?? original.ProxyType;
original.ProxyUsername = changed.ProxyUsername ?? original.ProxyUsername;
original.QueueingEnabled = changed.QueueingEnabled ?? original.QueueingEnabled;
original.RandomPort = changed.RandomPort ?? original.RandomPort;
original.ReannounceWhenAddressChanged = changed.ReannounceWhenAddressChanged ?? original.ReannounceWhenAddressChanged;
original.RecheckCompletedTorrents = changed.RecheckCompletedTorrents ?? original.RecheckCompletedTorrents;
original.RefreshInterval = changed.RefreshInterval ?? original.RefreshInterval;
original.RequestQueueSize = changed.RequestQueueSize ?? original.RequestQueueSize;
original.ResolvePeerCountries = changed.ResolvePeerCountries ?? original.ResolvePeerCountries;
original.ResumeDataStorageType = changed.ResumeDataStorageType ?? original.ResumeDataStorageType;
original.RssAutoDownloadingEnabled = changed.RssAutoDownloadingEnabled ?? original.RssAutoDownloadingEnabled;
original.RssDownloadRepackProperEpisodes = changed.RssDownloadRepackProperEpisodes ?? original.RssDownloadRepackProperEpisodes;
original.RssMaxArticlesPerFeed = changed.RssMaxArticlesPerFeed ?? original.RssMaxArticlesPerFeed;
original.RssProcessingEnabled = changed.RssProcessingEnabled ?? original.RssProcessingEnabled;
original.RssRefreshInterval = changed.RssRefreshInterval ?? original.RssRefreshInterval;
original.RssSmartEpisodeFilters = changed.RssSmartEpisodeFilters ?? original.RssSmartEpisodeFilters;
original.SavePath = changed.SavePath ?? original.SavePath;
original.SavePathChangedTmmEnabled = changed.SavePathChangedTmmEnabled ?? original.SavePathChangedTmmEnabled;
original.SaveResumeDataInterval = changed.SaveResumeDataInterval ?? original.SaveResumeDataInterval;
original.ScanDirs = changed.ScanDirs ?? original.ScanDirs;
original.ScheduleFromHour = changed.ScheduleFromHour ?? original.ScheduleFromHour;
original.ScheduleFromMin = changed.ScheduleFromMin ?? original.ScheduleFromMin;
original.ScheduleToHour = changed.ScheduleToHour ?? original.ScheduleToHour;
original.ScheduleToMin = changed.ScheduleToMin ?? original.ScheduleToMin;
original.SchedulerDays = changed.SchedulerDays ?? original.SchedulerDays;
original.SchedulerEnabled = changed.SchedulerEnabled ?? original.SchedulerEnabled;
original.SendBufferLowWatermark = changed.SendBufferLowWatermark ?? original.SendBufferLowWatermark;
original.SendBufferWatermark = changed.SendBufferWatermark ?? original.SendBufferWatermark;
original.SendBufferWatermarkFactor = changed.SendBufferWatermarkFactor ?? original.SendBufferWatermarkFactor;
original.SlowTorrentDlRateThreshold = changed.SlowTorrentDlRateThreshold ?? original.SlowTorrentDlRateThreshold;
original.SlowTorrentInactiveTimer = changed.SlowTorrentInactiveTimer ?? original.SlowTorrentInactiveTimer;
original.SlowTorrentUlRateThreshold = changed.SlowTorrentUlRateThreshold ?? original.SlowTorrentUlRateThreshold;
original.SocketBacklogSize = changed.SocketBacklogSize ?? original.SocketBacklogSize;
original.SocketReceiveBufferSize = changed.SocketReceiveBufferSize ?? original.SocketReceiveBufferSize;
original.SocketSendBufferSize = changed.SocketSendBufferSize ?? original.SocketSendBufferSize;
original.SsrfMitigation = changed.SsrfMitigation ?? original.SsrfMitigation;
original.AddStoppedEnabled = changed.AddStoppedEnabled ?? original.AddStoppedEnabled;
original.StopTrackerTimeout = changed.StopTrackerTimeout ?? original.StopTrackerTimeout;
original.TempPath = changed.TempPath ?? original.TempPath;
original.TempPathEnabled = changed.TempPathEnabled ?? original.TempPathEnabled;
original.TorrentChangedTmmEnabled = changed.TorrentChangedTmmEnabled ?? original.TorrentChangedTmmEnabled;
original.TorrentContentLayout = changed.TorrentContentLayout ?? original.TorrentContentLayout;
original.TorrentFileSizeLimit = changed.TorrentFileSizeLimit ?? original.TorrentFileSizeLimit;
original.TorrentStopCondition = changed.TorrentStopCondition ?? original.TorrentStopCondition;
original.UpLimit = changed.UpLimit ?? original.UpLimit;
original.UploadChokingAlgorithm = changed.UploadChokingAlgorithm ?? original.UploadChokingAlgorithm;
original.UploadSlotsBehavior = changed.UploadSlotsBehavior ?? original.UploadSlotsBehavior;
original.Upnp = changed.Upnp ?? original.Upnp;
original.UpnpLeaseDuration = changed.UpnpLeaseDuration ?? original.UpnpLeaseDuration;
original.UseCategoryPathsInManualMode = changed.UseCategoryPathsInManualMode ?? original.UseCategoryPathsInManualMode;
original.UseHttps = changed.UseHttps ?? original.UseHttps;
original.UseSubcategories = changed.UseSubcategories ?? original.UseSubcategories;
original.UtpTcpMixedMode = changed.UtpTcpMixedMode ?? original.UtpTcpMixedMode;
original.ValidateHttpsTrackerCertificate = changed.ValidateHttpsTrackerCertificate ?? original.ValidateHttpsTrackerCertificate;
original.WebUiAddress = changed.WebUiAddress ?? original.WebUiAddress;
original.WebUiBanDuration = changed.WebUiBanDuration ?? original.WebUiBanDuration;
original.WebUiClickjackingProtectionEnabled = changed.WebUiClickjackingProtectionEnabled ?? original.WebUiClickjackingProtectionEnabled;
original.WebUiCsrfProtectionEnabled = changed.WebUiCsrfProtectionEnabled ?? original.WebUiCsrfProtectionEnabled;
original.WebUiCustomHttpHeaders = changed.WebUiCustomHttpHeaders ?? original.WebUiCustomHttpHeaders;
original.WebUiDomainList = changed.WebUiDomainList ?? original.WebUiDomainList;
original.WebUiHostHeaderValidationEnabled = changed.WebUiHostHeaderValidationEnabled ?? original.WebUiHostHeaderValidationEnabled;
original.WebUiHttpsCertPath = changed.WebUiHttpsCertPath ?? original.WebUiHttpsCertPath;
original.WebUiHttpsKeyPath = changed.WebUiHttpsKeyPath ?? original.WebUiHttpsKeyPath;
original.WebUiMaxAuthFailCount = changed.WebUiMaxAuthFailCount ?? original.WebUiMaxAuthFailCount;
original.WebUiPort = changed.WebUiPort ?? original.WebUiPort;
original.WebUiReverseProxiesList = changed.WebUiReverseProxiesList ?? original.WebUiReverseProxiesList;
original.WebUiReverseProxyEnabled = changed.WebUiReverseProxyEnabled ?? original.WebUiReverseProxyEnabled;
original.WebUiSecureCookieEnabled = changed.WebUiSecureCookieEnabled ?? original.WebUiSecureCookieEnabled;
original.WebUiSessionTimeout = changed.WebUiSessionTimeout ?? original.WebUiSessionTimeout;
original.WebUiUpnp = changed.WebUiUpnp ?? original.WebUiUpnp;
original.WebUiUseCustomHttpHeadersEnabled = changed.WebUiUseCustomHttpHeadersEnabled ?? original.WebUiUseCustomHttpHeadersEnabled;
original.WebUiUsername = changed.WebUiUsername ?? original.WebUiUsername;
}
return original;
}
public bool MergeContentsList(IReadOnlyList<QBitTorrentClient.Models.FileData> files, Dictionary<string, ContentItem> contents)
{
if (files.Count == 0)
@@ -2000,7 +1545,7 @@ namespace Lantean.QBTMud.Services
? int.MaxValue
: contents.Values.Min(c => c.Index);
var minFileIndex = files.Min(f => f.Index);
var nextFolderIndex = System.Math.Min(minExistingIndex, minFileIndex) - 1;
var nextFolderIndex = Math.Min(minExistingIndex, minFileIndex) - 1;
foreach (var file in files)
{
@@ -2094,38 +1639,5 @@ namespace Lantean.QBTMud.Services
return hasChanges;
}
public RssList CreateRssList(IReadOnlyDictionary<string, QBitTorrentClient.Models.RssItem> rssItems)
{
var articles = new List<RssArticle>();
var feeds = new Dictionary<string, RssFeed>();
foreach (var (key, rssItem) in rssItems)
{
feeds.Add(key, new RssFeed(rssItem.HasError, rssItem.IsLoading, rssItem.LastBuildDate, rssItem.Title, rssItem.Uid, rssItem.Url));
if (rssItem.Articles is null)
{
continue;
}
foreach (var rssArticle in rssItem.Articles)
{
var article = new RssArticle(
key,
rssArticle.Category,
rssArticle.Comments,
rssArticle.Date!,
rssArticle.Description,
rssArticle.Id!,
rssArticle.Link,
rssArticle.Thumbnail,
rssArticle.Title!,
rssArticle.TorrentURL!,
rssArticle.IsRead);
articles.Add(article);
}
}
return new RssList(feeds, articles);
}
}
}

View File

@@ -0,0 +1,318 @@
using System.Net;
using System.Text;
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientAddTorrentAndMetadataTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientAddTorrentAndMetadataTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler) { BaseAddress = new Uri("http://localhost/") };
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_OnlyUrls_WHEN_AddTorrent_THEN_ShouldPostMultipartWithUrlsNewlineSeparated()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/add");
req.Content.Should().BeOfType<MultipartFormDataContent>();
var parts = (req.Content as MultipartFormDataContent)!.ToList();
parts.Count.Should().Be(1);
var urlsPart = parts.Single();
urlsPart.Headers.ContentDisposition!.Name.Should().Be("urls");
(await urlsPart.ReadAsStringAsync()).Should().Be("u1\nu2");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
};
};
var p = new AddTorrentParams
{
Urls = new[] { "u1", "u2" }
};
var result = await _target.AddTorrent(p);
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_FilesAndOptions_WHEN_AddTorrent_THEN_ShouldIncludeAllExpectedParts()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/add");
req.Content.Should().BeOfType<MultipartFormDataContent>();
var parts = (req.Content as MultipartFormDataContent)!.ToList();
string Read(string name) =>
parts.Single(p => p.Headers.ContentDisposition!.Name == name)
.ReadAsStringAsync().GetAwaiter().GetResult();
parts.Any(p => p.Headers.ContentDisposition!.Name == "torrents" &&
p.Headers.ContentDisposition!.FileName == "a.torrent").Should().BeTrue();
parts.Any(p => p.Headers.ContentDisposition!.Name == "torrents" &&
p.Headers.ContentDisposition!.FileName == "b.torrent").Should().BeTrue();
Read("skip_checking").Should().Be("true");
Read("sequentialDownload").Should().Be("false");
Read("firstLastPiecePrio").Should().Be("true");
Read("addToTopOfQueue").Should().Be("true");
Read("forced").Should().Be("false");
Read("stopped").Should().Be("true");
Read("savepath").Should().Be("/save");
Read("downloadPath").Should().Be("/dl");
Read("useDownloadPath").Should().Be("true");
Read("category").Should().Be("Movies");
Read("tags").Should().Be("one,two");
Read("rename").Should().Be("renamed");
Read("upLimit").Should().Be("123");
Read("dlLimit").Should().Be("456");
Read("downloader").Should().Be("curl");
Read("filePriorities").Should().Be("0,1");
Read("ssl_certificate").Should().Be("cert");
Read("ssl_private_key").Should().Be("key");
Read("ssl_dh_params").Should().Be("dh");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
};
};
using var s1 = new MemoryStream(Encoding.UTF8.GetBytes("a"));
using var s2 = new MemoryStream(Encoding.UTF8.GetBytes("b"));
var p = new AddTorrentParams
{
Urls = null,
Torrents = new Dictionary<string, Stream> { { "a.torrent", (Stream)s1 }, { "b.torrent", (Stream)s2 } },
SkipChecking = true,
SequentialDownload = false,
FirstLastPiecePriority = true,
AddToTopOfQueue = true,
Forced = false,
Stopped = true,
SavePath = "/save",
DownloadPath = "/dl",
UseDownloadPath = true,
Category = "Movies",
Tags = new[] { "one", "two" },
RenameTorrent = "renamed",
UploadLimit = 123,
DownloadLimit = 456,
Downloader = "curl",
FilePriorities = new[] { (Priority)0, (Priority)1 },
SslCertificate = "cert",
SslPrivateKey = "key",
SslDhParams = "dh"
};
var result = await _target.AddTorrent(p);
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_ConflictAndEmptyMessage_WHEN_AddTorrent_THEN_ShouldThrowWithDefaultConflictMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Conflict)
{
Content = new StringContent(string.Empty)
});
var p = new AddTorrentParams { Urls = new[] { "u" } };
var act = async () => await _target.AddTorrent(p);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict);
ex.Which.Message.Should().Be("All torrents failed to add.");
}
[Fact]
public async Task GIVEN_ConflictWithMessage_WHEN_AddTorrent_THEN_ShouldThrowWithServerMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Conflict)
{
Content = new StringContent("some failed")
});
var p = new AddTorrentParams { Urls = new[] { "u" } };
var act = async () => await _target.AddTorrent(p);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict);
ex.Which.Message.Should().Be("some failed");
}
[Fact]
public async Task GIVEN_SuccessAndEmptyBody_WHEN_AddTorrent_THEN_ShouldReturnDefaultResultObject()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(string.Empty)
});
var p = new AddTorrentParams { Urls = new[] { "u" } };
var result = await _target.AddTorrent(p);
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_BaseAddressAndHash_WHEN_GetExportUrl_THEN_ShouldReturnFormattedUrl()
{
var result = await _target.GetExportUrl("abc123");
result.Should().Be("http://localhost/torrents/export?hash=abc123");
}
[Fact]
public async Task GIVEN_SourceOnly_WHEN_FetchMetadata_THEN_ShouldPostFormAndReturnNullOnEmpty()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/fetchMetadata");
var form = await req.Content!.ReadAsStringAsync(ct);
Uri.UnescapeDataString(form).Should().Be("source=magnet:?xt=urn:btih:abc");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(string.Empty)
};
};
var result = await _target.FetchMetadata("magnet:?xt=urn:btih:abc");
result.Should().BeNull();
}
[Fact]
public async Task GIVEN_SourceAndDownloader_WHEN_FetchMetadata_THEN_ShouldIncludeDownloaderAndDeserialize()
{
_handler.Responder = async (req, ct) =>
{
var decoded = Uri.UnescapeDataString(await req.Content!.ReadAsStringAsync(ct));
decoded.Should().Be("source=src&downloader=aria2");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
};
};
var result = await _target.FetchMetadata("src", "aria2");
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_TorrentStreams_WHEN_ParseMetadata_THEN_ShouldPostMultipartAndReturnList()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/parseMetadata");
var parts = (req.Content as MultipartFormDataContent)!.ToList();
parts.Count.Should().Be(2);
parts.All(p => p.Headers.ContentDisposition!.Name == "torrents").Should().BeTrue();
parts.Any(p => p.Headers.ContentDisposition!.FileName == "a.torrent").Should().BeTrue();
parts.Any(p => p.Headers.ContentDisposition!.FileName == "b.torrent").Should().BeTrue();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
using var s1 = new MemoryStream(Encoding.UTF8.GetBytes("a"));
using var s2 = new MemoryStream(Encoding.UTF8.GetBytes("b"));
var list = await _target.ParseMetadata(new[] { ("a.torrent", (Stream)s1), ("b.torrent", (Stream)s2) });
list.Should().NotBeNull();
list.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_BadJson_WHEN_ParseMetadata_THEN_ShouldReturnEmptyList()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("oops")
});
var list = await _target.ParseMetadata(Array.Empty<(string, Stream)>());
list.Should().NotBeNull();
list.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_ParseMetadata_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.ParseMetadata(Array.Empty<(string, Stream)>());
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_Source_WHEN_SaveMetadata_THEN_ShouldPostFormAndReturnBytes()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/saveMetadata");
var body = await req.Content!.ReadAsStringAsync(ct);
Uri.UnescapeDataString(body).Should().Be("source=magnet");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
};
};
var bytes = await _target.SaveMetadata("magnet");
bytes.Should().Equal(new byte[] { 1, 2, 3 });
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_SaveMetadata_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("no")
});
var act = async () => await _target.SaveMetadata("x");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("no");
}
}
}

View File

@@ -0,0 +1,348 @@
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
using System.Net;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientApplicationTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientApplicationTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost/")
};
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_OK_WHEN_GetApplicationVersion_THEN_ShouldReturnRawBody()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/app/version");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("4.6.0")
};
};
var result = await _target.GetApplicationVersion();
result.Should().Be("4.6.0");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetApplicationVersion_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = new StringContent("bad")
});
var act = async () => await _target.GetApplicationVersion();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadGateway);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_OK_WHEN_GetAPIVersion_THEN_ShouldReturnRawBody()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("2.10")
});
var result = await _target.GetAPIVersion();
result.Should().Be("2.10");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetAPIVersion_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("no")
});
var act = async () => await _target.GetAPIVersion();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex.Which.Message.Should().Be("no");
}
[Fact]
public async Task GIVEN_OKAndJson_WHEN_GetBuildInfo_THEN_ShouldDeserialize()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
var result = await _target.GetBuildInfo();
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetBuildInfo_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("missing")
});
var act = async () => await _target.GetBuildInfo();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound);
ex.Which.Message.Should().Be("missing");
}
[Fact]
public async Task GIVEN_OK_WHEN_Shutdown_THEN_ShouldPostAndNotThrow()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/app/shutdown");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
};
await _target.Shutdown();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_Shutdown_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent("busy")
});
var act = async () => await _target.Shutdown();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
ex.Which.Message.Should().Be("busy");
}
[Fact]
public async Task GIVEN_OKAndJson_WHEN_GetApplicationPreferences_THEN_ShouldDeserialize()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
var result = await _target.GetApplicationPreferences();
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetApplicationPreferences_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad prefs")
});
var act = async () => await _target.GetApplicationPreferences();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad prefs");
}
[Fact]
public async Task GIVEN_Preferences_WHEN_SetApplicationPreferences_THEN_ShouldPostJsonFormAndSucceed()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/app/setPreferences");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().StartWith("json=");
return new HttpResponseMessage(HttpStatusCode.OK);
};
var prefs = new UpdatePreferences();
await _target.SetApplicationPreferences(prefs);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_SetApplicationPreferences_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Conflict)
{
Content = new StringContent("conflict")
});
var prefs = new UpdatePreferences();
var act = async () => await _target.SetApplicationPreferences(prefs);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict);
ex.Which.Message.Should().Be("conflict");
}
[Fact]
public async Task GIVEN_OKAndJsonList_WHEN_GetApplicationCookies_THEN_ShouldReturnListOrEmptyOnBadJson()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("not json")
});
var result = await _target.GetApplicationCookies();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_ListOfCookies_WHEN_SetApplicationCookies_THEN_ShouldPostJsonArrayInForm()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/app/setCookies");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().StartWith("cookies=");
body.Should().Contain("%5B"); // '[' encoded
body.Should().Contain("%5D"); // ']' encoded
return new HttpResponseMessage(HttpStatusCode.OK);
};
var cookies = new List<ApplicationCookie>();
await _target.SetApplicationCookies(cookies);
}
[Fact]
public async Task GIVEN_OKEmptyBody_WHEN_RotateApiKey_THEN_ShouldReturnEmptyString()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(string.Empty)
});
var result = await _target.RotateApiKey();
result.Should().Be(string.Empty);
}
[Fact]
public async Task GIVEN_ObjectWithApiKey_WHEN_RotateApiKey_THEN_ShouldReturnApiKeyValue()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"apiKey\":\"abcd1234\"}")
});
var result = await _target.RotateApiKey();
result.Should().Be("abcd1234");
}
[Fact]
public async Task GIVEN_ObjectWithoutApiKey_WHEN_RotateApiKey_THEN_ShouldReturnEmptyString()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
var result = await _target.RotateApiKey();
result.Should().Be(string.Empty);
}
[Fact]
public async Task GIVEN_NonObjectJson_WHEN_RotateApiKey_THEN_ShouldReturnEmptyString()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
var result = await _target.RotateApiKey();
result.Should().Be(string.Empty);
}
[Fact]
public async Task GIVEN_OK_WHEN_GetDefaultSavePath_THEN_ShouldReturnRawBody()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("/data/downloads")
});
var result = await _target.GetDefaultSavePath();
result.Should().Be("/data/downloads");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetDefaultSavePath_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("nope")
});
var act = async () => await _target.GetDefaultSavePath();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("nope");
}
[Fact]
public async Task GIVEN_BadJson_WHEN_GetNetworkInterfaces_THEN_ShouldReturnEmptyList()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("not json")
});
var result = await _target.GetNetworkInterfaces();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_OKJsonArrayOfStrings_WHEN_GetNetworkInterfaceAddressList_THEN_ShouldDeserializeStrings()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/app/networkInterfaceAddressList?iface=eth0");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[\"192.168.1.10\",\"fe80::1\"]")
});
};
var result = await _target.GetNetworkInterfaceAddressList("eth0");
result.Should().NotBeNull();
result.Count.Should().Be(2);
result[0].Should().Be("192.168.1.10");
result[1].Should().Be("fe80::1");
}
}
}

View File

@@ -0,0 +1,144 @@
using System.Net;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public partial class ApiClientAuthenticationTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientAuthenticationTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost/")
};
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_ServerReturnsOK_WHEN_CheckAuthState_THEN_ShouldBeTrue()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/app/version");
return new HttpResponseMessage(HttpStatusCode.OK);
};
var result = await _target.CheckAuthState();
result.Should().BeTrue();
}
[Fact]
public async Task GIVEN_ServerReturnsNonOK_WHEN_CheckAuthState_THEN_ShouldBeFalse()
{
_handler.Responder = async (req, ct) =>
{
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
};
var result = await _target.CheckAuthState();
result.Should().BeFalse();
}
[Fact]
public async Task GIVEN_HandlerThrows_WHEN_CheckAuthState_THEN_ShouldBeFalse()
{
_handler.Responder = (_, _) => throw new HttpRequestException("boom", null, HttpStatusCode.BadGateway);
var result = await _target.CheckAuthState();
result.Should().BeFalse();
}
[Fact]
public async Task GIVEN_ValidCredentialsAndSuccessStatus_WHEN_Login_THEN_ShouldPostFormAndNotThrow()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/auth/login");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("username=user&password=pass");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("Ok")
};
};
await _target.Login("user", "pass");
}
[Fact]
public async Task GIVEN_SuccessStatusButFailsBody_WHEN_Login_THEN_ShouldThrowBadRequest()
{
_handler.Responder = async (req, ct) =>
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("Fails.")
};
};
var act = async () => await _target.Login("user", "pass");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task GIVEN_NonSuccessStatus_WHEN_Login_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = async (req, ct) =>
{
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = new StringContent("Nope")
};
};
var act = async () => await _target.Login("user", "pass");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
ex.Which.Message.Should().Be("Nope");
}
[Fact]
public async Task GIVEN_Success_WHEN_Logout_THEN_ShouldPostAndNotThrow()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/auth/logout");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.Logout();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_Logout_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = async (req, ct) =>
{
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("fail")
};
};
var act = async () => await _target.Logout();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex.Which.Message.Should().Be("fail");
}
}
}

View File

@@ -0,0 +1,143 @@
using System.Net;
using System.Text.Json;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientClientDataTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientClientDataTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost/")
};
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_NoKeys_WHEN_LoadClientData_THEN_ShouldGETAndReturnDictionary()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/clientdata/load");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"k1\":1,\"k2\":2}")
};
};
var result = await _target.LoadClientData();
result.Should().NotBeNull();
result.Count.Should().Be(2);
result["k1"].GetInt32().Should().Be(1);
result["k2"].GetInt32().Should().Be(2);
}
[Fact]
public async Task GIVEN_Keys_WHEN_LoadClientData_THEN_ShouldEncodeKeysAsJsonQuery()
{
var keys = new[] { "alpha", "beta gamma" };
var expectedJson = JsonSerializer.Serialize(keys);
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/clientdata/load");
var query = req.RequestUri!.Query.TrimStart('?');
query.Should().StartWith("keys=");
var encoded = query.Substring("keys=".Length);
var decoded = Uri.UnescapeDataString(encoded);
decoded.Should().Be(expectedJson);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
};
};
var result = await _target.LoadClientData(keys);
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_LoadClientData_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("err")
});
var act = async () => await _target.LoadClientData();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex.Which.Message.Should().Be("err");
}
[Fact]
public async Task GIVEN_InvalidJson_WHEN_LoadClientData_THEN_ShouldReturnEmptyDictionary()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("not json")
});
var result = await _target.LoadClientData();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_Data_WHEN_StoreClientData_THEN_ShouldPostFormWithJson()
{
var data = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>("{\"a\":1,\"b\":\"x\"}")!;
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/clientdata/store");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().StartWith("data=");
var encoded = body.Substring("data=".Length);
var json = Uri.UnescapeDataString(encoded);
var roundTrip = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!;
roundTrip["a"].GetInt32().Should().Be(1);
roundTrip["b"].GetString().Should().Be("x");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.StoreClientData(data);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_StoreClientData_THEN_ShouldThrow()
{
var data = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>("{\"a\":1}")!;
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.StoreClientData(data);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
}
}

View File

@@ -0,0 +1,145 @@
using System.Net;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientLogTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientLogTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost/")
};
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_NoFilters_WHEN_GetLog_THEN_ShouldGETWithoutQueryAndReturnList()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/log/main");
req.RequestUri!.Query.Should().BeEmpty();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetLog();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_AllFilters_WHEN_GetLog_THEN_ShouldIncludeAllQueryParams()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/log/main");
req.RequestUri!.Query.Should().Be("?normal=true&info=false&warning=true&critical=false&last_known_id=123");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetLog(normal: true, info: false, warning: true, critical: false, lastKnownId: 123);
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_BadJson_WHEN_GetLog_THEN_ShouldReturnEmptyList()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("not json")
});
var result = await _target.GetLog();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetLog_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("boom")
});
var act = async () => await _target.GetLog();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex.Which.Message.Should().Be("boom");
}
[Fact]
public async Task GIVEN_NoLastKnownId_WHEN_GetPeerLog_THEN_ShouldGETWithoutQueryAndReturnList()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/log/peers");
req.RequestUri!.Query.Should().BeEmpty();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetPeerLog();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_LastKnownId_WHEN_GetPeerLog_THEN_ShouldIncludeQueryParam()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/log/peers");
req.RequestUri!.Query.Should().Be("?last_known_id=77");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetPeerLog(77);
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetPeerLog_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = new StringContent("fail")
});
var act = async () => await _target.GetPeerLog();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadGateway);
ex.Which.Message.Should().Be("fail");
}
}
}

View File

@@ -0,0 +1,333 @@
using System.Net;
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientRssTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientRssTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler) { BaseAddress = new Uri("http://localhost/") };
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_Path_WHEN_AddRssFolder_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/rss/addFolder");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("path=%2Ffeeds%2Ftv");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.AddRssFolder("/feeds/tv");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_AddRssFolder_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Conflict)
{
Content = new StringContent("exists")
});
var act = async () => await _target.AddRssFolder("/x");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict);
ex.Which.Message.Should().Be("exists");
}
[Fact]
public async Task GIVEN_UrlOnly_WHEN_AddRssFeed_THEN_ShouldPOSTUrlWithoutPath()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/rss/addFeed");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("url=http%3A%2F%2Ffeed");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.AddRssFeed("http://feed");
}
[Fact]
public async Task GIVEN_UrlAndPath_WHEN_AddRssFeed_THEN_ShouldIncludeBoth()
{
_handler.Responder = async (req, ct) =>
{
var decoded = Uri.UnescapeDataString(await req.Content!.ReadAsStringAsync(ct));
decoded.Should().Be("url=http://feed&path=/podcasts");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.AddRssFeed("http://feed", "/podcasts");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_AddRssFeed_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.AddRssFeed("u");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_Path_WHEN_RemoveRssItem_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/rss/removeItem");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("path=%2Ffeeds%2Ftv");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RemoveRssItem("/feeds/tv");
}
[Fact]
public async Task GIVEN_ItemAndDest_WHEN_MoveRssItem_THEN_ShouldPOSTBoth()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/rss/moveItem");
var decoded = Uri.UnescapeDataString(await req.Content!.ReadAsStringAsync(ct));
decoded.Should().Be("itemPath=/feeds/tv&destPath=/feeds/news");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.MoveRssItem("/feeds/tv", "/feeds/news");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_MoveRssItem_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("nope")
});
var act = async () => await _target.MoveRssItem("/a", "/b");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("nope");
}
[Fact]
public async Task GIVEN_NoFlag_WHEN_GetAllRssItems_THEN_ShouldGETWithoutQueryAndReturnDict()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/rss/items");
req.RequestUri!.Query.Should().BeEmpty();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
};
var dict = await _target.GetAllRssItems();
dict.Should().NotBeNull();
dict.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_WithDataTrue_WHEN_GetAllRssItems_THEN_ShouldQueryWithTrueCapitalized()
{
_handler.Responder = (req, _) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/rss/items?withData=True");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
};
var dict = await _target.GetAllRssItems(true);
dict.Should().NotBeNull();
dict.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetAllRssItems_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("err")
});
var act = async () => await _target.GetAllRssItems();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex.Which.Message.Should().Be("err");
}
[Fact]
public async Task GIVEN_ItemPathOnly_WHEN_MarkRssItemAsRead_THEN_ShouldPOSTOnlyItemPath()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/rss/markAsRead");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("itemPath=%2Ffeeds%2Ftv");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.MarkRssItemAsRead("/feeds/tv");
}
[Fact]
public async Task GIVEN_ArticleId_WHEN_MarkRssItemAsRead_THEN_ShouldIncludeArticleId()
{
_handler.Responder = async (req, ct) =>
{
var decoded = Uri.UnescapeDataString(await req.Content!.ReadAsStringAsync(ct));
decoded.Should().Be("itemPath=/feeds/tv&articleId=a1");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.MarkRssItemAsRead("/feeds/tv", "a1");
}
[Fact]
public async Task GIVEN_ItemPath_WHEN_RefreshRssItem_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/rss/refreshItem");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("itemPath=%2Ffeeds%2Ftv");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RefreshRssItem("/feeds/tv");
}
[Fact]
public async Task GIVEN_Rule_WHEN_SetRssAutoDownloadingRule_THEN_ShouldPOSTRuleNameAndRuleDefJson()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/rss/setRule");
var decoded = Uri.UnescapeDataString(await req.Content!.ReadAsStringAsync(ct));
decoded.Should().StartWith("ruleName=r1&ruleDef=");
var json = decoded.Substring("ruleName=r1&ruleDef=".Length);
var expectedJson = System.Text.Json.JsonSerializer.Serialize(new AutoDownloadingRule());
json.Should().Be(expectedJson);
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
};
await _target.SetRssAutoDownloadingRule("r1", new AutoDownloadingRule());
}
[Fact]
public async Task GIVEN_RuleNames_WHEN_RenameRssAutoDownloadingRule_THEN_ShouldPOSTBothNames()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/rss/renameRule");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("ruleName=old&newRuleName=new");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RenameRssAutoDownloadingRule("old", "new");
}
[Fact]
public async Task GIVEN_RuleName_WHEN_RemoveRssAutoDownloadingRule_THEN_ShouldPOSTName()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/rss/removeRule");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("ruleName=dead");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RemoveRssAutoDownloadingRule("dead");
}
[Fact]
public async Task GIVEN_OKOrBadJson_WHEN_GetAllRssAutoDownloadingRules_THEN_ShouldDeserializeOrReturnEmpty()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
var dict = await _target.GetAllRssAutoDownloadingRules();
dict.Should().NotBeNull();
dict.Count.Should().Be(0);
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("bad")
});
var empty = await _target.GetAllRssAutoDownloadingRules();
empty.Should().NotBeNull();
empty.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_RuleName_WHEN_GetRssMatchingArticles_THEN_ShouldGETAndReturnDictionaryOfLists()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/rss/matchingArticles?ruleName=myrule");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"feed1\":[\"a\",\"b\"]}")
});
};
var dict = await _target.GetRssMatchingArticles("myrule");
dict.Should().NotBeNull();
dict.Count.Should().Be(1);
dict["feed1"].Count.Should().Be(2);
dict["feed1"][0].Should().Be("a");
dict["feed1"][1].Should().Be("b");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetRssMatchingArticles_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = new StringContent("fail")
});
var act = async () => await _target.GetRssMatchingArticles("x");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadGateway);
ex.Which.Message.Should().Be("fail");
}
}
}

View File

@@ -0,0 +1,373 @@
using System.Net;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientSearchTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientSearchTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler) { BaseAddress = new Uri("http://localhost/") };
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_PatternAndPlugins_WHEN_StartSearch_THEN_ShouldPOSTFormAndReturnId()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/search/start");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("pattern=My+pattern&plugins=a%7Cb%7Cc&category=all");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"id\":123}")
};
};
var id = await _target.StartSearch("My pattern", new[] { "a", "b", "c" });
id.Should().Be(123);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_StartSearch_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.StartSearch("p", new[] { "x" });
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_Id_WHEN_StopSearch_THEN_ShouldPOSTFormWithId()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/search/stop");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("id=77");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.StopSearch(77);
}
[Fact]
public async Task GIVEN_Id_WHEN_GetSearchStatus_THEN_ShouldGETWithIdAndReturnNullOnEmpty()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/search/status?id=5");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var status = await _target.GetSearchStatus(5);
status.Should().BeNull();
}
[Fact]
public async Task GIVEN_NotFound_WHEN_GetSearchStatus_THEN_ShouldReturnNull()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
var status = await _target.GetSearchStatus(1);
status.Should().BeNull();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetSearchStatus_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("nope")
});
var act = async () => await _target.GetSearchStatus(2);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("nope");
}
[Fact]
public async Task GIVEN_Request_WHEN_GetSearchesStatus_THEN_ShouldGETAndReturnListOrEmptyOnBadJson()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
var list = await _target.GetSearchesStatus();
list.Should().NotBeNull();
list.Count.Should().Be(0);
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("oops")
});
var empty = await _target.GetSearchesStatus();
empty.Should().NotBeNull();
empty.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_IdOnly_WHEN_GetSearchResults_THEN_ShouldGETWithIdOnly()
{
_handler.Responder = (req, _) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/search/results?id=9");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
};
var results = await _target.GetSearchResults(9);
results.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_LimitAndOffset_WHEN_GetSearchResults_THEN_ShouldGETWithAllParamsInOrder()
{
_handler.Responder = (req, _) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/search/results?id=9&limit=50&offset=100");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
};
var results = await _target.GetSearchResults(9, limit: 50, offset: 100);
results.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetSearchResults_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("err")
});
var act = async () => await _target.GetSearchResults(1);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex.Which.Message.Should().Be("err");
}
[Fact]
public async Task GIVEN_Id_WHEN_DeleteSearch_THEN_ShouldPOSTFormWithId()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/search/delete");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("id=3");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.DeleteSearch(3);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_DeleteSearch_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.DeleteSearch(3);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_Request_WHEN_GetSearchPlugins_THEN_ShouldGETAndReturnListOrEmptyOnBadJson()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
var list = await _target.GetSearchPlugins();
list.Should().NotBeNull();
list.Count.Should().Be(0);
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("bad")
});
var empty = await _target.GetSearchPlugins();
empty.Should().NotBeNull();
empty.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_Sources_WHEN_InstallSearchPlugins_THEN_ShouldPOSTPipeSeparatedSources()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/search/installPlugin");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("sources=s1%7Cs2");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.InstallSearchPlugins("s1", "s2");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_InstallSearchPlugins_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Conflict)
{
Content = new StringContent("conflict")
});
var act = async () => await _target.InstallSearchPlugins("s");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict);
ex.Which.Message.Should().Be("conflict");
}
[Fact]
public async Task GIVEN_Names_WHEN_UninstallSearchPlugins_THEN_ShouldPOSTPipeSeparatedNames()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/search/uninstallPlugin");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("names=p1%7Cp2");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.UninstallSearchPlugins("p1", "p2");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_UninstallSearchPlugins_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("nope")
});
var act = async () => await _target.UninstallSearchPlugins("p");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("nope");
}
[Fact]
public async Task GIVEN_Names_WHEN_EnableSearchPlugins_THEN_ShouldPOSTNamesAndEnableTrue()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/search/enablePlugin");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("names=p1%7Cp2&enable=true");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.EnableSearchPlugins("p1", "p2");
}
[Fact]
public async Task GIVEN_Names_WHEN_DisableSearchPlugins_THEN_ShouldPOSTNamesAndEnableFalse()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/search/enablePlugin");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("names=p1%7Cp2&enable=false");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.DisableSearchPlugins("p1", "p2");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_EnableOrDisableSearchPlugins_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("err")
});
var act1 = async () => await _target.EnableSearchPlugins("p");
var ex1 = await act1.Should().ThrowAsync<HttpRequestException>();
ex1.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex1.Which.Message.Should().Be("err");
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("err2")
});
var act2 = async () => await _target.DisableSearchPlugins("p");
var ex2 = await act2.Should().ThrowAsync<HttpRequestException>();
ex2.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex2.Which.Message.Should().Be("err2");
}
[Fact]
public async Task GIVEN_Request_WHEN_UpdateSearchPlugins_THEN_ShouldPOSTAndNotThrow()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/search/updatePlugins");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
};
await _target.UpdateSearchPlugins();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_UpdateSearchPlugins_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = new StringContent("bad")
});
var act = async () => await _target.UpdateSearchPlugins();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadGateway);
ex.Which.Message.Should().Be("bad");
}
}
}

View File

@@ -0,0 +1,113 @@
using System.Net;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientSyncTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientSyncTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost/")
};
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_RequestId_WHEN_GetMainData_THEN_ShouldGETWithRidAndDeserialize()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/sync/maindata?rid=123");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
};
var result = await _target.GetMainData(123);
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetMainData_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = new StringContent("bad")
});
var act = async () => await _target.GetMainData(1);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadGateway);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_NullJsonBody_WHEN_GetMainData_THEN_ShouldThrowInvalidOperation()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null")
});
var act = async () => await _target.GetMainData(5);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task GIVEN_HashAndRid_WHEN_GetTorrentPeersData_THEN_ShouldGETWithParamsAndDeserialize()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/sync/torrentPeers?hash=abcdef&rid=7");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
};
var result = await _target.GetTorrentPeersData("abcdef", 7);
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetTorrentPeersData_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("missing")
});
var act = async () => await _target.GetTorrentPeersData("abc", 1);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound);
ex.Which.Message.Should().Be("missing");
}
[Fact]
public async Task GIVEN_NullJsonBody_WHEN_GetTorrentPeersData_THEN_ShouldThrowInvalidOperation()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null")
});
var act = async () => await _target.GetTorrentPeersData("abc", 1);
await act.Should().ThrowAsync<InvalidOperationException>();
}
}
}

View File

@@ -0,0 +1,114 @@
using System.Net;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientTorrentAutoAndRenameTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientTorrentAutoAndRenameTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler) { BaseAddress = new Uri("http://localhost/") };
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_EnableTrueAndHashes_WHEN_SetAutomaticTorrentManagement_THEN_ShouldPostEnableAndHashes()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/setAutoManagement");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=h1%7Ch2&enable=true");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetAutomaticTorrentManagement(true, false, "h1", "h2");
}
[Fact]
public async Task GIVEN_AllTrue_WHEN_ToggleSequentialDownload_THEN_ShouldOnlyPostHashesAll()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/toggleSequentialDownload");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("hashes=all");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.ToggleSequentialDownload(true);
}
[Fact]
public async Task GIVEN_Hashes_WHEN_SetFirstLastPiecePriority_THEN_ShouldOnlyPostHashes()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/toggleFirstLastPiecePrio");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("hashes=h");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetFirstLastPiecePriority(false, "h");
}
[Fact]
public async Task GIVEN_ValueTrue_WHEN_SetForceStart_THEN_ShouldPostValueAndHashes()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/setForceStart");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=h1%7Ch2&value=true");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetForceStart(true, false, "h1", "h2");
}
[Fact]
public async Task GIVEN_ValueFalse_WHEN_SetSuperSeeding_THEN_ShouldPostValueAndAll()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/setSuperSeeding");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=all&value=false");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetSuperSeeding(false, true);
}
[Fact]
public async Task GIVEN_RenameFile_WHEN_RenameFile_THEN_ShouldPostHashAndPaths()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/renameFile");
var decoded = Uri.UnescapeDataString(await req.Content!.ReadAsStringAsync(ct));
decoded.Should().Be("hash=h&oldPath=old/name&newPath=new/name");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RenameFile("h", "old/name", "new/name");
}
[Fact]
public async Task GIVEN_RenameFolder_WHEN_RenameFolder_THEN_ShouldPostHashAndPaths()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/renameFolder");
var decoded = Uri.UnescapeDataString(await req.Content!.ReadAsStringAsync(ct));
decoded.Should().Be("hash=h&oldPath=old/folder&newPath=new/folder");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RenameFolder("h", "old/folder", "new/folder");
}
}
}

View File

@@ -0,0 +1,138 @@
using System.Net;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientTorrentBasicActionsTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientTorrentBasicActionsTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost/")
};
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_LocationAndHashes_WHEN_SetTorrentLocation_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/setLocation");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=h1%7Ch2&location=%2Fdata%2Fdl");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetTorrentLocation("/data/dl", false, "h1", "h2");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_SetTorrentLocation_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.SetTorrentLocation("/x");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_NameAndHash_WHEN_SetTorrentName_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/rename");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hash=hx&name=My+Torrent"); // spaces => '+'
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetTorrentName("My Torrent", "hx");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_SetTorrentName_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Conflict)
{
Content = new StringContent("exists")
});
var act = async () => await _target.SetTorrentName("n", "h");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict);
ex.Which.Message.Should().Be("exists");
}
[Fact]
public async Task GIVEN_HashesAndComment_WHEN_SetTorrentComment_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/setComment");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=h1%7Ch2&comment=hello+world");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetTorrentComment(new[] { "h1", "h2" }, "hello world");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_SetTorrentComment_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("forbidden")
});
var act = async () => await _target.SetTorrentComment(Array.Empty<string>(), "x");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("forbidden");
}
[Fact]
public async Task GIVEN_CategoryAndHashes_WHEN_SetTorrentCategory_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/setCategory");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=h1%7Ch2&category=Movies");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetTorrentCategory("Movies", false, "h1", "h2");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_SetTorrentCategory_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = new StringContent("bad")
});
var act = async () => await _target.SetTorrentCategory("c");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadGateway);
ex.Which.Message.Should().Be("bad");
}
}
}

View File

@@ -0,0 +1,154 @@
using System.Net;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientTorrentCategoriesAndTagsTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientTorrentCategoriesAndTagsTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler) { BaseAddress = new Uri("http://localhost/") };
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_OKJson_WHEN_GetAllCategories_THEN_ShouldDeserializeOrEmptyOnBadJson()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
var result = await _target.GetAllCategories();
result.Should().NotBeNull();
result.Count.Should().Be(0);
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("bad")
});
var empty = await _target.GetAllCategories();
empty.Should().NotBeNull();
empty.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_CategoryAndPath_WHEN_AddCategory_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/createCategory");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("category=Movies&savePath=%2Fdata");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.AddCategory("Movies", "/data");
}
[Fact]
public async Task GIVEN_CategoryAndPath_WHEN_EditCategory_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/editCategory");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("category=Shows&savePath=%2Ftv");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.EditCategory("Shows", "/tv");
}
[Fact]
public async Task GIVEN_Categories_WHEN_RemoveCategories_THEN_ShouldPOSTNewlineSeparated()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/removeCategories");
var decoded = Uri.UnescapeDataString(await req.Content!.ReadAsStringAsync(ct));
decoded.Should().Be("categories=a\nb\nc");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RemoveCategories("a", "b", "c");
}
[Fact]
public async Task GIVEN_TagsAndHashes_WHEN_AddTorrentTags_THEN_ShouldCSVAndEncode()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/addTags");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=h1%7Ch2&tags=one%2Ctwo%2Cthree");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.AddTorrentTags(new[] { "one", "two", "three" }, false, "h1", "h2");
}
[Fact]
public async Task GIVEN_TagsAndAllTrue_WHEN_RemoveTorrentTags_THEN_ShouldCSVAndAll()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/removeTags");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=all&tags=a%2Cb");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RemoveTorrentTags(new[] { "a", "b" }, true);
}
[Fact]
public async Task GIVEN_OKJson_WHEN_GetAllTags_THEN_ShouldDeserializeList()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[\"x\",\"y\"]")
});
var result = await _target.GetAllTags();
result.Should().NotBeNull();
result.Count.Should().Be(2);
result[0].Should().Be("x");
result[1].Should().Be("y");
}
[Fact]
public async Task GIVEN_Tags_WHEN_CreateTags_THEN_ShouldCSV()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/createTags");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("tags=a%2Cb%2Cc");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.CreateTags(new[] { "a", "b", "c" });
}
[Fact]
public async Task GIVEN_Tags_WHEN_DeleteTags_THEN_ShouldCSV()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/deleteTags");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("tags=a%2Cb");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.DeleteTags("a", "b");
}
}
}

View File

@@ -0,0 +1,288 @@
using System.Net;
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientTorrentCreatorTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientTorrentCreatorTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler) { BaseAddress = new Uri("http://localhost/") };
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_NullRequest_WHEN_AddTorrentCreationTask_THEN_ShouldThrowArgumentNullException()
{
var act = async () => await _target.AddTorrentCreationTask(null!);
var ex = await act.Should().ThrowAsync<ArgumentNullException>();
ex.Which.ParamName.Should().Be("request");
}
[Fact]
public async Task GIVEN_EmptySourcePath_WHEN_AddTorrentCreationTask_THEN_ShouldThrowArgumentException()
{
var act = async () => await _target.AddTorrentCreationTask(new TorrentCreationTaskRequest
{
SourcePath = " "
});
var ex = await act.Should().ThrowAsync<ArgumentException>();
ex.Which.ParamName.Should().Be("request");
ex.Which.Message.Should().Contain("SourcePath is required.");
}
[Fact]
public async Task GIVEN_MinimalRequest_WHEN_AddTorrentCreationTask_THEN_ShouldPOSTOnlySourcePathAndReturnEmptyOnEmptyBody()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrentcreator/addTask");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("sourcePath=%2Fsrc");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(string.Empty)
};
};
var id = await _target.AddTorrentCreationTask(new TorrentCreationTaskRequest
{
SourcePath = "/src"
});
id.Should().Be(string.Empty);
}
[Fact]
public async Task GIVEN_AllFields_WHEN_AddTorrentCreationTask_THEN_ShouldIncludeEveryParameterAndReturnTaskId()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrentcreator/addTask");
var form = await req.Content!.ReadAsStringAsync(ct);
var parts = form.Split('&')
.Select(p => p.Split('='))
.ToDictionary(a => a[0], a => Uri.UnescapeDataString(a.Length > 1 ? a[1] : string.Empty));
parts["sourcePath"].Should().Be("/src");
parts["torrentFilePath"].Should().Be("/out.torrent");
parts["pieceSize"].Should().Be("512");
parts["private"].Should().Be("true");
parts["startSeeding"].Should().Be("false");
parts["comment"].Should().Be("hello");
parts["source"].Should().Be("mysrc");
parts["trackers"].Should().Be("t1|t2");
parts["urlSeeds"].Should().Be("u1|u2");
parts["format"].Should().Be("v2");
parts["optimizeAlignment"].Should().Be("true");
parts["paddedFileSizeLimit"].Should().Be("4096");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"taskID\":\"task-123\"}")
};
};
var request = new TorrentCreationTaskRequest
{
SourcePath = "/src",
TorrentFilePath = "/out.torrent",
PieceSize = 512,
Private = true,
StartSeeding = false,
Comment = "hello",
Source = "mysrc",
Trackers = new[] { "t1", "t2" },
UrlSeeds = new[] { "u1", "u2" },
Format = "v2",
OptimizeAlignment = true,
PaddedFileSizeLimit = 4096
};
var id = await _target.AddTorrentCreationTask(request);
id.Should().Be("task-123");
}
[Fact]
public async Task GIVEN_OKButNoTaskIdInJson_WHEN_AddTorrentCreationTask_THEN_ShouldReturnEmptyString()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
var id = await _target.AddTorrentCreationTask(new TorrentCreationTaskRequest
{
SourcePath = "/src"
});
id.Should().Be(string.Empty);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_AddTorrentCreationTask_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad req")
});
var act = async () => await _target.AddTorrentCreationTask(new TorrentCreationTaskRequest { SourcePath = "/src" });
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad req");
}
[Fact]
public async Task GIVEN_NoTaskId_WHEN_GetTorrentCreationTasks_THEN_ShouldGETWithoutQueryAndReturnList()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/torrentcreator/status");
req.RequestUri!.Query.Should().BeEmpty();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var list = await _target.GetTorrentCreationTasks();
list.Should().NotBeNull();
list.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_TaskId_WHEN_GetTorrentCreationTasks_THEN_ShouldGETWithQuery()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/torrentcreator/status?taskID=task-1");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var list = await _target.GetTorrentCreationTasks("task-1");
list.Should().NotBeNull();
list.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_BadJson_WHEN_GetTorrentCreationTasks_THEN_ShouldReturnEmptyList()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("oops")
});
var list = await _target.GetTorrentCreationTasks();
list.Should().NotBeNull();
list.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetTorrentCreationTasks_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent("busy")
});
var act = async () => await _target.GetTorrentCreationTasks();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
ex.Which.Message.Should().Be("busy");
}
[Fact]
public async Task GIVEN_TaskId_WHEN_GetTorrentCreationTaskFile_THEN_ShouldGETWithQueryAndReturnBytes()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/torrentcreator/torrentFile?taskID=abc");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2 })
});
};
var bytes = await _target.GetTorrentCreationTaskFile("abc");
bytes.Should().Equal(new byte[] { 1, 2 });
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetTorrentCreationTaskFile_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("missing")
});
var act = async () => await _target.GetTorrentCreationTaskFile("x");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound);
ex.Which.Message.Should().Be("missing");
}
[Fact]
public async Task GIVEN_TaskId_WHEN_DeleteTorrentCreationTask_THEN_ShouldPOSTForm()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrentcreator/deleteTask");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("taskID=abc");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.DeleteTorrentCreationTask("abc");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_DeleteTorrentCreationTask_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("nope")
});
var act = async () => await _target.DeleteTorrentCreationTask("abc");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("nope");
}
}
}

View File

@@ -0,0 +1,325 @@
using System.Net;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientTorrentListAndGettersTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientTorrentListAndGettersTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost/")
};
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_NoFilters_WHEN_GetTorrentList_THEN_ShouldGETWithoutQueryAndReturnList()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/torrents/info");
req.RequestUri!.Query.Should().BeEmpty();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetTorrentList();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_AllFilters_WHEN_GetTorrentList_THEN_ShouldIncludeAllParamsInOrder()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/torrents/info");
req.RequestUri!.Query.Should().Be("?filter=active&category=Movies&tag=HD&sort=name&reverse=true&limit=50&offset=5&hashes=a%7Cb%7Cc&private=true&includeFiles=false");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetTorrentList(
filter: "active",
category: "Movies",
tag: "HD",
sort: "name",
reverse: true,
limit: 50,
offset: 5,
isPrivate: true,
includeFiles: false,
"a", "b", "c"
);
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_BadJson_WHEN_GetTorrentList_THEN_ShouldReturnEmptyList()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("not json")
});
var result = await _target.GetTorrentList();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetTorrentList_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("no")
});
var act = async () => await _target.GetTorrentList();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("no");
}
[Fact]
public async Task GIVEN_Hash_WHEN_GetTorrentProperties_THEN_ShouldGETWithHashAndDeserialize()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/properties?hash=abc");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
};
var result = await _target.GetTorrentProperties("abc");
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetTorrentProperties_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("missing")
});
var act = async () => await _target.GetTorrentProperties("abc");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound);
ex.Which.Message.Should().Be("missing");
}
[Fact]
public async Task GIVEN_Hash_WHEN_GetTorrentTrackers_THEN_ShouldGETAndReturnList()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/trackers?hash=xyz");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetTorrentTrackers("xyz");
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_Hash_WHEN_GetTorrentWebSeeds_THEN_ShouldGETAndReturnList()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/webseeds?hash=h1");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetTorrentWebSeeds("h1");
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_HashOnly_WHEN_GetTorrentContents_THEN_ShouldGETWithHashOnly()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/torrents/files");
req.RequestUri!.Query.Should().Be("?hash=abc");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetTorrentContents("abc");
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_Indexes_WHEN_GetTorrentContents_THEN_ShouldGETWithIndexesPipeSeparated()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.AbsolutePath.Should().Be("/torrents/files");
req.RequestUri!.Query.Should().Be("?hash=abc&indexes=1%7C2%7C3");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]")
});
};
var result = await _target.GetTorrentContents("abc", 1, 2, 3);
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_BadJson_WHEN_GetTorrentContents_THEN_ShouldReturnEmptyList()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("oops")
});
var result = await _target.GetTorrentContents("abc");
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetTorrentContents_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.GetTorrentContents("abc");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_Hash_WHEN_GetTorrentPieceStates_THEN_ShouldGETAndReturnListOrEmptyOnBadJson()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/pieceStates?hash=abc");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("not json")
});
};
var result = await _target.GetTorrentPieceStates("abc");
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetTorrentPieceStates_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("missing")
});
var act = async () => await _target.GetTorrentPieceStates("abc");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound);
ex.Which.Message.Should().Be("missing");
}
[Fact]
public async Task GIVEN_Hash_WHEN_GetTorrentPieceHashes_THEN_ShouldGETAndReturnStrings()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Get);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/pieceHashes?hash=abc");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[\"h1\",\"h2\"]")
});
};
var result = await _target.GetTorrentPieceHashes("abc");
result.Should().NotBeNull();
result.Count.Should().Be(2);
result[0].Should().Be("h1");
result[1].Should().Be("h2");
}
[Fact]
public async Task GIVEN_BadJson_WHEN_GetTorrentPieceHashes_THEN_ShouldReturnEmptyList()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("bad")
});
var result = await _target.GetTorrentPieceHashes("abc");
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetTorrentPieceHashes_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("err")
});
var act = async () => await _target.GetTorrentPieceHashes("abc");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex.Which.Message.Should().Be("err");
}
}
}

View File

@@ -0,0 +1,224 @@
using System.Net;
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientTorrentPriorityAndLimitsTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientTorrentPriorityAndLimitsTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler) { BaseAddress = new Uri("http://localhost/") };
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_Hashes_WHEN_IncreaseTorrentPriority_THEN_ShouldPostHashes()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/increasePrio");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("hashes=h1%7Ch2");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.IncreaseTorrentPriority(false, "h1", "h2");
}
[Fact]
public async Task GIVEN_AllTrue_WHEN_DecreaseTorrentPriority_THEN_ShouldPostAll()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/decreasePrio");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("hashes=all");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.DecreaseTorrentPriority(true);
}
[Fact]
public async Task GIVEN_Hashes_WHEN_MaxTorrentPriority_THEN_ShouldPostHashes()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/topPrio");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("hashes=h");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.MaxTorrentPriority(false, "h");
}
[Fact]
public async Task GIVEN_Hashes_WHEN_MinTorrentPriority_THEN_ShouldPostHashes()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/bottomPrio");
(await req.Content!.ReadAsStringAsync(ct)).Should().Be("hashes=h1%7Ch2%7Ch3");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.MinTorrentPriority(false, "h1", "h2", "h3");
}
[Fact]
public async Task GIVEN_FileIdsAndPriority_WHEN_SetFilePriority_THEN_ShouldPostIdsAndPriorityInt()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/filePrio");
var body = Uri.UnescapeDataString(await req.Content!.ReadAsStringAsync(ct));
body.Should().Be("hash=h1&id=1|2|3&priority=7");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetFilePriority("h1", new[] { 1, 2, 3 }, (Priority)7);
}
[Fact]
public async Task GIVEN_Hashes_WHEN_GetTorrentDownloadLimit_THEN_ShouldReturnDictionary()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"h1\":1000,\"h2\":0}")
});
var result = await _target.GetTorrentDownloadLimit(false, "h1", "h2");
result.Should().NotBeNull();
result.Count.Should().Be(2);
result["h1"].Should().Be(1000);
result["h2"].Should().Be(0);
}
[Fact]
public async Task GIVEN_BadJson_WHEN_GetTorrentDownloadLimit_THEN_ShouldReturnEmptyDict()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("oops")
});
var result = await _target.GetTorrentDownloadLimit();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_LimitAndHashes_WHEN_SetTorrentDownloadLimit_THEN_ShouldPostLimitAndHashes()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/setDownloadLimit");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=h%7Ci&limit=500");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetTorrentDownloadLimit(500, false, "h", "i");
}
[Fact]
public async Task GIVEN_Ratios_WHEN_SetTorrentShareLimit_THEN_ShouldPostAllValuesAndOptionalAction()
{
var ratio = 1.5f.ToString();
var seed = 2.25f.ToString();
var inactive = 0.75f.ToString();
_handler.Responder = async (req, ct) =>
{
var form = await req.Content!.ReadAsStringAsync(ct);
var parts = form.Split('&').ToDictionary(
s => s.Split('=')[0],
s => Uri.UnescapeDataString(s.Split('=')[1])
);
parts["hashes"].Should().Be("h1|h2");
parts["ratioLimit"].Should().Be(ratio);
parts["seedingTimeLimit"].Should().Be(seed);
parts["inactiveSeedingTimeLimit"].Should().Be(inactive);
// enum is serialized as its NAME, not numeric value
parts["shareLimitAction"].Should().Be(ShareLimitAction.Remove.ToString());
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetTorrentShareLimit(
ratioLimit: 1.5f,
seedingTimeLimit: 2.25f,
inactiveSeedingTimeLimit: 0.75f,
shareLimitAction: ShareLimitAction.Remove, // <-- changed from Delete
all: false,
hashes: new[] { "h1", "h2" }
);
}
[Fact]
public async Task GIVEN_NoAction_WHEN_SetTorrentShareLimit_THEN_ShouldOmitActionField()
{
_handler.Responder = async (req, ct) =>
{
var form = await req.Content!.ReadAsStringAsync(ct);
form.Should().Contain("ratioLimit=");
form.Should().Contain("seedingTimeLimit=");
form.Should().Contain("inactiveSeedingTimeLimit=");
form.Should().NotContain("shareLimitAction=");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetTorrentShareLimit(1, 2, 3, shareLimitAction: null, all: true);
}
[Fact]
public async Task GIVEN_Hashes_WHEN_GetTorrentUploadLimit_THEN_ShouldReturnDictionary()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"x\":10}")
});
var result = await _target.GetTorrentUploadLimit(false, "x");
result.Should().NotBeNull();
result.Count.Should().Be(1);
result["x"].Should().Be(10);
}
[Fact]
public async Task GIVEN_BadJson_WHEN_GetTorrentUploadLimit_THEN_ShouldReturnEmptyDict()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("bad")
});
var result = await _target.GetTorrentUploadLimit();
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_LimitAndHashes_WHEN_SetTorrentUploadLimit_THEN_ShouldPostLimitAndHashes()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/setUploadLimit");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=h1&limit=42");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetTorrentUploadLimit(42, false, "h1");
}
}
}

View File

@@ -0,0 +1,261 @@
using System.Net;
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientTorrentWebSeedsAndLifecycleTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientTorrentWebSeedsAndLifecycleTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost/")
};
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_Urls_WHEN_AddTorrentWebSeeds_THEN_ShouldPOSTFormWithPipeSeparatedUrls()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/addWebSeeds");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hash=h123&urls=a%7Cb%7Cc");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.AddTorrentWebSeeds("h123", new[] { "a", "b", "c" });
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_AddTorrentWebSeeds_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.AddTorrentWebSeeds("h", new[] { "u" });
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_Urls_WHEN_RemoveTorrentWebSeeds_THEN_ShouldPOSTFormWithPipeSeparatedUrls()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/removeWebSeeds");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hash=h1&urls=http%3A%2F%2Fe1%7Chttp%3A%2F%2Fe2");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RemoveTorrentWebSeeds("h1", new[] { "http://e1", "http://e2" });
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_RemoveTorrentWebSeeds_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Conflict)
{
Content = new StringContent("conflict")
});
var act = async () => await _target.RemoveTorrentWebSeeds("h", new[] { "u" });
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict);
ex.Which.Message.Should().Be("conflict");
}
[Fact]
public async Task GIVEN_EditParams_WHEN_EditTorrentWebSeed_THEN_ShouldPOSTFormWithAllFields()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/editWebSeed");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hash=hx&origUrl=old%2Furl&newUrl=new%2Furl");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.EditTorrentWebSeed("hx", "old/url", "new/url");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_EditTorrentWebSeed_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("missing")
});
var act = async () => await _target.EditTorrentWebSeed("h", "o", "n");
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound);
ex.Which.Message.Should().Be("missing");
}
[Fact]
public async Task GIVEN_NoArgs_WHEN_StopTorrents_THEN_ShouldPOSTWithEmptyHashesValue()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/stop");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.StopTorrents();
}
[Fact]
public async Task GIVEN_AllTrue_WHEN_StopTorrents_THEN_ShouldSendAllLiteral()
{
_handler.Responder = async (req, ct) =>
{
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=all");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.StopTorrents(all: true);
}
[Fact]
public async Task GIVEN_Hashes_WHEN_StartTorrents_THEN_ShouldPipeSeparate()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/start");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=a%7Cb%7Cc");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.StartTorrents(false, "a", "b", "c");
}
[Fact]
public async Task GIVEN_DeleteFilesTrue_WHEN_DeleteTorrents_THEN_ShouldIncludeDeleteFlag()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/delete");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=a%7Cb&deleteFiles=true");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.DeleteTorrents(false, true, "a", "b");
}
[Fact]
public async Task GIVEN_AllTrueAndDefaultDeleteFlag_WHEN_DeleteTorrents_THEN_ShouldSendFalseAndAll()
{
_handler.Responder = async (req, ct) =>
{
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=all&deleteFiles=false");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.DeleteTorrents(true);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_DeleteTorrents_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.DeleteTorrents();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_Hashes_WHEN_RecheckTorrents_THEN_ShouldPOST()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/recheck");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=h1%7Ch2");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.RecheckTorrents(false, "h1", "h2");
}
[Fact]
public async Task GIVEN_AllTrueAndNoTrackers_WHEN_ReannounceTorrents_THEN_ShouldOnlySendHashes()
{
_handler.Responder = async (req, ct) =>
{
req.RequestUri!.ToString().Should().Be("http://localhost/torrents/reannounce");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("hashes=all");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.ReannounceTorrents(true, trackers: null);
}
[Fact]
public async Task GIVEN_Trackers_WHEN_ReannounceTorrents_THEN_ShouldIncludeUrlsJoinedByPipe()
{
_handler.Responder = async (req, ct) =>
{
var body = await req.Content!.ReadAsStringAsync(ct);
// Decode to assert actual values
var parts = body.Split('&').ToDictionary(
s => s.Split('=')[0],
s => Uri.UnescapeDataString(s.Split('=')[1])
);
parts["hashes"].Should().Be("h1|h2");
parts["urls"].Should().Be("http://t1|http://t2");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.ReannounceTorrents(false, new[] { "http://t1", "http://t2" }, "h1", "h2");
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_ReannounceTorrents_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("nope")
});
var act = async () => await _target.ReannounceTorrents();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("nope");
}
}
}

View File

@@ -0,0 +1,264 @@
using System.Net;
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBitTorrentClient.Test
{
public class ApiClientTransferInfoTests
{
private readonly ApiClient _target;
private readonly StubHttpMessageHandler _handler;
public ApiClientTransferInfoTests()
{
_handler = new StubHttpMessageHandler();
var http = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost/")
};
_target = new ApiClient(http);
}
[Fact]
public async Task GIVEN_OKJson_WHEN_GetGlobalTransferInfo_THEN_ShouldDeserialize()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
var result = await _target.GetGlobalTransferInfo();
result.Should().NotBeNull();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetGlobalTransferInfo_THEN_ShouldThrowWithStatusAndMessage()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = new StringContent("bad")
});
var act = async () => await _target.GetGlobalTransferInfo();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadGateway);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_ResponseIsOne_WHEN_GetAlternativeSpeedLimitsState_THEN_ShouldBeTrue()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("1")
});
var result = await _target.GetAlternativeSpeedLimitsState();
result.Should().BeTrue();
}
[Fact]
public async Task GIVEN_ResponseIsZero_WHEN_GetAlternativeSpeedLimitsState_THEN_ShouldBeFalse()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("0")
});
var result = await _target.GetAlternativeSpeedLimitsState();
result.Should().BeFalse();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_GetAlternativeSpeedLimitsState_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = new StringContent("no")
});
var act = async () => await _target.GetAlternativeSpeedLimitsState();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
ex.Which.Message.Should().Be("no");
}
[Fact]
public async Task GIVEN_OK_WHEN_ToggleAlternativeSpeedLimits_THEN_ShouldPOSTAndNotThrow()
{
_handler.Responder = (req, _) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/transfer/toggleSpeedLimitsMode");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
};
await _target.ToggleAlternativeSpeedLimits();
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_ToggleAlternativeSpeedLimits_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("err")
});
var act = async () => await _target.ToggleAlternativeSpeedLimits();
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
ex.Which.Message.Should().Be("err");
}
[Fact]
public async Task GIVEN_Digits_WHEN_GetGlobalDownloadLimit_THEN_ShouldParseLong()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("1234567890")
});
var result = await _target.GetGlobalDownloadLimit();
result.Should().Be(1234567890);
}
[Fact]
public async Task GIVEN_InvalidNumber_WHEN_GetGlobalDownloadLimit_THEN_ShouldThrowFormatException()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("oops")
});
var act = async () => await _target.GetGlobalDownloadLimit();
await act.Should().ThrowAsync<FormatException>();
}
[Fact]
public async Task GIVEN_Limit_WHEN_SetGlobalDownloadLimit_THEN_ShouldPOSTFormWithLimit()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/transfer/setDownloadLimit");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("limit=5000");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetGlobalDownloadLimit(5000);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_SetGlobalDownloadLimit_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad")
});
var act = async () => await _target.SetGlobalDownloadLimit(1);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest);
ex.Which.Message.Should().Be("bad");
}
[Fact]
public async Task GIVEN_Digits_WHEN_GetGlobalUploadLimit_THEN_ShouldParseLong()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("4321")
});
var result = await _target.GetGlobalUploadLimit();
result.Should().Be(4321);
}
[Fact]
public async Task GIVEN_InvalidNumber_WHEN_GetGlobalUploadLimit_THEN_ShouldThrowFormatException()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("NaN")
});
var act = async () => await _target.GetGlobalUploadLimit();
await act.Should().ThrowAsync<FormatException>();
}
[Fact]
public async Task GIVEN_Limit_WHEN_SetGlobalUploadLimit_THEN_ShouldPOSTFormWithLimit()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/transfer/setUploadLimit");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("limit=9001");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.SetGlobalUploadLimit(9001);
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_SetGlobalUploadLimit_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent("nope")
});
var act = async () => await _target.SetGlobalUploadLimit(1);
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Forbidden);
ex.Which.Message.Should().Be("nope");
}
[Fact]
public async Task GIVEN_EmptyPeers_WHEN_BanPeers_THEN_ShouldPOSTFormWithEmptyPeersValue()
{
_handler.Responder = async (req, ct) =>
{
req.Method.Should().Be(HttpMethod.Post);
req.RequestUri!.ToString().Should().Be("http://localhost/transfer/banPeers");
req.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded");
var body = await req.Content!.ReadAsStringAsync(ct);
body.Should().Be("peers=");
return new HttpResponseMessage(HttpStatusCode.OK);
};
await _target.BanPeers(Array.Empty<PeerId>());
}
[Fact]
public async Task GIVEN_NonSuccess_WHEN_BanPeers_THEN_ShouldThrow()
{
_handler.Responder = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Conflict)
{
Content = new StringContent("conflict")
});
var act = async () => await _target.BanPeers(Array.Empty<PeerId>());
var ex = await act.Should().ThrowAsync<HttpRequestException>();
ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict);
ex.Which.Message.Should().Be("conflict");
}
}
}

View File

@@ -0,0 +1,93 @@
using System.Text.Json;
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Converters;
namespace Lantean.QBitTorrentClient.Test.Converters
{
public class CommaSeparatedJsonConverterTests
{
private static JsonSerializerOptions CreateOptions()
{
var o = new JsonSerializerOptions();
o.Converters.Add(new CommaSeparatedJsonConverter());
return o;
}
[Fact]
public void GIVEN_EmptyString_WHEN_Read_THEN_ShouldReturnEmptyReadOnlyList()
{
var options = CreateOptions();
var json = "\"\"";
var result = JsonSerializer.Deserialize<IReadOnlyList<string>>(json, options);
result.Should().NotBeNull();
result!.Count.Should().Be(0);
}
[Fact]
public void GIVEN_CommaSeparatedWithSpacesAndEmpties_WHEN_Read_THEN_ShouldSplitTrimAndRemoveEmpties()
{
var options = CreateOptions();
// contains spaces and empty segments between commas
var json = "\" alpha , , beta ,, , gamma \"";
var result = JsonSerializer.Deserialize<IReadOnlyList<string>>(json, options);
result.Should().NotBeNull();
result!.Count.Should().Be(3);
result[0].Should().Be("alpha");
result[1].Should().Be("beta");
result[2].Should().Be("gamma");
}
[Fact]
public void GIVEN_NonStringToken_WHEN_Read_THEN_ShouldThrowJsonException()
{
var options = CreateOptions();
var json = "123"; // number token, not a string
var act = () => JsonSerializer.Deserialize<IReadOnlyList<string>>(json, options)!;
var ex = act.Should().Throw<JsonException>();
ex.Which.Message.Should().Be("Must be of type string.");
}
[Fact]
public void GIVEN_List_WHEN_Write_THEN_ShouldOutputSingleJsonStringCommaJoined()
{
var options = CreateOptions();
IReadOnlyList<string> value = new[] { "a", "b", "c" };
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("\"a,b,c\"");
}
[Fact]
public void GIVEN_EmptyList_WHEN_Write_THEN_ShouldOutputEmptyJsonString()
{
var options = CreateOptions();
IReadOnlyList<string> value = Array.Empty<string>();
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("\"\"");
}
[Fact]
public void GIVEN_ReadResult_WHEN_AttemptToMutate_THEN_ShouldThrowNotSupportedException()
{
var options = CreateOptions();
var json = "\"x,y\"";
var result = JsonSerializer.Deserialize<IReadOnlyList<string>>(json, options)!;
// Converter returns list.AsReadOnly() -> ReadOnlyCollection<string>
var asList = (IList<string>)result;
var act = () => asList.Add("z");
act.Should().Throw<NotSupportedException>();
}
}
}

View File

@@ -0,0 +1,153 @@
using System.Text.Json;
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Converters;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBitTorrentClient.Test.Converters
{
public class DownloadPathOptionJsonConverterTests
{
private static JsonSerializerOptions CreateOptions()
{
var o = new JsonSerializerOptions();
o.Converters.Add(new DownloadPathOptionJsonConverter());
return o;
}
// -------- Read --------
[Fact]
public void GIVEN_JsonNull_WHEN_Read_THEN_ShouldReturnNull()
{
var options = CreateOptions();
var json = "null";
var result = JsonSerializer.Deserialize<DownloadPathOption>(json, options);
result.Should().BeNull();
}
[Fact]
public void GIVEN_JsonFalse_WHEN_Read_THEN_ShouldReturnDisabledWithNullPath()
{
var options = CreateOptions();
var json = "false";
var result = JsonSerializer.Deserialize<DownloadPathOption>(json, options);
result.Should().NotBeNull();
result!.Enabled.Should().BeFalse();
result.Path.Should().BeNull();
}
[Fact]
public void GIVEN_JsonTrue_WHEN_Read_THEN_ShouldReturnEnabledWithNullPath()
{
var options = CreateOptions();
var json = "true";
var result = JsonSerializer.Deserialize<DownloadPathOption>(json, options);
result.Should().NotBeNull();
result!.Enabled.Should().BeTrue();
result.Path.Should().BeNull();
}
[Fact]
public void GIVEN_JsonString_WHEN_Read_THEN_ShouldReturnEnabledWithThatPath()
{
var options = CreateOptions();
var json = "\"/downloads\"";
var result = JsonSerializer.Deserialize<DownloadPathOption>(json, options);
result.Should().NotBeNull();
result!.Enabled.Should().BeTrue();
result.Path.Should().Be("/downloads");
}
[Fact]
public void GIVEN_UnexpectedToken_WHEN_Read_THEN_ShouldThrowJsonException()
{
var options = CreateOptions();
var json = "123"; // number token, not supported
var act = () => JsonSerializer.Deserialize<DownloadPathOption>(json, options)!;
var ex = act.Should().Throw<JsonException>();
ex.Which.Message.Should().Contain("Unexpected token");
}
// -------- Write --------
[Fact]
public void GIVEN_NullValue_WHEN_Write_THEN_ShouldEmitJsonNull()
{
var options = CreateOptions();
DownloadPathOption? value = null;
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("null");
}
[Fact]
public void GIVEN_Disabled_WHEN_Write_THEN_ShouldEmitFalse()
{
var options = CreateOptions();
var value = new DownloadPathOption(false, null);
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("false");
}
[Fact]
public void GIVEN_EnabledWithNullPath_WHEN_Write_THEN_ShouldEmitTrue()
{
var options = CreateOptions();
var value = new DownloadPathOption(true, null);
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("true");
}
[Fact]
public void GIVEN_EnabledWithWhitespacePath_WHEN_Write_THEN_ShouldEmitTrue()
{
var options = CreateOptions();
var value = new DownloadPathOption(true, " ");
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("true");
}
[Fact]
public void GIVEN_EnabledWithPath_WHEN_Write_THEN_ShouldEmitThatString()
{
var options = CreateOptions();
var value = new DownloadPathOption(true, "/dl/path");
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("\"/dl/path\"");
}
// -------- Round-trip sanity --------
[Fact]
public void GIVEN_PathString_WHEN_RoundTrip_THEN_ShouldPreserveEnabledTrueAndPath()
{
var options = CreateOptions();
var original = new DownloadPathOption(true, "/data");
var json = JsonSerializer.Serialize(original, options);
var round = JsonSerializer.Deserialize<DownloadPathOption>(json, options)!;
round.Enabled.Should().BeTrue();
round.Path.Should().Be("/data");
}
}
}

View File

@@ -0,0 +1,152 @@
using System.Text.Json;
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Converters;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBitTorrentClient.Test.Converters
{
public class SaveLocationJsonConverterTests
{
private static JsonSerializerOptions CreateOptions()
{
var o = new JsonSerializerOptions();
o.Converters.Add(new SaveLocationJsonConverter());
return o;
}
// -------- Read --------
[Fact]
public void GIVEN_String_WHEN_Read_THEN_ShouldReturnCustomPath()
{
var options = CreateOptions();
var json = "\"/downloads\"";
var result = JsonSerializer.Deserialize<SaveLocation>(json, options);
result.Should().NotBeNull();
result.SavePath.Should().Be("/downloads");
result.IsDefaultFolder.Should().BeFalse();
result.IsWatchedFolder.Should().BeFalse();
}
[Fact]
public void GIVEN_NumberZero_WHEN_Read_THEN_ShouldReturnWatchedFolder()
{
var options = CreateOptions();
var json = "0";
var result = JsonSerializer.Deserialize<SaveLocation>(json, options);
result.Should().NotBeNull();
result.IsWatchedFolder.Should().BeTrue();
result.IsDefaultFolder.Should().BeFalse();
result.SavePath.Should().BeNull();
}
[Fact]
public void GIVEN_NumberOne_WHEN_Read_THEN_ShouldReturnDefaultFolder()
{
var options = CreateOptions();
var json = "1";
var result = JsonSerializer.Deserialize<SaveLocation>(json, options);
result.Should().NotBeNull();
result.IsDefaultFolder.Should().BeTrue();
result.IsWatchedFolder.Should().BeFalse();
result.SavePath.Should().BeNull();
}
[Fact]
public void GIVEN_UnsupportedToken_WHEN_Read_THEN_ShouldThrowJsonException()
{
var options = CreateOptions();
var json = "true"; // bool token is not supported
var act = () => JsonSerializer.Deserialize<SaveLocation>(json, options)!;
var ex = act.Should().Throw<JsonException>();
ex.Which.Message.Should().Contain("Unsupported token type");
}
// -------- Write --------
[Fact]
public void GIVEN_WatchedFolder_WHEN_Write_THEN_ShouldEmitZero()
{
var options = CreateOptions();
var value = SaveLocation.Create(0);
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("0");
}
[Fact]
public void GIVEN_DefaultFolder_WHEN_Write_THEN_ShouldEmitOne()
{
var options = CreateOptions();
var value = SaveLocation.Create(1);
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("1");
}
[Fact]
public void GIVEN_CustomPath_WHEN_Write_THEN_ShouldEmitJsonString()
{
var options = CreateOptions();
var value = SaveLocation.Create("/data/films");
var json = JsonSerializer.Serialize(value, options);
json.Should().Be("\"/data/films\"");
}
// -------- Round-trip sanity --------
[Fact]
public void GIVEN_PathString_WHEN_RoundTrip_THEN_ShouldPreserveCustomPath()
{
var options = CreateOptions();
var original = SaveLocation.Create("/data");
var json = JsonSerializer.Serialize(original, options);
var round = JsonSerializer.Deserialize<SaveLocation>(json, options)!;
round.SavePath.Should().Be("/data");
round.IsDefaultFolder.Should().BeFalse();
round.IsWatchedFolder.Should().BeFalse();
}
[Fact]
public void GIVEN_Zero_WHEN_RoundTrip_THEN_ShouldStayWatchedFolder()
{
var options = CreateOptions();
var original = SaveLocation.Create(0);
var json = JsonSerializer.Serialize(original, options);
var round = JsonSerializer.Deserialize<SaveLocation>(json, options)!;
round.IsWatchedFolder.Should().BeTrue();
round.IsDefaultFolder.Should().BeFalse();
round.SavePath.Should().BeNull();
}
[Fact]
public void GIVEN_One_WHEN_RoundTrip_THEN_ShouldStayDefaultFolder()
{
var options = CreateOptions();
var original = SaveLocation.Create(1);
var json = JsonSerializer.Serialize(original, options);
var round = JsonSerializer.Deserialize<SaveLocation>(json, options)!;
round.IsDefaultFolder.Should().BeTrue();
round.IsWatchedFolder.Should().BeFalse();
round.SavePath.Should().BeNull();
}
}
}

View File

@@ -0,0 +1,131 @@
using System.Text.Json;
using AwesomeAssertions;
using Lantean.QBitTorrentClient.Converters;
namespace Lantean.QBitTorrentClient.Test.Converters
{
public class StringFloatJsonConverterTests
{
private static JsonSerializerOptions CreateOptions()
{
var o = new JsonSerializerOptions();
o.Converters.Add(new StringFloatJsonConverter());
return o;
}
// -------- Read --------
[Fact]
public async Task GIVEN_StringNumber_WHEN_Read_THEN_ShouldParseFloat()
{
var options = CreateOptions();
var json = "\"42\"";
var result = JsonSerializer.Deserialize<float>(json, options);
result.Should().Be(42f);
await Task.CompletedTask;
}
[Fact]
public async Task GIVEN_StringInvalid_WHEN_Read_THEN_ShouldReturnZero()
{
var options = CreateOptions();
var json = "\"not-a-number\"";
var result = JsonSerializer.Deserialize<float>(json, options);
result.Should().Be(0f);
await Task.CompletedTask;
}
[Fact]
public async Task GIVEN_NumberToken_WHEN_Read_THEN_ShouldReadSingle()
{
var options = CreateOptions();
var json = "123.0";
var result = JsonSerializer.Deserialize<float>(json, options);
result.Should().Be(123f);
await Task.CompletedTask;
}
[Fact]
public async Task GIVEN_NullToken_WHEN_Read_THEN_ShouldReturnZero()
{
var options = CreateOptions();
var json = "null";
var result = JsonSerializer.Deserialize<float>(json, options);
result.Should().Be(0f);
await Task.CompletedTask;
}
[Fact]
public async Task GIVEN_UnsupportedToken_WHEN_Read_THEN_ShouldReturnZero()
{
var options = CreateOptions();
var json = "true"; // bool token -> converter returns 0
var result = JsonSerializer.Deserialize<float>(json, options);
result.Should().Be(0f);
await Task.CompletedTask;
}
// -------- Write --------
[Fact]
public async Task GIVEN_IntegerValue_WHEN_Write_THEN_ShouldEmitJsonStringContainingThatValue()
{
var options = CreateOptions();
float value = 42f;
var json = JsonSerializer.Serialize(value, options);
// Should be a JSON string (quotes)
json.Should().StartWith("\"").And.EndWith("\"");
// Remove quotes and ensure it parses back to the original value
var inner = json.Trim('"');
float.TryParse(inner, out var parsed).Should().BeTrue();
parsed.Should().Be(42f);
await Task.CompletedTask;
}
[Fact]
public async Task GIVEN_FractionalValue_WHEN_Write_THEN_ShouldEmitJsonStringParsableToSameValue()
{
var options = CreateOptions();
float value = 1.5f; // exactly representable
var json = JsonSerializer.Serialize(value, options);
json.Should().StartWith("\"").And.EndWith("\"");
var inner = json.Trim('"');
float.TryParse(inner, out var parsed).Should().BeTrue();
parsed.Should().Be(1.5f);
await Task.CompletedTask;
}
// -------- Round-trip --------
[Fact]
public async Task GIVEN_Value_WHEN_RoundTrip_THEN_ShouldPreserveValue()
{
var options = CreateOptions();
float original = 1.5f;
var json = JsonSerializer.Serialize(original, options);
var round = JsonSerializer.Deserialize<float>(json, options);
round.Should().Be(1.5f);
await Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,246 @@
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class FormUrlEncodedBuilderExtensionsTests
{
private readonly FormUrlEncodedBuilder _target;
public FormUrlEncodedBuilderExtensionsTests()
{
_target = new FormUrlEncodedBuilder();
}
[Fact]
public async Task GIVEN_BoolTrue_WHEN_Add_THEN_ShouldSerializeAsTrue()
{
var returned = _target.Add("flag", true);
ReferenceEquals(_target, returned).Should().BeTrue();
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("flag");
parameters[0].Value.Should().Be("true");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("flag=true");
}
[Fact]
public async Task GIVEN_BoolFalse_WHEN_Add_THEN_ShouldSerializeAsFalse()
{
_target.Add("flag", false);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("flag");
parameters[0].Value.Should().Be("false");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("flag=false");
}
[Fact]
public async Task GIVEN_Int_WHEN_Add_THEN_ShouldSerializeAsDigits()
{
_target.Add("count", 123);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("count");
parameters[0].Value.Should().Be("123");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("count=123");
}
[Fact]
public async Task GIVEN_Long_WHEN_Add_THEN_ShouldSerializeAsDigits()
{
_target.Add("size", 9223372036854775807);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("size");
parameters[0].Value.Should().Be("9223372036854775807");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("size=9223372036854775807");
}
[Fact]
public async Task GIVEN_DateTimeOffsetSeconds_WHEN_Add_THEN_ShouldUseUnixSeconds()
{
var when = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
_target.Add("epoch", when);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("epoch");
parameters[0].Value.Should().Be("946684800");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("epoch=946684800");
}
[Fact]
public async Task GIVEN_DateTimeOffsetMilliseconds_WHEN_Add_THEN_ShouldUseUnixMilliseconds()
{
var when = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
_target.Add("epochMs", when, useSeconds: false);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("epochMs");
parameters[0].Value.Should().Be("946684800000");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("epochMs=946684800000");
}
[Fact]
public async Task GIVEN_Float_WHEN_Add_THEN_ShouldUseCurrentCultureToString()
{
_target.Add("ratio", 42f);
var expected = 42f.ToString();
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("ratio");
parameters[0].Value.Should().Be(expected);
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be($"ratio={expected}");
}
[Fact]
public async Task GIVEN_GenericByte_WHEN_Add_Generic_THEN_ShouldSerializeAsInt32String()
{
_target.Add<byte>("b", 7);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("b");
parameters[0].Value.Should().Be("7");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("b=7");
}
[Fact]
public async Task GIVEN_GenericEnum_WHEN_Add_Generic_THEN_ShouldSerializeUnderlyingInt32String()
{
_target.Add("day", DayOfWeek.Friday);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("day");
parameters[0].Value.Should().Be("5");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("day=5");
}
[Fact]
public async Task GIVEN_AllTrue_WHEN_AddAllOrPipeSeparated_THEN_ShouldUseAllLiteral()
{
var returned = _target.AddAllOrPipeSeparated("list", all: true, "a", "b");
ReferenceEquals(_target, returned).Should().BeTrue();
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("list");
parameters[0].Value.Should().Be("all");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("list=all");
}
[Fact]
public async Task GIVEN_AllNullOrFalse_WHEN_AddAllOrPipeSeparated_THEN_ShouldJoinWithPipes()
{
_target.AddAllOrPipeSeparated("list", null, "a", "b c", "d|e");
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("list");
parameters[0].Value.Should().Be("a|b c|d|e");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("list=a%7Cb+c%7Cd%7Ce");
}
[Fact]
public async Task GIVEN_NoValues_WHEN_AddAllOrPipeSeparatedWithFalse_THEN_ShouldYieldEmptyValue()
{
_target.AddAllOrPipeSeparated("list", false);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("list");
parameters[0].Value.Should().Be(string.Empty);
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("list=");
}
[Fact]
public async Task GIVEN_PipeSeparatedValues_WHEN_AddPipeSeparated_THEN_ShouldJoinAndEncodeProperly()
{
_target.AddPipeSeparated("ids", new[] { "a", "b c", "d|e" });
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("ids");
parameters[0].Value.Should().Be("a|b c|d|e");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("ids=a%7Cb+c%7Cd%7Ce");
}
[Fact]
public async Task GIVEN_CommaSeparatedValues_WHEN_AddCommaSeparated_THEN_ShouldJoinAndEncodeProperly()
{
_target.AddCommaSeparated("items", new[] { 1, 2, 3 });
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("items");
parameters[0].Value.Should().Be("1,2,3");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("items=1%2C2%2C3");
}
[Fact]
public async Task GIVEN_MultipleAdds_WHEN_Chained_THEN_ShouldPreserveOrder()
{
var returned = _target
.Add("flag", true)
.Add("count", 2)
.Add("epoch", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero))
.Add<byte>("b", 7);
ReferenceEquals(_target, returned).Should().BeTrue();
var parameters = _target.GetParameters();
parameters.Count.Should().Be(4);
parameters[0].Key.Should().Be("flag");
parameters[0].Value.Should().Be("true");
parameters[1].Key.Should().Be("count");
parameters[1].Value.Should().Be("2");
parameters[2].Key.Should().Be("epoch");
parameters[2].Value.Should().Be("946684800");
parameters[3].Key.Should().Be("b");
parameters[3].Value.Should().Be("7");
using var content = _target.ToFormUrlEncodedContent();
(await content.ReadAsStringAsync()).Should().Be("flag=true&count=2&epoch=946684800&b=7");
}
}
}

View File

@@ -0,0 +1,176 @@
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class FormUrlEncodedBuilderTests
{
private readonly FormUrlEncodedBuilder _target;
public FormUrlEncodedBuilderTests()
{
_target = new FormUrlEncodedBuilder();
}
[Fact]
public async Task GIVEN_NoParameters_WHEN_ToFormUrlEncodedContent_THEN_ShouldBeEmptyString()
{
using var content = _target.ToFormUrlEncodedContent();
var payload = await content.ReadAsStringAsync();
payload.Should().Be(string.Empty);
_target.GetParameters().Count.Should().Be(0);
}
[Fact]
public async Task GIVEN_SingleParameter_WHEN_ToFormUrlEncodedContent_THEN_ShouldEncodeAndContainPair()
{
_target.Add("first", "one");
using var content = _target.ToFormUrlEncodedContent();
var payload = await content.ReadAsStringAsync();
payload.Should().Be("first=one");
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("first");
parameters[0].Value.Should().Be("one");
}
[Fact]
public async Task GIVEN_MultipleParameters_WHEN_ToFormUrlEncodedContent_THEN_ShouldPreserveOrderWithAmpersand()
{
_target.Add("a", "1").Add("b", "2").Add("c", "3");
using var content = _target.ToFormUrlEncodedContent();
var payload = await content.ReadAsStringAsync();
payload.Should().Be("a=1&b=2&c=3");
var parameters = _target.GetParameters();
parameters.Count.Should().Be(3);
parameters[0].Key.Should().Be("a");
parameters[0].Value.Should().Be("1");
parameters[1].Key.Should().Be("b");
parameters[1].Value.Should().Be("2");
parameters[2].Key.Should().Be("c");
parameters[2].Value.Should().Be("3");
}
[Fact]
public async Task GIVEN_SpecialCharacters_WHEN_ToFormUrlEncodedContent_THEN_ShouldBeProperlyEncoded()
{
_target.Add("a b", "c+d&=");
using var content = _target.ToFormUrlEncodedContent();
var payload = await content.ReadAsStringAsync();
((payload.StartsWith("a%20b=") || payload.StartsWith("a+b="))).Should().BeTrue();
payload.EndsWith("c%2Bd%26%3D").Should().BeTrue();
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("a b");
parameters[0].Value.Should().Be("c+d&=");
}
[Fact]
public async Task GIVEN_NonEmptyString_WHEN_AddIfNotNullOrEmpty_THEN_ShouldAddPair()
{
_target.AddIfNotNullOrEmpty("key", "value");
_target.GetParameters().Count.Should().Be(1);
using var content = _target.ToFormUrlEncodedContent();
var payload = await content.ReadAsStringAsync();
payload.Should().Be("key=value");
}
[Fact]
public async Task GIVEN_EmptyOrNullString_WHEN_AddIfNotNullOrEmpty_THEN_ShouldNotAddPair()
{
_target.AddIfNotNullOrEmpty("k1", "");
_target.AddIfNotNullOrEmpty("k2", null);
_target.GetParameters().Count.Should().Be(0);
using var content = _target.ToFormUrlEncodedContent();
var payload = await content.ReadAsStringAsync();
payload.Should().Be(string.Empty);
}
[Fact]
public async Task GIVEN_NullableValueHasNoValue_WHEN_AddIfNotNullOrEmpty_Generic_THEN_ShouldNotAddPair()
{
int? value = null;
_target.AddIfNotNullOrEmpty("count", value);
_target.GetParameters().Count.Should().Be(0);
using var content = _target.ToFormUrlEncodedContent();
var payload = await content.ReadAsStringAsync();
payload.Should().Be(string.Empty);
}
[Fact]
public async Task GIVEN_NullableValueHasValue_WHEN_AddIfNotNullOrEmpty_Generic_THEN_ShouldAddPairUsingToString()
{
int? value = 42;
_target.AddIfNotNullOrEmpty("count", value);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("count");
parameters[0].Value.Should().Be("42");
using var content = _target.ToFormUrlEncodedContent();
var payload = await content.ReadAsStringAsync();
payload.Should().Be("count=42");
}
[Fact]
public void GIVEN_FluentAdd_WHEN_Used_THEN_ShouldReturnSameInstance()
{
var returned = _target.Add("x", "y");
ReferenceEquals(_target, returned).Should().BeTrue();
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("x");
parameters[0].Value.Should().Be("y");
}
[Fact]
public async Task GIVEN_CustomParameterList_WHEN_ConstructedWithList_THEN_ShouldUseInjectedList()
{
var backing = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("a", "1")
};
var builder = new FormUrlEncodedBuilder(backing);
builder.Add("b", "2");
var observed = builder.GetParameters();
ReferenceEquals(backing, observed).Should().BeTrue();
observed.Count.Should().Be(2);
observed[0].Key.Should().Be("a");
observed[0].Value.Should().Be("1");
observed[1].Key.Should().Be("b");
observed[1].Value.Should().Be("2");
using var content = builder.ToFormUrlEncodedContent();
var payload = await content.ReadAsStringAsync();
payload.Should().Be("a=1&b=2");
}
}
}

View File

@@ -8,12 +8,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AwesomeAssertions" Version="9.2.1" />
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lantean.QBitTorrentClient\Lantean.QBitTorrentClient.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

View File

@@ -0,0 +1,113 @@
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class MultipartFormDataContentExtensionsTests : IDisposable
{
private readonly MultipartFormDataContent _target;
public MultipartFormDataContentExtensionsTests()
{
_target = new MultipartFormDataContent();
}
[Fact]
public async Task GIVEN_String_WHEN_AddString_THEN_ShouldAddWithNameAndValue()
{
_target.AddString("name", "value");
var part = _target.ToList().Single();
part.Headers.ContentDisposition!.Name.Should().Be("name");
(await part.ReadAsStringAsync()).Should().Be("value");
}
[Fact]
public async Task GIVEN_BoolTrue_WHEN_AddString_THEN_ShouldStoreTrue()
{
_target.AddString("flag", true);
var part = _target.ToList().Single();
part.Headers.ContentDisposition!.Name.Should().Be("flag");
(await part.ReadAsStringAsync()).Should().Be("true");
}
[Fact]
public async Task GIVEN_BoolFalse_WHEN_AddString_THEN_ShouldStoreFalse()
{
_target.AddString("flag", false);
var part = _target.ToList().Single();
part.Headers.ContentDisposition!.Name.Should().Be("flag");
(await part.ReadAsStringAsync()).Should().Be("false");
}
[Fact]
public async Task GIVEN_Int_WHEN_AddString_THEN_ShouldStoreNumericString()
{
_target.AddString("count", 123);
var part = _target.ToList().Single();
part.Headers.ContentDisposition!.Name.Should().Be("count");
(await part.ReadAsStringAsync()).Should().Be("123");
}
[Fact]
public async Task GIVEN_Long_WHEN_AddString_THEN_ShouldStoreNumericString()
{
_target.AddString("size", 9223372036854775807);
var part = _target.ToList().Single();
part.Headers.ContentDisposition!.Name.Should().Be("size");
(await part.ReadAsStringAsync()).Should().Be("9223372036854775807");
}
[Fact]
public async Task GIVEN_Float_WHEN_AddString_THEN_ShouldUseCurrentCultureToString()
{
_target.AddString("ratio", 42f);
var part = _target.ToList().Single();
part.Headers.ContentDisposition!.Name.Should().Be("ratio");
(await part.ReadAsStringAsync()).Should().Be(42f.ToString());
}
[Fact]
public async Task GIVEN_Enum_WHEN_AddString_THEN_ShouldStoreEnumName()
{
_target.AddString("day", DayOfWeek.Monday);
var part = _target.ToList().Single();
part.Headers.ContentDisposition!.Name.Should().Be("day");
(await part.ReadAsStringAsync()).Should().Be("Monday");
}
[Fact]
public async Task GIVEN_DateTimeOffsetSeconds_WHEN_AddString_THEN_ShouldUseUnixSeconds()
{
var when = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
_target.AddString("epoch", when);
var part = _target.ToList().Single();
part.Headers.ContentDisposition!.Name.Should().Be("epoch");
(await part.ReadAsStringAsync()).Should().Be("946684800");
}
[Fact]
public async Task GIVEN_DateTimeOffsetMilliseconds_WHEN_AddString_THEN_ShouldUseUnixMilliseconds()
{
var when = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
_target.AddString("epochMs", when, useSeconds: false);
var part = _target.ToList().Single();
part.Headers.ContentDisposition!.Name.Should().Be("epochMs");
(await part.ReadAsStringAsync()).Should().Be("946684800000");
}
public void Dispose()
{
_target.Dispose();
}
}
}

View File

@@ -0,0 +1,157 @@
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class QueryBuilderExtensionsTests
{
private readonly QueryBuilder _target;
public QueryBuilderExtensionsTests()
{
_target = new QueryBuilder();
}
[Fact]
public void GIVEN_BoolTrue_WHEN_Add_THEN_ShouldStoreTrueString()
{
var returned = _target.Add("flag", true);
ReferenceEquals(_target, returned).Should().BeTrue();
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("flag");
parameters[0].Value.Should().Be("true");
_target.ToQueryString().Should().Be("?flag=true");
}
[Fact]
public void GIVEN_BoolFalse_WHEN_Add_THEN_ShouldStoreFalseString()
{
_target.Add("flag", false);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("flag");
parameters[0].Value.Should().Be("false");
_target.ToQueryString().Should().Be("?flag=false");
}
[Fact]
public void GIVEN_Int_WHEN_Add_THEN_ShouldStoreNumericString()
{
_target.Add("count", 123);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("count");
parameters[0].Value.Should().Be("123");
_target.ToQueryString().Should().Be("?count=123");
}
[Fact]
public void GIVEN_Long_WHEN_Add_THEN_ShouldStoreNumericString()
{
_target.Add("size", 9223372036854775807);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("size");
parameters[0].Value.Should().Be("9223372036854775807");
_target.ToQueryString().Should().Be("?size=9223372036854775807");
}
[Fact]
public void GIVEN_DateTimeOffsetSeconds_WHEN_Add_THEN_ShouldUseUnixSeconds()
{
var when = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
_target.Add("epoch", when);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("epoch");
parameters[0].Value.Should().Be("946684800");
_target.ToQueryString().Should().Be("?epoch=946684800");
}
[Fact]
public void GIVEN_DateTimeOffsetMilliseconds_WHEN_Add_THEN_ShouldUseUnixMilliseconds()
{
var when = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
_target.Add("epochMs", when, useSeconds: false);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("epochMs");
parameters[0].Value.Should().Be("946684800000");
_target.ToQueryString().Should().Be("?epochMs=946684800000");
}
[Fact]
public void GIVEN_Enum_WHEN_Add_THEN_ShouldUseEnumNameString()
{
_target.Add("day", DayOfWeek.Monday);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("day");
parameters[0].Value.Should().Be("Monday");
_target.ToQueryString().Should().Be("?day=Monday");
}
[Fact]
public void GIVEN_PipeSeparatedValues_WHEN_AddPipeSeparated_THEN_ShouldJoinWithPipeAndEscapeInQuery()
{
_target.AddPipeSeparated("list", new[] { "a", "b c", "d|e" });
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("list");
parameters[0].Value.Should().Be("a|b c|d|e");
_target.ToQueryString().Should().Be("?list=a%7Cb%20c%7Cd%7Ce");
}
[Fact]
public void GIVEN_CommaSeparatedValues_WHEN_AddCommaSeparated_THEN_ShouldJoinWithCommaAndEscapeInQuery()
{
_target.AddCommaSeparated("items", new[] { 1, 2, 3 });
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("items");
parameters[0].Value.Should().Be("1,2,3");
_target.ToQueryString().Should().Be("?items=1%2C2%2C3");
}
[Fact]
public void GIVEN_MultipleExtensionAdds_WHEN_Chained_THEN_ShouldPreserveOrderInQuery()
{
_target
.Add("flag", true)
.Add("count", 2)
.Add("day", DayOfWeek.Friday);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(3);
parameters[0].Key.Should().Be("flag");
parameters[0].Value.Should().Be("true");
parameters[1].Key.Should().Be("count");
parameters[1].Value.Should().Be("2");
parameters[2].Key.Should().Be("day");
parameters[2].Value.Should().Be("Friday");
_target.ToQueryString().Should().Be("?flag=true&count=2&day=Friday");
}
}
}

View File

@@ -0,0 +1,159 @@
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
public class QueryBuilderTests
{
private readonly QueryBuilder _target;
public QueryBuilderTests()
{
_target = new QueryBuilder();
}
[Fact]
public void GIVEN_NoParameters_WHEN_ToQueryString_THEN_ShouldBeEmptyString()
{
var result = _target.ToQueryString();
result.Should().Be(string.Empty);
_target.GetParameters().Count.Should().Be(0);
}
[Fact]
public void GIVEN_MultipleParameters_WHEN_ToQueryString_THEN_ShouldStartWithQuestionAndUseAmpersandBetweenPairs()
{
_target.Add("first", "one");
_target.Add("second", "two");
var result = _target.ToQueryString();
result.Should().Be("?first=one&second=two");
var parameters = _target.GetParameters();
parameters.Count.Should().Be(2);
parameters[0].Key.Should().Be("first");
parameters[0].Value.Should().Be("one");
parameters[1].Key.Should().Be("second");
parameters[1].Value.Should().Be("two");
}
[Fact]
public void GIVEN_SpecialChars_WHEN_ToQueryString_THEN_ShouldBeUriEscaped()
{
_target.Add("a b", "c+d&");
_target.Add("こんにちは", "é l'œ");
var result = _target.ToQueryString();
result.Should().Be("?a%20b=c%2Bd%26&%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF=%C3%A9%20l%27%C5%93");
var parameters = _target.GetParameters();
parameters.Count.Should().Be(2);
parameters[0].Key.Should().Be("a b");
parameters[0].Value.Should().Be("c+d&");
parameters[1].Key.Should().Be("こんにちは");
parameters[1].Value.Should().Be("é l'œ");
}
[Fact]
public void GIVEN_NonEmptyString_WHEN_AddIfNotNullOrEmpty_THEN_ShouldAddPair()
{
_target.AddIfNotNullOrEmpty("key", "value");
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("key");
parameters[0].Value.Should().Be("value");
_target.ToQueryString().Should().Be("?key=value");
}
[Fact]
public void GIVEN_EmptyOrNullString_WHEN_AddIfNotNullOrEmpty_THEN_ShouldNotAddPair()
{
_target.AddIfNotNullOrEmpty("k1", "");
_target.AddIfNotNullOrEmpty("k2", null);
_target.GetParameters().Count.Should().Be(0);
_target.ToQueryString().Should().Be(string.Empty);
}
[Fact]
public void GIVEN_NullableValueHasNoValue_WHEN_AddIfNotNullOrEmpty_THEN_ShouldNotAddPair()
{
int? value = null;
_target.AddIfNotNullOrEmpty("count", value);
_target.GetParameters().Count.Should().Be(0);
_target.ToQueryString().Should().Be(string.Empty);
}
[Fact]
public void GIVEN_NullableValueHasValue_WHEN_AddIfNotNullOrEmpty_THEN_ShouldAddPairUsingToString()
{
int? value = 42;
_target.AddIfNotNullOrEmpty("count", value);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("count");
parameters[0].Value.Should().Be("42");
_target.ToQueryString().Should().Be("?count=42");
}
[Fact]
public void GIVEN_FluentAdd_WHEN_Used_THEN_ShouldReturnSameInstanceAndAppendParameter()
{
var returned = _target.Add("x", "y");
ReferenceEquals(_target, returned).Should().BeTrue();
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("x");
parameters[0].Value.Should().Be("y");
}
[Fact]
public void GIVEN_CustomParameterList_WHEN_ConstructedWithList_THEN_ShouldUseInjectedList()
{
var backing = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("a", "1")
};
var builder = new QueryBuilder(backing);
builder.Add("b", "2");
var observed = builder.GetParameters();
ReferenceEquals(backing, observed).Should().BeTrue();
observed.Count.Should().Be(2);
observed[0].Key.Should().Be("a");
observed[0].Value.Should().Be("1");
observed[1].Key.Should().Be("b");
observed[1].Value.Should().Be("2");
builder.ToQueryString().Should().Be("?a=1&b=2");
}
[Fact]
public void GIVEN_ToStringCalled_WHEN_ParametersExist_THEN_ShouldReturnSameAsToQueryString()
{
_target.Add("p", "q");
var qs = _target.ToQueryString();
var result = _target.ToString();
result.Should().Be(qs);
var parameters = _target.GetParameters();
parameters.Count.Should().Be(1);
parameters[0].Key.Should().Be("p");
parameters[0].Value.Should().Be("q");
}
}
}

View File

@@ -0,0 +1,15 @@
using AwesomeAssertions;
namespace Lantean.QBitTorrentClient.Test
{
internal sealed class StubHttpMessageHandler : HttpMessageHandler
{
public Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>>? Responder { get; set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Responder.Should().NotBeNull();
return Responder!(request, cancellationToken);
}
}
}

View File

@@ -1,11 +0,0 @@
namespace Lantean.QBitTorrentClient.Test
{
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
}

View File

@@ -1,9 +1,5 @@
using Lantean.QBitTorrentClient.Models;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
@@ -1230,10 +1226,10 @@ namespace Lantean.QBitTorrentClient
public async Task<string> AddTorrentCreationTask(TorrentCreationTaskRequest request)
{
if (request is null)
throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(request.SourcePath))
{
throw new ArgumentException("SourcePath is required.", nameof(request));
}
var builder = new FormUrlEncodedBuilder()
.Add("sourcePath", request.SourcePath);

View File

@@ -0,0 +1,44 @@
using Lantean.QBitTorrentClient.Models;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Converters
{
internal sealed class DownloadPathOptionJsonConverter : JsonConverter<DownloadPathOption>
{
public override DownloadPathOption? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.Null => null,
JsonTokenType.False => new DownloadPathOption(false, null),
JsonTokenType.True => new DownloadPathOption(true, null),
JsonTokenType.String => new DownloadPathOption(true, reader.GetString()),
_ => throw new JsonException($"Unexpected token {reader.TokenType} when parsing download_path.")
};
}
public override void Write(Utf8JsonWriter writer, DownloadPathOption? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
if (!value.Enabled)
{
writer.WriteBooleanValue(false);
return;
}
if (string.IsNullOrWhiteSpace(value.Path))
{
writer.WriteBooleanValue(true);
return;
}
writer.WriteStringValue(value.Path);
}
}
}

View File

@@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Converters
{
public class SaveLocationJsonConverter : JsonConverter<SaveLocation>
internal class SaveLocationJsonConverter : JsonConverter<SaveLocation>
{
public override SaveLocation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{

View File

@@ -44,5 +44,10 @@
{
return new FormUrlEncodedContent(_parameters);
}
internal IList<KeyValuePair<string, string>> GetParameters()
{
return _parameters;
}
}
}

View File

@@ -4,7 +4,11 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Lantean.QBitTorrentClient.Test" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using Lantean.QBitTorrentClient.Converters;
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Models
{
@@ -7,10 +8,12 @@ namespace Lantean.QBitTorrentClient.Models
[JsonConstructor]
public Category(
string name,
string? savePath)
string? savePath,
DownloadPathOption? downloadPath)
{
Name = name;
SavePath = savePath;
DownloadPath = downloadPath;
}
[JsonPropertyName("name")]
@@ -18,5 +21,9 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("savePath")]
public string? SavePath { get; }
[JsonPropertyName("download_path")]
[JsonConverter(typeof(DownloadPathOptionJsonConverter))]
public DownloadPathOption? DownloadPath { get; }
}
}

View File

@@ -0,0 +1,15 @@
namespace Lantean.QBitTorrentClient.Models
{
public record DownloadPathOption
{
public DownloadPathOption(bool enabled, string? path)
{
Enabled = enabled;
Path = path;
}
public bool Enabled { get; }
public string? Path { get; }
}
}

View File

@@ -14,7 +14,7 @@ namespace Lantean.QBitTorrentClient.Models
IReadOnlyList<string>? categoriesRemoved,
IReadOnlyList<string>? tags,
IReadOnlyList<string>? tagsRemoved,
IReadOnlyDictionary<string, IReadOnlyList<string>> trackers,
IReadOnlyDictionary<string, IReadOnlyList<string>>? trackers,
IReadOnlyList<string>? trackersRemoved,
ServerState? serverState)
{

View File

@@ -221,7 +221,10 @@ namespace Lantean.QBitTorrentClient.Models
bool webUiUpnp,
bool webUiUseCustomHttpHeadersEnabled,
string webUiUsername,
string webUiPassword
string webUiPassword,
bool confirmTorrentDeletion,
bool confirmTorrentRecheck,
bool statusBarExternalIp
)
{
AddToTopOfQueue = addToTopOfQueue;
@@ -440,6 +443,9 @@ namespace Lantean.QBitTorrentClient.Models
WebUiUseCustomHttpHeadersEnabled = webUiUseCustomHttpHeadersEnabled;
WebUiUsername = webUiUsername;
WebUiPassword = webUiPassword;
ConfirmTorrentDeletion = confirmTorrentDeletion;
ConfirmTorrentRecheck = confirmTorrentRecheck;
StatusBarExternalIp = statusBarExternalIp;
}
[JsonPropertyName("add_to_top_of_queue")]
@@ -1089,5 +1095,14 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("web_ui_password")]
public string WebUiPassword { get; }
[JsonPropertyName("confirm_torrent_deletion")]
public bool ConfirmTorrentDeletion { get; }
[JsonPropertyName("confirm_torrent_recheck")]
public bool ConfirmTorrentRecheck { get; }
[JsonPropertyName("status_bar_external_ip")]
public bool StatusBarExternalIp { get; }
}
}

View File

@@ -12,49 +12,66 @@
{
if (value is int intValue)
{
if (intValue == 0)
{
return new SaveLocation
{
IsWatchedFolder = true
};
}
else if (intValue == 1)
{
return new SaveLocation
{
IsDefaultFolder = true
};
}
return Create(intValue);
}
else if (value is string stringValue)
{
if (stringValue == "0")
{
return new SaveLocation
{
IsWatchedFolder = true
};
}
else if (stringValue == "1")
{
return new SaveLocation
{
IsDefaultFolder = true
};
}
else
{
return new SaveLocation
{
SavePath = stringValue
};
}
return Create(stringValue);
}
throw new ArgumentOutOfRangeException(nameof(value));
}
public static SaveLocation Create(int value)
{
if (value == 0)
{
return new SaveLocation
{
IsWatchedFolder = true
};
}
else if (value == 1)
{
return new SaveLocation
{
IsDefaultFolder = true
};
}
throw new ArgumentOutOfRangeException(nameof(value));
}
public static SaveLocation Create(string? value)
{
if (value is null)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
if (value == "0")
{
return new SaveLocation
{
IsWatchedFolder = true
};
}
else if (value == "1")
{
return new SaveLocation
{
IsDefaultFolder = true
};
}
else
{
return new SaveLocation
{
SavePath = value
};
}
}
public object ToValue()
{
if (IsWatchedFolder)

View File

@@ -30,7 +30,9 @@ namespace Lantean.QBitTorrentClient.Models
long? uploadRateLimit,
bool? useAltSpeedLimits,
bool? useSubcategories,
float? writeCacheOverload) : base(connectionStatus, dHTNodes, downloadInfoData, downloadInfoSpeed, downloadRateLimit, uploadInfoData, uploadInfoSpeed, uploadRateLimit)
float? writeCacheOverload,
string? lastExternalAddressV4 = null,
string? lastExternalAddressV6 = null) : base(connectionStatus, dHTNodes, downloadInfoData, downloadInfoSpeed, downloadRateLimit, uploadInfoData, uploadInfoSpeed, uploadRateLimit)
{
AllTimeDownloaded = allTimeDownloaded;
AllTimeUploaded = allTimeUploaded;
@@ -49,6 +51,8 @@ namespace Lantean.QBitTorrentClient.Models
UseAltSpeedLimits = useAltSpeedLimits;
UseSubcategories = useSubcategories;
WriteCacheOverload = writeCacheOverload;
LastExternalAddressV4 = lastExternalAddressV4;
LastExternalAddressV6 = lastExternalAddressV6;
}
[JsonPropertyName("alltime_dl")]
@@ -101,5 +105,11 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("write_cache_overload")]
public float? WriteCacheOverload { get; }
[JsonPropertyName("last_external_address_v4")]
public string? LastExternalAddressV4 { get; }
[JsonPropertyName("last_external_address_v6")]
public string? LastExternalAddressV6 { get; }
}
}

View File

@@ -653,6 +653,15 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("web_ui_password")]
public string? WebUiPassword { get; set; }
[JsonPropertyName("confirm_torrent_deletion")]
public bool? ConfirmTorrentDeletion { get; set; }
[JsonPropertyName("confirm_torrent_recheck")]
public bool? ConfirmTorrentRecheck { get; set; }
[JsonPropertyName("status_bar_external_ip")]
public bool? StatusBarExternalIp { get; set; }
public void Validate()
{
if (MaxRatio.HasValue && MaxRatioEnabled.HasValue)

View File

@@ -73,5 +73,10 @@ namespace Lantean.QBitTorrentClient
{
return ToQueryString();
}
internal IList<KeyValuePair<string, string>> GetParameters()
{
return _parameters;
}
}
}

25
Upgrade-To-v5-Planning.md Normal file
View File

@@ -0,0 +1,25 @@
# Upgrade to qBittorrent WebUI v5 UI Alignment Plan
## Torrent List Filtering
- **Regex toggle & field selector**: Introduce the regex checkbox and the "Filter by" (Name/Save path) select found in v5. Update `FilterState`/`LoggedInLayout` to carry both values, wire them to `TorrentList`s toolbar, and validate invalid patterns gracefully.
- **Filter helper parity**: Rework `FilterHelper.ContainsAllTerms/FilterTerms` to mirror `window.qBittorrent.Misc.containsAllTerms` (evaluate every term, respect `+`/`-` prefixes). Ensure filtering applies to the selected field, not just the torrent name.
- **New status buckets**: Add `Running` and `Moving` to `Status` enum, update `FilterHelper.FilterStatus`, `DisplayHelpers`, and `FiltersNav` so counts/icons match upstream.
## Tracker Filters
- **Special buckets**: Extend `FilterHelper`/`DataManager` to create sets for "Announce error", "Error", "Warning", and "Trackerless" in addition to "All". Store the required flags on the UI `Torrent` model (`HasTrackerError`, `HasTrackerWarning`, `HasOtherAnnounceError`, `TrackersCount`, etc.).
- **Tracker grouping & removal**: When grouping trackers by host in `FiltersNav`, retain original URL entries so removal can target the right string. Replace the placeholder "Remove tracker" action with a real implementation and disable it for synthetic buckets.
## ~~Torrent Data Model & Columns~~
- ~~**Model sync**: Bring `Lantean.QBTMud.Models.Torrent` into parity with v5 (`Popularity`, `DownloadPath`, `RootPath`, `InfoHashV1/2`, `IsPrivate`, share-limit action fields, tracker flags, etc.) and map them in `DataManager.CreateTorrent`.~~
- ~~**Column set alignment**: Match the v5 table defaults—add missing columns (Popularity, Reannounce in, Info hashes, Download path, Private, etc.), fix "Ratio Limit" to display `RatioLimit`, and ensure column ordering/enabled state mirrors `DynamicTable.TorrentsTable`.~~
- ~~**Helper updates**: Extend `DisplayHelpers` to format the new fields (popularity, private flag, info hashes, error state icons).~~
## Actions & Dialogs
- ~~**Copy submenu**: Add "Copy comment" and "Copy content path" to the copy submenu in `TorrentActions`, keeping clipboard behaviour identical to v5.~~
- ~~**Share ratio dialog**: Update `ShareRatioDialog`, `ShareRatio/ShareRatioMax`, and `DialogHelper.InvokeShareRatioDialog` to surface `ShareLimitAction`, fix the `MaxInactiveSeedingTime` mapping, and call `SetTorrentShareLimit` with the action.~~
## ~~Add-Torrent Flow~~
- ~~Mirror the v5 add-torrent pane: add controls for incomplete save path, tags, auto-start, queue position, share-limit action, etc., in `AddTorrentOptions.razor`, and wire the new fields into the submission object.~~
## ~~Preferences & Local Settings~~
- ~~Introduce new v5 toggles such as "Display full tracker URL" in `AdvancedOptions`, persist them via the preferences service, and respect the setting in the tracker column rendering.~~