15 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
ahjephson
0db0ad4374 Remove other v4 logic 2025-10-21 14:20:25 +01:00
ahjephson
c390d83e4d Update to use v5 api only. 2025-10-21 13:38:50 +01:00
ahjephson
8dd29c238d Update client to use net v5 apis 2025-10-21 13:12:38 +01:00
ahjephson
fca17edfd1 Merge tag '1.2.0' into develop
1.2.0
2025-10-20 20:56:10 +01:00
103 changed files with 10580 additions and 1256 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

@@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
readme.md = readme.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lantean.QBitTorrentClient.Test", "Lantean.QBitTorrentClient.Test\Lantean.QBitTorrentClient.Test.csproj", "{796E865C-7AA6-4BD9-B12F-394801199A75}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -33,6 +35,10 @@ Global
{83BC76CC-D51B-42AF-A6EE-FA400C300098}.Debug|Any CPU.Build.0 = Debug|Any CPU
{83BC76CC-D51B-42AF-A6EE-FA400C300098}.Release|Any CPU.ActiveCfg = Release|Any CPU
{83BC76CC-D51B-42AF-A6EE-FA400C300098}.Release|Any CPU.Build.0 = Release|Any CPU
{796E865C-7AA6-4BD9-B12F-394801199A75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{796E865C-7AA6-4BD9-B12F-394801199A75}.Debug|Any CPU.Build.0 = Debug|Any CPU
{796E865C-7AA6-4BD9-B12F-394801199A75}.Release|Any CPU.ActiveCfg = Release|Any CPU
{796E865C-7AA6-4BD9-B12F-394801199A75}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

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">
<MudItem xs="12">
<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>
<MudSelectItem Value="@("NoSubfolder")">Don't create subfolder</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Download in sequentual order" @bind-Value="DownloadInSequentialOrder" />
<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;
StartTorrent = !preferences.StartPausedEnabled;
_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

@@ -53,7 +53,7 @@
<MudNumericField T="int" Label="Ignore Subsequent Matches for (0 to Disable)" Value="IgnoreDays" ValueChanged="IgnoreDaysChanged" Disabled="@(SelectedRuleName is null)" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12">
<MudSelect T="string" Label="Add paused" Value="AddPaused" ValueChanged="AddPausedChanged" Disabled="@(SelectedRuleName is null)" Variant="Variant.Outlined">
<MudSelect T="string" Label="Add stopped" Value="AddStopped" ValueChanged="AddStoppedChanged" Disabled="@(SelectedRuleName is null)" Variant="Variant.Outlined">
<MudSelectItem Value="@("default")">Use global settings</MudSelectItem>
<MudSelectItem Value="@("always")">Always</MudSelectItem>
<MudSelectItem Value="@("never")">Never</MudSelectItem>

View File

@@ -114,11 +114,11 @@ namespace Lantean.QBTMud.Components.Dialogs
SelectedRule.IgnoreDays = value;
}
protected string? AddPaused { get; set; }
protected string? AddStopped { get; set; }
protected void AddPausedChanged(string value)
protected void AddStoppedChanged(string value)
{
AddPaused = value;
AddStopped = value;
switch (value)
{
case "default":
@@ -273,15 +273,15 @@ namespace Lantean.QBTMud.Components.Dialogs
switch (SelectedRule.TorrentParams.Stopped)
{
case null:
AddPaused = "default";
AddStopped = "default";
break;
case true:
AddPaused = "always";
AddStopped = "always";
break;
case false:
AddPaused = "never";
AddStopped = "never";
break;
}

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;
Ratio = 0;
}
if (Value.SeedingTimeLimit >= 0)
if (baseline.SeedingTimeLimit >= 0)
{
TotalMinutesEnabled = true;
TotalMinutes = (int)Value.SeedingTimeLimit;
TotalMinutes = (int)baseline.SeedingTimeLimit;
}
if (Value.InactiveSeedingTimeLimit >= 0)
else
{
TotalMinutes = 0;
}
if (baseline.InactiveSeedingTimeLimit >= 0)
{
InactiveMinutesEnabled = true;
InactiveMinutes = (int)Value.InactiveSeedingTimeLimit;
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

@@ -65,8 +65,8 @@
{
return __builder =>
{
<MudMenuItem Icon="@Icons.Material.Filled.PlayArrow" IconColor="Color.Success" OnClick="@(e => ResumeTorrents(type))">Resume torrents</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Pause" IconColor="Color.Warning" OnClick="@(e => PauseTorrents(type))">Pause torrents</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.PlayArrow" IconColor="Color.Success" OnClick="@(e => StartTorrents(type))">Start torrents</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Stop" IconColor="Color.Warning" OnClick="@(e => StopTorrents(type))">Stop torrents</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="@(e => RemoveTorrents(type))">Remove torrents</MudMenuItem>
};
}

View File

@@ -5,6 +5,7 @@ using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using MudBlazor;
using System.Linq;
namespace Lantean.QBTMud.Components
{
@@ -352,25 +353,25 @@ namespace Lantean.QBTMud.Components
}
}
protected async Task ResumeTorrents(string type)
protected async Task StartTorrents(string type)
{
var torrents = GetAffectedTorrentHashes(type);
await ApiClient.ResumeTorrents(torrents);
await ApiClient.StartTorrents(hashes: torrents.ToArray());
}
protected async Task PauseTorrents(string type)
protected async Task StopTorrents(string type)
{
var torrents = GetAffectedTorrentHashes(type);
await ApiClient.PauseTorrents(torrents);
await ApiClient.StopTorrents(hashes: torrents.ToArray());
}
protected async Task RemoveTorrents(string type)
{
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

@@ -19,7 +19,7 @@
<FieldSwitch Label="Add to top of queue" Value="AddToTopOfQueue" ValueChanged="AddToTopOfQueueChanged" />
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Do not start the download automatically" Value="StartPausedEnabled" ValueChanged="StartPausedEnabledChanged" />
<FieldSwitch Label="Do not start the download automatically" Value="AddStoppedEnabled" ValueChanged="AddStoppedEnabledChanged" />
</MudItem>
<MudItem xs="12">
<MudSelect T="string" Label="Torrent stop condition" Value="TorrentStopCondition" ValueChanged="TorrentStopConditionChanged" Variant="Variant.Outlined">

View File

@@ -6,7 +6,7 @@ namespace Lantean.QBTMud.Components.Options
{
protected string? TorrentContentLayout { get; set; }
protected bool AddToTopOfQueue { get; set; }
protected bool StartPausedEnabled { get; set; }
protected bool AddStoppedEnabled { get; set; }
protected string? TorrentStopCondition { get; set; }
protected bool AutoDeleteMode { get; set; }
protected bool PreallocateAll { get; set; }
@@ -51,7 +51,7 @@ namespace Lantean.QBTMud.Components.Options
// when adding a torrent
TorrentContentLayout = Preferences.TorrentContentLayout;
AddToTopOfQueue = Preferences.AddToTopOfQueue;
StartPausedEnabled = Preferences.StartPausedEnabled;
AddStoppedEnabled = Preferences.AddStoppedEnabled;
TorrentStopCondition = Preferences.TorrentStopCondition;
AutoDeleteMode = Preferences.AutoDeleteMode == 1;
PreallocateAll = Preferences.PreallocateAll;
@@ -116,10 +116,10 @@ namespace Lantean.QBTMud.Components.Options
await PreferencesChanged.InvokeAsync(UpdatePreferences);
}
protected async Task StartPausedEnabledChanged(bool value)
protected async Task AddStoppedEnabledChanged(bool value)
{
StartPausedEnabled = value;
UpdatePreferences.StartPausedEnabled = value;
AddStoppedEnabled = value;
UpdatePreferences.AddStoppedEnabled = value;
await PreferencesChanged.InvokeAsync(UpdatePreferences);
}

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

@@ -29,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!;
@@ -37,9 +37,6 @@ namespace Lantean.QBTMud.Components
[Inject]
protected IKeyboardService KeyboardService { get; set; } = default!;
[CascadingParameter(Name = "Version")]
public string? Version { get; set; }
[Parameter]
[EditorRequired]
public IEnumerable<string> Hashes { get; set; } = default!;
@@ -71,14 +68,12 @@ namespace Lantean.QBTMud.Components
protected bool OverlayVisible { get; set; }
protected int MajorVersion => VersionHelper.GetMajorVersion(Version);
protected override void OnInitialized()
{
_actions =
[
new("start", "Start", Icons.Material.Filled.PlayArrow, Color.Success, CreateCallback(Resume)),
new("pause", "Pause", MajorVersion < 5 ? Icons.Material.Filled.Pause : Icons.Material.Filled.Stop, Color.Warning, CreateCallback(Pause)),
new("start", "Start", Icons.Material.Filled.PlayArrow, Color.Success, CreateCallback(Start)),
new("stop", "Stop", Icons.Material.Filled.Stop, Color.Warning, CreateCallback(Stop)),
new("forceStart", "Force start", Icons.Material.Filled.Forward, Color.Warning, CreateCallback(ForceStart)),
new("delete", "Remove", Icons.Material.Filled.Delete, Color.Error, CreateCallback(Remove), separatorBefore: true),
new("setLocation", "Set location", Icons.Material.Filled.MyLocation, Color.Info, CreateCallback(SetLocation), separatorBefore: true),
@@ -109,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)),
];
@@ -146,33 +143,17 @@ namespace Lantean.QBTMud.Components
OverlayVisible = value;
}
protected async Task Pause()
protected async Task Stop()
{
if (MajorVersion < 5)
{
await ApiClient.PauseTorrents(Hashes);
Snackbar.Add("Torrent paused.");
}
else
{
await ApiClient.StopTorrents(Hashes);
await ApiClient.StopTorrents(hashes: Hashes.ToArray());
Snackbar.Add("Torrent stopped.");
}
}
protected async Task Resume()
protected async Task Start()
{
if (MajorVersion < 5)
{
await ApiClient.ResumeTorrents(Hashes);
Snackbar.Add("Torrent resumed.");
}
else
{
await ApiClient.StartTorrents(Hashes);
await ApiClient.StartTorrents(hashes: Hashes.ToArray());
Snackbar.Add("Torrent started.");
}
}
protected async Task ForceStart()
{
@@ -182,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)
{
@@ -278,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()
@@ -385,8 +366,8 @@ namespace Lantean.QBTMud.Components
var allAreFirstLastPiecePrio = true;
var thereAreFirstLastPiecePrio = false;
var allAreDownloaded = true;
var allArePaused = true;
var thereArePaused = false;
var allAreStopped = true;
var thereAreStopped = false;
var allAreForceStart = true;
var thereAreForceStart = false;
var allAreSuperSeeding = true;
@@ -424,27 +405,13 @@ namespace Lantean.QBTMud.Components
allAreSuperSeeding = false;
}
if (MajorVersion < 5)
{
if (torrent.State != "pausedUP" && torrent.State != "pausedDL")
{
allArePaused = false;
}
else
{
thereArePaused = true;
}
}
else
{
if (torrent.State != "stoppedUP" && torrent.State != "stoppedDL")
{
allArePaused = false;
allAreStopped = false;
}
else
{
thereArePaused = true;
}
thereAreStopped = true;
}
if (!torrent.ForceStart)
@@ -532,7 +499,7 @@ namespace Lantean.QBTMud.Components
actionStates["superSeeding"] = ActionState.Hidden;
}
if (allArePaused)
if (allAreStopped)
{
actionStates["pause"] = ActionState.Hidden;
}
@@ -540,13 +507,11 @@ namespace Lantean.QBTMud.Components
{
actionStates["forceStart"] = ActionState.Hidden;
}
else if (!thereArePaused && !thereAreForceStart)
else if (!thereAreStopped && !thereAreForceStart)
{
actionStates["start"] = ActionState.Hidden;
}
if (MajorVersion >= 5)
{
if (actionStates.TryGetValue("start", out ActionState? startActionState))
{
startActionState.TextOverride = "Start";
@@ -564,7 +529,6 @@ namespace Lantean.QBTMud.Components
{
actionStates["pause"] = new ActionState { TextOverride = "Stop" };
}
}
if (!allAreAutoTmm && thereAreAutoTmm)
{

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; }
@@ -171,7 +171,7 @@ namespace Lantean.QBTMud.Components
return;
}
await ApiClient.AddTrackersToTorrent(Hash, trackers);
await ApiClient.AddTrackersToTorrent(trackers, hashes: new[] { Hash });
}
protected Task EditTrackerToolbar()
@@ -211,7 +211,7 @@ namespace Lantean.QBTMud.Components
return;
}
await ApiClient.RemoveTrackers(Hash, [tracker.Url]);
await ApiClient.RemoveTrackers([tracker.Url], hashes: new[] { Hash });
}
protected Task CopyTrackerUrlToolbar()

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,4 +1,5 @@
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;
@@ -56,7 +57,7 @@ namespace Lantean.QBTMud.Helpers
var addTorrentParams = CreateAddTorrentParams(options);
addTorrentParams.Torrents = files;
await apiClient.AddTorrent(addTorrentParams);
_ = await apiClient.AddTorrent(addTorrentParams);
foreach (var stream in streams)
{
@@ -74,18 +75,19 @@ namespace Lantean.QBTMud.Helpers
{
addTorrentParams.ContentLayout = Enum.Parse<QBitTorrentClient.Models.TorrentContentLayout>(options.ContentLayout);
}
if (string.IsNullOrEmpty(options.Cookie))
{
addTorrentParams.Cookie = options.Cookie;
}
addTorrentParams.DownloadLimit = options.DownloadLimit;
if (!string.IsNullOrWhiteSpace(options.DownloadPath))
{
addTorrentParams.DownloadPath = options.DownloadPath;
}
addTorrentParams.FirstLastPiecePriority = options.DownloadFirstAndLastPiecesFirst;
addTorrentParams.InactiveSeedingTimeLimit = options.InactiveSeedingTimeLimit;
addTorrentParams.Paused = !options.StartTorrent;
addTorrentParams.RatioLimit = options.RatioLimit;
addTorrentParams.RenameTorrent = options.RenameTorrent;
if (!options.TorrentManagementMode)
{
addTorrentParams.SavePath = options.SavePath;
}
addTorrentParams.SeedingTimeLimit = options.SeedingTimeLimit;
addTorrentParams.SequentialDownload = options.DownloadInSequentialOrder;
if (!string.IsNullOrEmpty(options.ShareLimitAction))
@@ -100,7 +102,10 @@ namespace Lantean.QBTMud.Helpers
addTorrentParams.Stopped = !options.StartTorrent;
addTorrentParams.Tags = options.Tags;
addTorrentParams.UploadLimit = options.UploadLimit;
if (options.UseDownloadPath.HasValue)
{
addTorrentParams.UseDownloadPath = options.UseDownloadPath;
}
return addTorrentParams;
}
@@ -123,10 +128,10 @@ namespace Lantean.QBTMud.Helpers
var addTorrentParams = CreateAddTorrentParams(options);
addTorrentParams.Urls = options.Urls;
await apiClient.AddTorrent(addTorrentParams);
_ = 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)
{
@@ -138,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)
@@ -150,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();
@@ -217,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);
@@ -243,7 +285,7 @@ namespace Lantean.QBTMud.Helpers
var shareRatio = (ShareRatio)dialogResult.Data;
await apiClient.SetTorrentShareLimit(shareRatio.RatioLimit, shareRatio.SeedingTimeLimit, shareRatio.InactiveSeedingTimeLimit, null, 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)
@@ -437,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;
@@ -404,8 +405,6 @@ namespace Lantean.QBTMud.Helpers
Status.Downloading => (Icons.Material.Filled.Downloading, Color.Success),
Status.Seeding => (Icons.Material.Filled.Upload, Color.Info),
Status.Completed => (Icons.Material.Filled.Check, Color.Default),
Status.Resumed => (Icons.Material.Filled.PlayArrow, Color.Success),
Status.Paused => (Icons.Material.Filled.Pause, Color.Default),
Status.Stopped => (Icons.Material.Filled.Stop, Color.Default),
Status.Active => (Icons.Material.Filled.Sort, Color.Success),
Status.Inactive => (Icons.Material.Filled.Sort, Color.Error),
@@ -417,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

@@ -200,15 +200,8 @@ namespace Lantean.QBTMud.Helpers
break;
case Status.Resumed:
if (!state.Contains("resumed"))
{
return false;
}
break;
case Status.Paused:
if (!state.Contains("paused") && !state.Contains("stopped"))
case Status.Stopped:
if (state != "stoppedDL" && state != "stoppedUP")
{
return false;
}

View File

@@ -1,33 +0,0 @@
namespace Lantean.QBTMud.Helpers
{
internal static class VersionHelper
{
private static int? _version;
private const int _defaultVersion = 5;
public static int DefaultVersion => _defaultVersion;
public static int GetMajorVersion(string? version)
{
if (_version is not null)
{
return _version.Value;
}
if (string.IsNullOrEmpty(version))
{
return _defaultVersion;
}
if (!Version.TryParse(version?.Replace("v", ""), out var theVersion))
{
return _defaultVersion;
}
_version = theVersion.Major;
return _version.Value;
}
}
}

View File

@@ -23,4 +23,8 @@
<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!;
@@ -97,7 +97,7 @@ namespace Lantean.QBTMud.Layout
Preferences = await ApiClient.GetApplicationPreferences();
Version = await ApiClient.GetApplicationVersion();
var data = await ApiClient.GetMainData(_requestId);
MainData = DataManager.CreateMainData(data, Version);
MainData = DataManager.CreateMainData(data);
MarkTorrentsDirty();
_requestId = data.ResponseId;
@@ -145,7 +145,7 @@ namespace Lantean.QBTMud.Layout
if (MainData is null || data.FullUpdate)
{
MainData = DataManager.CreateMainData(data, Version);
MainData = DataManager.CreateMainData(data);
MarkTorrentsDirty();
shouldRender = true;
}
@@ -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

@@ -11,8 +11,7 @@
Dictionary<string, HashSet<string>> tagState,
Dictionary<string, HashSet<string>> categoriesState,
Dictionary<string, HashSet<string>> statusState,
Dictionary<string, HashSet<string>> trackersState,
int majorVersion)
Dictionary<string, HashSet<string>> trackersState)
{
Torrents = torrents.ToDictionary();
Tags = tags.ToHashSet();
@@ -23,7 +22,6 @@
CategoriesState = categoriesState;
StatusState = statusState;
TrackersState = trackersState;
MajorVersion = majorVersion;
}
public Dictionary<string, Torrent> Torrents { get; }
@@ -38,6 +36,5 @@
public Dictionary<string, HashSet<string>> TrackersState { get; }
public string? SelectedTorrentHash { get; set; }
public bool LostConnection { get; set; }
public int MajorVersion { get; }
}
}

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,8 +6,6 @@
Downloading,
Seeding,
Completed,
Resumed,
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,24 +2,16 @@
namespace Lantean.QBTMud.Services
{
public interface IDataManager
public interface ITorrentDataManager
{
MainData CreateMainData(QBitTorrentClient.Models.MainData mainData, string version);
MainData CreateMainData(QBitTorrentClient.Models.MainData mainData);
Torrent CreateTorrent(string hash, QBitTorrentClient.Models.Torrent torrent);
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,33 +1,15 @@
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)
public MainData CreateMainData(QBitTorrentClient.Models.MainData mainData)
{
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, string version)
{
var majorVersion = VersionHelper.GetMajorVersion(version);
var torrents = new Dictionary<string, Torrent>(mainData.Torrents?.Count ?? 0);
if (mainData.Torrents is not null)
{
@@ -95,7 +77,7 @@ namespace Lantean.QBTMud.Services
categoriesState.Add(category, torrents.Values.Where(t => FilterHelper.FilterCategory(t, category, serverState.UseSubcategories)).ToHashesHashSet());
}
var statuses = GetStatuses(majorVersion).ToArray();
var statuses = GetStatuses().ToArray();
var statusState = new Dictionary<string, HashSet<string>>(statuses.Length + 2);
foreach (var status in statuses)
{
@@ -110,7 +92,7 @@ namespace Lantean.QBTMud.Services
trackersState.Add(tracker, torrents.Values.Where(t => FilterHelper.FilterTracker(t, tracker)).ToHashesHashSet());
}
var torrentList = new MainData(torrents, tags, categories, trackers, serverState, tagState, categoriesState, statusState, trackersState, majorVersion);
var torrentList = new MainData(torrents, tags, categories, trackers, serverState, tagState, categoriesState, statusState, trackersState);
return torrentList;
}
@@ -146,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)
@@ -184,7 +168,6 @@ namespace Lantean.QBTMud.Services
{
filterChanged = true;
}
torrentList.TagState.Remove(normalizedTag);
}
}
@@ -208,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;
}
@@ -284,7 +272,7 @@ namespace Lantean.QBTMud.Services
{
var newTorrent = CreateTorrent(hash, torrent);
torrentList.Torrents.Add(hash, newTorrent);
AddTorrentToStates(torrentList, hash, torrentList.MajorVersion);
AddTorrentToStates(torrentList, hash);
dataChanged = true;
filterChanged = true;
}
@@ -316,7 +304,7 @@ namespace Lantean.QBTMud.Services
return dataChanged;
}
private static void AddTorrentToStates(MainData torrentList, string hash, int version)
private static void AddTorrentToStates(MainData torrentList, string hash)
{
if (!torrentList.Torrents.TryGetValue(hash, out var torrent))
{
@@ -329,7 +317,7 @@ namespace Lantean.QBTMud.Services
torrentList.CategoriesState[FilterHelper.CATEGORY_ALL].Add(hash);
UpdateCategoryState(torrentList, torrent, hash, previousCategory: null);
foreach (var status in GetStatuses(version))
foreach (var status in GetStatuses())
{
if (!torrentList.StatusState.TryGetValue(status.ToString(), out var statusSet))
{
@@ -346,21 +334,14 @@ namespace Lantean.QBTMud.Services
UpdateTrackerState(torrentList, torrent, hash, previousTracker: null);
}
private static Status[] GetStatuses(int version)
private static Status[] GetStatuses()
{
if (_statusArray is not null)
{
return _statusArray;
}
if (version == 5)
{
_statusArray = Enum.GetValues<Status>().Where(s => s != Status.Paused).ToArray();
}
else
{
_statusArray = Enum.GetValues<Status>().Where(s => s != Status.Stopped).ToArray();
}
_statusArray = Enum.GetValues<Status>();
return _statusArray;
}
@@ -373,22 +354,15 @@ 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);
torrentList.CategoriesState[FilterHelper.CATEGORY_ALL].Remove(hash);
UpdateCategoryStateForRemoval(torrentList, hash, snapshot.Category);
foreach (var status in GetStatuses(torrentList.MajorVersion))
foreach (var status in GetStatuses())
{
if (!torrentList.StatusState.TryGetValue(status.ToString(), out var statusState))
{
@@ -559,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?
@@ -694,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))
{
@@ -710,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,
@@ -720,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)
{
@@ -742,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)
{
@@ -762,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);
@@ -784,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);
@@ -802,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;
@@ -833,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))
{
@@ -850,9 +773,9 @@ 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(torrentList.MajorVersion))
foreach (var status in GetStatuses())
{
if (!torrentList.StatusState.TryGetValue(status.ToString(), out var statusSet))
{
@@ -879,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))
{
@@ -904,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))
{
@@ -918,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))
{
@@ -946,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))
{
@@ -957,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))
{
@@ -968,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))
{
@@ -979,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)
{
@@ -990,7 +913,7 @@ namespace Lantean.QBTMud.Services
return false;
}
private readonly struct TorrentUpdateResult
internal readonly struct TorrentUpdateResult
{
public TorrentUpdateResult(bool dataChanged, bool filterChanged)
{
@@ -1003,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;
@@ -1320,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);
}
@@ -1445,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;
@@ -1456,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;
@@ -1468,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;
@@ -1566,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,
StartPausedEnabled = changed.StartPausedEnabled,
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.StartPausedEnabled = changed.StartPausedEnabled ?? original.StartPausedEnabled;
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)
@@ -2006,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)
{
@@ -2100,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

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</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>
</Project>

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,4 +1,5 @@
using Lantean.QBitTorrentClient.Models;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
@@ -105,6 +106,8 @@ namespace Lantean.QBitTorrentClient
public async Task SetApplicationPreferences(UpdatePreferences preferences)
{
preferences.Validate();
var json = JsonSerializer.Serialize(preferences, _options);
var content = new FormUrlEncodedBuilder()
@@ -116,6 +119,49 @@ namespace Lantean.QBitTorrentClient
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task<IReadOnlyList<ApplicationCookie>> GetApplicationCookies()
{
var response = await _httpClient.GetAsync("app/cookies");
await ThrowIfNotSuccessfulStatusCode(response);
return await GetJsonList<ApplicationCookie>(response.Content);
}
public async Task SetApplicationCookies(IEnumerable<ApplicationCookie> cookies)
{
var json = JsonSerializer.Serialize(cookies, _options);
var content = new FormUrlEncodedBuilder()
.Add("cookies", json)
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("app/setCookies", content);
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task<string> RotateApiKey()
{
var response = await _httpClient.PostAsync("app/rotateAPIKey", null);
await ThrowIfNotSuccessfulStatusCode(response);
var payload = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(payload))
{
return string.Empty;
}
var json = JsonSerializer.Deserialize<JsonElement>(payload, _options);
if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("apiKey", out var apiKeyElement))
{
return apiKeyElement.GetString() ?? string.Empty;
}
return string.Empty;
}
public async Task<string> GetDefaultSavePath()
{
var response = await _httpClient.GetAsync("app/defaultSavePath");
@@ -145,6 +191,43 @@ namespace Lantean.QBitTorrentClient
#endregion Application
#region Client data
public async Task<IReadOnlyDictionary<string, JsonElement>> LoadClientData(IEnumerable<string>? keys = null)
{
HttpResponseMessage response;
if (keys is null)
{
response = await _httpClient.GetAsync("clientdata/load");
}
else
{
var query = new QueryBuilder()
.Add("keys", JsonSerializer.Serialize(keys, _options));
response = await _httpClient.GetAsync("clientdata/load", query);
}
await ThrowIfNotSuccessfulStatusCode(response);
return await GetJsonDictionary<string, JsonElement>(response.Content);
}
public async Task StoreClientData(IReadOnlyDictionary<string, JsonElement> data)
{
var json = JsonSerializer.Serialize(data, _options);
var content = new FormUrlEncodedBuilder()
.Add("data", json)
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("clientdata/store", content);
await ThrowIfNotSuccessfulStatusCode(response);
}
#endregion Client data
#region Log
public async Task<IReadOnlyList<Log>> GetLog(bool? normal = null, bool? info = null, bool? warning = null, bool? critical = null, int? lastKnownId = null)
@@ -305,7 +388,7 @@ namespace Lantean.QBitTorrentClient
#region Torrent management
public async Task<IReadOnlyList<Torrent>> GetTorrentList(string? filter = null, string? category = null, string? tag = null, string? sort = null, bool? reverse = null, int? limit = null, int? offset = null, bool? isPrivate = null, params string[] hashes)
public async Task<IReadOnlyList<Torrent>> GetTorrentList(string? filter = null, string? category = null, string? tag = null, string? sort = null, bool? reverse = null, int? limit = null, int? offset = null, bool? isPrivate = null, bool? includeFiles = null, params string[] hashes)
{
var query = new QueryBuilder();
if (filter is not null)
@@ -344,6 +427,10 @@ namespace Lantean.QBitTorrentClient
{
query.Add("private", isPrivate.Value ? "true" : "false");
}
if (includeFiles is not null)
{
query.Add("includeFiles", includeFiles.Value ? "true" : "false");
}
var response = await _httpClient.GetAsync("torrents/info", query);
@@ -379,6 +466,43 @@ namespace Lantean.QBitTorrentClient
return await GetJsonList<WebSeed>(response.Content);
}
public async Task AddTorrentWebSeeds(string hash, IEnumerable<string> urls)
{
var content = new FormUrlEncodedBuilder()
.Add("hash", hash)
.Add("urls", string.Join('|', urls))
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/addWebSeeds", content);
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task EditTorrentWebSeed(string hash, string originalUrl, string newUrl)
{
var content = new FormUrlEncodedBuilder()
.Add("hash", hash)
.Add("origUrl", originalUrl)
.Add("newUrl", newUrl)
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/editWebSeed", content);
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task RemoveTorrentWebSeeds(string hash, IEnumerable<string> urls)
{
var content = new FormUrlEncodedBuilder()
.Add("hash", hash)
.Add("urls", string.Join('|', urls))
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/removeWebSeeds", content);
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task<IReadOnlyList<FileData>> GetTorrentContents(string hash, params int[] indexes)
{
var query = new QueryBuilder();
@@ -411,18 +535,6 @@ namespace Lantean.QBitTorrentClient
return await GetJsonList<string>(response.Content);
}
public async Task PauseTorrents(bool? all = null, params string[] hashes)
{
var content = new FormUrlEncodedBuilder()
.AddAllOrPipeSeparated("hashes", all, hashes)
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/pause", content);
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task StopTorrents(bool? all = null, params string[] hashes)
{
var content = new FormUrlEncodedBuilder()
@@ -433,18 +545,6 @@ namespace Lantean.QBitTorrentClient
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task ResumeTorrents(bool? all = null, params string[] hashes)
{
var content = new FormUrlEncodedBuilder()
.AddAllOrPipeSeparated("hashes", all, hashes)
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/resume", content);
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task StartTorrents(bool? all = null, params string[] hashes)
{
var content = new FormUrlEncodedBuilder()
@@ -479,10 +579,11 @@ namespace Lantean.QBitTorrentClient
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task ReannounceTorrents(bool? all = null, params string[] hashes)
public async Task ReannounceTorrents(bool? all = null, IEnumerable<string>? trackers = null, params string[] hashes)
{
var content = new FormUrlEncodedBuilder()
.AddAllOrPipeSeparated("hashes", all, hashes)
.AddIfNotNullOrEmpty("urls", trackers is null ? null : string.Join('|', trackers))
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/reannounce", content);
@@ -490,13 +591,15 @@ namespace Lantean.QBitTorrentClient
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task AddTorrent(AddTorrentParams addTorrentParams)
public async Task<AddTorrentResult> AddTorrent(AddTorrentParams addTorrentParams)
{
var content = new MultipartFormDataContent();
if (addTorrentParams.Urls is not null)
if (addTorrentParams.Urls?.Any() == true)
{
content.AddString("urls", string.Join('\n', addTorrentParams.Urls));
}
if (addTorrentParams.Torrents is not null)
{
foreach (var (name, stream) in addTorrentParams.Torrents)
@@ -504,6 +607,7 @@ namespace Lantean.QBitTorrentClient
content.Add(new StreamContent(stream), "torrents", name);
}
}
if (addTorrentParams.SkipChecking is not null)
{
content.AddString("skip_checking", addTorrentParams.SkipChecking.Value);
@@ -520,12 +624,10 @@ namespace Lantean.QBitTorrentClient
{
content.AddString("addToTopOfQueue", addTorrentParams.AddToTopOfQueue.Value);
}
// v4
if (addTorrentParams.Paused is not null)
if (addTorrentParams.Forced is not null)
{
content.AddString("paused", addTorrentParams.Paused.Value);
content.AddString("forced", addTorrentParams.Forced.Value);
}
// v5
if (addTorrentParams.Stopped is not null)
{
content.AddString("stopped", addTorrentParams.Stopped.Value);
@@ -590,21 +692,61 @@ namespace Lantean.QBitTorrentClient
{
content.AddString("contentLayout", addTorrentParams.ContentLayout.Value);
}
if (addTorrentParams.Cookie is not null)
if (addTorrentParams.Downloader is not null)
{
content.AddString("cookie", addTorrentParams.Cookie);
content.AddString("downloader", addTorrentParams.Downloader);
}
if (addTorrentParams.FilePriorities is not null)
{
var priorities = string.Join(',', addTorrentParams.FilePriorities.Select(priority => ((int)priority).ToString(CultureInfo.InvariantCulture)));
content.AddString("filePriorities", priorities);
}
if (!string.IsNullOrWhiteSpace(addTorrentParams.SslCertificate))
{
content.AddString("ssl_certificate", addTorrentParams.SslCertificate!);
}
if (!string.IsNullOrWhiteSpace(addTorrentParams.SslPrivateKey))
{
content.AddString("ssl_private_key", addTorrentParams.SslPrivateKey!);
}
if (!string.IsNullOrWhiteSpace(addTorrentParams.SslDhParams))
{
content.AddString("ssl_dh_params", addTorrentParams.SslDhParams!);
}
var response = await _httpClient.PostAsync("torrents/add", content);
await ThrowIfNotSuccessfulStatusCode(response);
if (response.StatusCode == HttpStatusCode.Conflict)
{
var conflictMessage = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(conflictMessage))
{
conflictMessage = "All torrents failed to add.";
}
public async Task AddTrackersToTorrent(string hash, IEnumerable<string> urls)
throw new HttpRequestException(conflictMessage, null, response.StatusCode);
}
await ThrowIfNotSuccessfulStatusCode(response);
var payload = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(payload))
{
return new AddTorrentResult(0, 0, 0, Array.Empty<string>());
}
return JsonSerializer.Deserialize<AddTorrentResult>(payload, _options) ?? new AddTorrentResult(0, 0, 0, Array.Empty<string>());
}
public async Task AddTrackersToTorrent(IEnumerable<string> urls, bool? all = null, params string[] hashes)
{
if (all is not true && (hashes is null || hashes.Length == 0))
{
throw new ArgumentException("Specify at least one torrent hash or set all=true.", nameof(hashes));
}
var content = new FormUrlEncodedBuilder()
.Add("hash", hash)
.AddAllOrPipeSeparated("hash", all, hashes ?? Array.Empty<string>())
.Add("urls", string.Join('\n', urls))
.ToFormUrlEncodedContent();
@@ -613,23 +755,42 @@ namespace Lantean.QBitTorrentClient
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task EditTracker(string hash, string originalUrl, string newUrl)
public async Task EditTracker(string hash, string url, string? newUrl = null, int? tier = null)
{
if ((newUrl is null) && (tier is null))
{
throw new ArgumentException("Must specify at least one of newUrl or tier.");
}
var content = new FormUrlEncodedBuilder()
.Add("hash", hash)
.Add("originalUrl", originalUrl)
.Add("newUrl", newUrl)
.ToFormUrlEncodedContent();
.Add("url", url);
var response = await _httpClient.PostAsync("torrents/editTracker", content);
if (!string.IsNullOrEmpty(newUrl))
{
content.Add("newUrl", newUrl!);
}
if (tier is not null)
{
content.Add("tier", tier.Value);
}
var form = content.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/editTracker", form);
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task RemoveTrackers(string hash, IEnumerable<string> urls)
public async Task RemoveTrackers(IEnumerable<string> urls, bool? all = null, params string[] hashes)
{
if (all is not true && (hashes is null || hashes.Length == 0))
{
throw new ArgumentException("Specify at least one torrent hash or set all=true.", nameof(hashes));
}
var content = new FormUrlEncodedBuilder()
.Add("hash", hash)
.AddAllOrPipeSeparated("hash", all, hashes ?? Array.Empty<string>())
.AddPipeSeparated("urls", urls)
.ToFormUrlEncodedContent();
@@ -732,13 +893,14 @@ namespace Lantean.QBitTorrentClient
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task SetTorrentShareLimit(float ratioLimit, float seedingTimeLimit, float inactiveSeedingTimeLimit, bool? all = null, params string[] hashes)
public async Task SetTorrentShareLimit(float ratioLimit, float seedingTimeLimit, float inactiveSeedingTimeLimit, ShareLimitAction? shareLimitAction = null, bool? all = null, params string[] hashes)
{
var content = new FormUrlEncodedBuilder()
.AddAllOrPipeSeparated("hashes", all, hashes)
.Add("ratioLimit", ratioLimit)
.Add("seedingTimeLimit", seedingTimeLimit)
.Add("inactiveSeedingTimeLimit", inactiveSeedingTimeLimit)
.AddIfNotNullOrEmpty("shareLimitAction", shareLimitAction)
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/setShareLimits", content);
@@ -795,6 +957,18 @@ namespace Lantean.QBitTorrentClient
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task SetTorrentComment(IEnumerable<string> hashes, string comment)
{
var content = new FormUrlEncodedBuilder()
.Add("hashes", string.Join('|', hashes))
.Add("comment", comment)
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/setComment", content);
await ThrowIfNotSuccessfulStatusCode(response);
}
public async Task SetTorrentCategory(string category, bool? all = null, params string[] hashes)
{
var content = new FormUrlEncodedBuilder()
@@ -995,8 +1169,180 @@ namespace Lantean.QBitTorrentClient
return Task.FromResult($"{_httpClient.BaseAddress}torrents/export?hash={hash}");
}
public async Task<TorrentMetadata?> FetchMetadata(string source, string? downloader = null)
{
var builder = new FormUrlEncodedBuilder()
.Add("source", source);
if (!string.IsNullOrWhiteSpace(downloader))
{
builder.Add("downloader", downloader!);
}
var response = await _httpClient.PostAsync("torrents/fetchMetadata", builder.ToFormUrlEncodedContent());
await ThrowIfNotSuccessfulStatusCode(response);
var payload = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(payload))
{
return null;
}
return JsonSerializer.Deserialize<TorrentMetadata>(payload, _options);
}
public async Task<IReadOnlyList<TorrentMetadata>> ParseMetadata(IEnumerable<(string FileName, Stream Content)> torrents)
{
var content = new MultipartFormDataContent();
foreach (var (fileName, stream) in torrents)
{
content.Add(new StreamContent(stream), "torrents", fileName);
}
var response = await _httpClient.PostAsync("torrents/parseMetadata", content);
await ThrowIfNotSuccessfulStatusCode(response);
return await GetJsonList<TorrentMetadata>(response.Content);
}
public async Task<byte[]> SaveMetadata(string source)
{
var content = new FormUrlEncodedBuilder()
.Add("source", source)
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrents/saveMetadata", content);
await ThrowIfNotSuccessfulStatusCode(response);
return await response.Content.ReadAsByteArrayAsync();
}
#endregion Torrent management
#region Torrent creator
public async Task<string> AddTorrentCreationTask(TorrentCreationTaskRequest request)
{
if (string.IsNullOrWhiteSpace(request.SourcePath))
{
throw new ArgumentException("SourcePath is required.", nameof(request));
}
var builder = new FormUrlEncodedBuilder()
.Add("sourcePath", request.SourcePath);
if (!string.IsNullOrWhiteSpace(request.TorrentFilePath))
{
builder.Add("torrentFilePath", request.TorrentFilePath!);
}
if (request.PieceSize.HasValue)
{
builder.Add("pieceSize", request.PieceSize.Value);
}
if (request.Private.HasValue)
{
builder.Add("private", request.Private.Value);
}
if (request.StartSeeding.HasValue)
{
builder.Add("startSeeding", request.StartSeeding.Value);
}
if (!string.IsNullOrWhiteSpace(request.Comment))
{
builder.Add("comment", request.Comment!);
}
if (!string.IsNullOrWhiteSpace(request.Source))
{
builder.Add("source", request.Source!);
}
if (request.Trackers is not null)
{
builder.Add("trackers", string.Join('|', request.Trackers));
}
if (request.UrlSeeds is not null)
{
builder.Add("urlSeeds", string.Join('|', request.UrlSeeds));
}
if (!string.IsNullOrWhiteSpace(request.Format))
{
builder.Add("format", request.Format!);
}
if (request.OptimizeAlignment.HasValue)
{
builder.Add("optimizeAlignment", request.OptimizeAlignment.Value);
}
if (request.PaddedFileSizeLimit.HasValue)
{
builder.Add("paddedFileSizeLimit", request.PaddedFileSizeLimit.Value);
}
var response = await _httpClient.PostAsync("torrentcreator/addTask", builder.ToFormUrlEncodedContent());
await ThrowIfNotSuccessfulStatusCode(response);
var payload = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(payload))
{
return string.Empty;
}
var json = JsonSerializer.Deserialize<JsonElement>(payload, _options);
if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("taskID", out var idElement))
{
return idElement.GetString() ?? string.Empty;
}
return string.Empty;
}
public async Task<IReadOnlyList<TorrentCreationTaskStatus>> GetTorrentCreationTasks(string? taskId = null)
{
HttpResponseMessage response;
if (string.IsNullOrWhiteSpace(taskId))
{
response = await _httpClient.GetAsync("torrentcreator/status");
}
else
{
var query = new QueryBuilder()
.Add("taskID", taskId);
response = await _httpClient.GetAsync("torrentcreator/status", query);
}
await ThrowIfNotSuccessfulStatusCode(response);
return await GetJsonList<TorrentCreationTaskStatus>(response.Content);
}
public async Task<byte[]> GetTorrentCreationTaskFile(string taskId)
{
var query = new QueryBuilder()
.Add("taskID", taskId);
var response = await _httpClient.GetAsync("torrentcreator/torrentFile", query);
await ThrowIfNotSuccessfulStatusCode(response);
return await response.Content.ReadAsByteArrayAsync();
}
public async Task DeleteTorrentCreationTask(string taskId)
{
var content = new FormUrlEncodedBuilder()
.Add("taskID", taskId)
.ToFormUrlEncodedContent();
var response = await _httpClient.PostAsync("torrentcreator/deleteTask", content);
await ThrowIfNotSuccessfulStatusCode(response);
}
#endregion Torrent creator
#region RSS
public async Task AddRssFolder(string path)

View File

@@ -1,24 +1,10 @@
using Lantean.QBitTorrentClient.Models;
using System.Linq;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBitTorrentClient
{
public static class ApiClientExtensions
{
public static Task PauseTorrent(this IApiClient apiClient, string hash)
{
return apiClient.PauseTorrents(null, hash);
}
public static Task PauseTorrents(this IApiClient apiClient, IEnumerable<string> hashes)
{
return apiClient.PauseTorrents(null, hashes.ToArray());
}
public static Task PauseAllTorrents(this IApiClient apiClient)
{
return apiClient.PauseTorrents(true);
}
public static Task StopTorrent(this IApiClient apiClient, string hash)
{
return apiClient.StopTorrents(null, hash);
@@ -34,21 +20,6 @@ namespace Lantean.QBitTorrentClient
return apiClient.StopTorrents(true);
}
public static Task ResumeTorrent(this IApiClient apiClient, string hash)
{
return apiClient.ResumeTorrents(null, hash);
}
public static Task ResumeTorrents(this IApiClient apiClient, IEnumerable<string> hashes)
{
return apiClient.ResumeTorrents(null, hashes.ToArray());
}
public static Task ResumeAllTorrents(this IApiClient apiClient)
{
return apiClient.ResumeTorrents(true);
}
public static Task StartTorrent(this IApiClient apiClient, string hash)
{
return apiClient.StartTorrents(null, hash);
@@ -158,7 +129,7 @@ namespace Lantean.QBitTorrentClient
public static Task ReannounceTorrent(this IApiClient apiClient, string hash)
{
return apiClient.ReannounceTorrents(null, hash);
return apiClient.ReannounceTorrents(null, null, hash);
}
public static async Task<IEnumerable<string>> RemoveUnusedCategories(this IApiClient apiClient)

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

@@ -1,4 +1,8 @@
using Lantean.QBitTorrentClient.Models;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Lantean.QBitTorrentClient.Models;
namespace Lantean.QBitTorrentClient
{
@@ -28,6 +32,12 @@ namespace Lantean.QBitTorrentClient
Task SetApplicationPreferences(UpdatePreferences preferences);
Task<IReadOnlyList<ApplicationCookie>> GetApplicationCookies();
Task SetApplicationCookies(IEnumerable<ApplicationCookie> cookies);
Task<string> RotateApiKey();
Task<string> GetDefaultSavePath();
Task<IReadOnlyList<NetworkInterface>> GetNetworkInterfaces();
@@ -36,6 +46,14 @@ namespace Lantean.QBitTorrentClient
#endregion Application
#region Client data
Task<IReadOnlyDictionary<string, JsonElement>> LoadClientData(IEnumerable<string>? keys = null);
Task StoreClientData(IReadOnlyDictionary<string, JsonElement> data);
#endregion Client data
#region Log
Task<IReadOnlyList<Log>> GetLog(bool? normal = null, bool? info = null, bool? warning = null, bool? critical = null, int? lastKnownId = null);
@@ -74,7 +92,7 @@ namespace Lantean.QBitTorrentClient
#region Torrent management
Task<IReadOnlyList<Torrent>> GetTorrentList(string? filter = null, string? category = null, string? tag = null, string? sort = null, bool? reverse = null, int? limit = null, int? offset = null, bool? isPrivate = null, params string[] hashes);
Task<IReadOnlyList<Torrent>> GetTorrentList(string? filter = null, string? category = null, string? tag = null, string? sort = null, bool? reverse = null, int? limit = null, int? offset = null, bool? isPrivate = null, bool? includeFiles = null, params string[] hashes);
Task<TorrentProperties> GetTorrentProperties(string hash);
@@ -82,16 +100,18 @@ namespace Lantean.QBitTorrentClient
Task<IReadOnlyList<WebSeed>> GetTorrentWebSeeds(string hash);
Task AddTorrentWebSeeds(string hash, IEnumerable<string> urls);
Task EditTorrentWebSeed(string hash, string originalUrl, string newUrl);
Task RemoveTorrentWebSeeds(string hash, IEnumerable<string> urls);
Task<IReadOnlyList<FileData>> GetTorrentContents(string hash, params int[] indexes);
Task<IReadOnlyList<PieceState>> GetTorrentPieceStates(string hash);
Task<IReadOnlyList<string>> GetTorrentPieceHashes(string hash);
Task PauseTorrents(bool? all = null, params string[] hashes);
Task ResumeTorrents(bool? all = null, params string[] hashes);
Task StartTorrents(bool? all = null, params string[] hashes);
Task StopTorrents(bool? all = null, params string[] hashes);
@@ -100,15 +120,15 @@ namespace Lantean.QBitTorrentClient
Task RecheckTorrents(bool? all = null, params string[] hashes);
Task ReannounceTorrents(bool? all = null, params string[] hashes);
Task ReannounceTorrents(bool? all = null, IEnumerable<string>? trackers = null, params string[] hashes);
Task AddTorrent(AddTorrentParams addTorrentParams);
Task<AddTorrentResult> AddTorrent(AddTorrentParams addTorrentParams);
Task AddTrackersToTorrent(string hash, IEnumerable<string> urls);
Task AddTrackersToTorrent(IEnumerable<string> urls, bool? all = null, params string[] hashes);
Task EditTracker(string hash, string originalUrl, string newUrl);
Task EditTracker(string hash, string url, string? newUrl = null, int? tier = null);
Task RemoveTrackers(string hash, IEnumerable<string> urls);
Task RemoveTrackers(IEnumerable<string> urls, bool? all = null, params string[] hashes);
Task AddPeers(IEnumerable<string> hashes, IEnumerable<PeerId> peers);
@@ -126,7 +146,7 @@ namespace Lantean.QBitTorrentClient
Task SetTorrentDownloadLimit(long limit, bool? all = null, params string[] hashes);
Task SetTorrentShareLimit(float ratioLimit, float seedingTimeLimit, float inactiveSeedingTimeLimit, bool? all = null, params string[] hashes);
Task SetTorrentShareLimit(float ratioLimit, float seedingTimeLimit, float inactiveSeedingTimeLimit, ShareLimitAction? shareLimitAction = null, bool? all = null, params string[] hashes);
Task<IReadOnlyDictionary<string, long>> GetTorrentUploadLimit(bool? all = null, params string[] hashes);
@@ -136,6 +156,8 @@ namespace Lantean.QBitTorrentClient
Task SetTorrentName(string name, string hash);
Task SetTorrentComment(IEnumerable<string> hashes, string comment);
Task SetTorrentCategory(string category, bool? all = null, params string[] hashes);
Task<IReadOnlyDictionary<string, Category>> GetAllCategories();
@@ -172,8 +194,26 @@ namespace Lantean.QBitTorrentClient
Task<string> GetExportUrl(string hash);
Task<TorrentMetadata?> FetchMetadata(string source, string? downloader = null);
Task<IReadOnlyList<TorrentMetadata>> ParseMetadata(IEnumerable<(string FileName, Stream Content)> torrents);
Task<byte[]> SaveMetadata(string source);
#endregion Torrent management
#region Torrent creator
Task<string> AddTorrentCreationTask(TorrentCreationTaskRequest request);
Task<IReadOnlyList<TorrentCreationTaskStatus>> GetTorrentCreationTasks(string? taskId = null);
Task<byte[]> GetTorrentCreationTaskFile(string taskId);
Task DeleteTorrentCreationTask(string taskId);
#endregion Torrent creator
#region RSS
Task AddRssFolder(string path);

View File

@@ -7,4 +7,8 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Lantean.QBitTorrentClient.Test" />
</ItemGroup>
</Project>

View File

@@ -12,9 +12,8 @@
public bool? AddToTopOfQueue { get; set; }
// v4
public bool? Paused { get; set; }
// v5
public bool? Forced { get; set; }
public bool? Stopped { get; set; }
public string? SavePath { get; set; }
@@ -47,7 +46,15 @@
public TorrentContentLayout? ContentLayout { get; set; }
public string? Cookie { get; set; }
public IEnumerable<Priority>? FilePriorities { get; set; }
public string? Downloader { get; set; }
public string? SslCertificate { get; set; }
public string? SslPrivateKey { get; set; }
public string? SslDhParams { get; set; }
public Dictionary<string, Stream>? Torrents { get; set; }
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Models
{
public record AddTorrentResult
{
[JsonConstructor]
public AddTorrentResult(int successCount, int failureCount, int pendingCount, IReadOnlyList<string>? addedTorrentIds)
{
SuccessCount = successCount;
FailureCount = failureCount;
PendingCount = pendingCount;
AddedTorrentIds = addedTorrentIds ?? Array.Empty<string>();
}
[JsonPropertyName("success_count")]
public int SuccessCount { get; }
[JsonPropertyName("failure_count")]
public int FailureCount { get; }
[JsonPropertyName("pending_count")]
public int PendingCount { get; }
[JsonPropertyName("added_torrent_ids")]
public IReadOnlyList<string> AddedTorrentIds { get; }
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Models
{
public record ApplicationCookie
{
[JsonConstructor]
public ApplicationCookie(string name, string? domain, string? path, string? value, long? expirationDate)
{
Name = name;
Domain = domain;
Path = path;
Value = value;
ExpirationDate = expirationDate;
}
[JsonPropertyName("name")]
public string Name { get; }
[JsonPropertyName("domain")]
public string? Domain { get; }
[JsonPropertyName("path")]
public string? Path { get; }
[JsonPropertyName("value")]
public string? Value { get; }
[JsonPropertyName("expirationDate")]
public long? ExpirationDate { get; }
}
}

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,8 @@ 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)
{
ResponseId = responseId;
@@ -26,6 +27,7 @@ namespace Lantean.QBitTorrentClient.Models
Tags = tags;
TagsRemoved = tagsRemoved;
Trackers = trackers;
TrackersRemoved = trackersRemoved;
ServerState = serverState;
}

View File

@@ -16,6 +16,7 @@ namespace Lantean.QBitTorrentClient.Models
string? flags,
string? flagsDescription,
string? iPAddress,
string? i2pDestination,
string? clientId,
int? port,
float? progress,
@@ -33,6 +34,7 @@ namespace Lantean.QBitTorrentClient.Models
Flags = flags;
FlagsDescription = flagsDescription;
IPAddress = iPAddress;
I2pDestination = i2pDestination;
ClientId = clientId;
Port = port;
Progress = progress;
@@ -71,6 +73,9 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("ip")]
public string? IPAddress { get; }
[JsonPropertyName("i2p_dest")]
public string? I2pDestination { get; }
[JsonPropertyName("peer_id_client")]
public string? ClientId { get; }

View File

@@ -7,6 +7,7 @@ namespace Lantean.QBitTorrentClient.Models
[JsonConstructor]
public Preferences(
bool addToTopOfQueue,
bool addStoppedEnabled,
string addTrackers,
bool addTrackersEnabled,
int altDlLimit,
@@ -14,6 +15,7 @@ namespace Lantean.QBitTorrentClient.Models
bool alternativeWebuiEnabled,
string alternativeWebuiPath,
string announceIp,
int announcePort,
bool announceToAllTiers,
bool announceToAllTrackers,
bool anonymousMode,
@@ -85,6 +87,7 @@ namespace Lantean.QBitTorrentClient.Models
int i2pPort,
bool idnSupportEnabled,
bool incompleteFilesExt,
bool useUnwantedFolder,
bool ipFilterEnabled,
string ipFilterPath,
bool ipFilterTrackers,
@@ -92,6 +95,8 @@ namespace Lantean.QBitTorrentClient.Models
bool limitTcpOverhead,
bool limitUtpRate,
int listenPort,
bool sslEnabled,
int sslListenPort,
string locale,
bool lsd,
bool mailNotificationAuthEnabled,
@@ -160,6 +165,7 @@ namespace Lantean.QBitTorrentClient.Models
string savePath,
bool savePathChangedTmmEnabled,
int saveResumeDataInterval,
int saveStatisticsInterval,
Dictionary<string, SaveLocation> scanDirs,
int scheduleFromHour,
int scheduleFromMin,
@@ -177,12 +183,12 @@ namespace Lantean.QBitTorrentClient.Models
int socketReceiveBufferSize,
int socketSendBufferSize,
bool ssrfMitigation,
bool startPausedEnabled,
int stopTrackerTimeout,
string tempPath,
bool tempPathEnabled,
bool torrentChangedTmmEnabled,
string torrentContentLayout,
string torrentContentRemoveOption,
int torrentFileSizeLimit,
string torrentStopCondition,
int upLimit,
@@ -192,10 +198,12 @@ namespace Lantean.QBitTorrentClient.Models
int upnpLeaseDuration,
bool useCategoryPathsInManualMode,
bool useHttps,
bool ignoreSslErrors,
bool useSubcategories,
int utpTcpMixedMode,
bool validateHttpsTrackerCertificate,
string webUiAddress,
string webUiApiKey,
int webUiBanDuration,
bool webUiClickjackingProtectionEnabled,
bool webUiCsrfProtectionEnabled,
@@ -213,10 +221,14 @@ namespace Lantean.QBitTorrentClient.Models
bool webUiUpnp,
bool webUiUseCustomHttpHeadersEnabled,
string webUiUsername,
string webUiPassword
string webUiPassword,
bool confirmTorrentDeletion,
bool confirmTorrentRecheck,
bool statusBarExternalIp
)
{
AddToTopOfQueue = addToTopOfQueue;
AddStoppedEnabled = addStoppedEnabled;
AddTrackers = addTrackers;
AddTrackersEnabled = addTrackersEnabled;
AltDlLimit = altDlLimit;
@@ -224,6 +236,7 @@ namespace Lantean.QBitTorrentClient.Models
AlternativeWebuiEnabled = alternativeWebuiEnabled;
AlternativeWebuiPath = alternativeWebuiPath;
AnnounceIp = announceIp;
AnnouncePort = announcePort;
AnnounceToAllTiers = announceToAllTiers;
AnnounceToAllTrackers = announceToAllTrackers;
AnonymousMode = anonymousMode;
@@ -295,6 +308,7 @@ namespace Lantean.QBitTorrentClient.Models
I2pPort = i2pPort;
IdnSupportEnabled = idnSupportEnabled;
IncompleteFilesExt = incompleteFilesExt;
UseUnwantedFolder = useUnwantedFolder;
IpFilterEnabled = ipFilterEnabled;
IpFilterPath = ipFilterPath;
IpFilterTrackers = ipFilterTrackers;
@@ -302,6 +316,8 @@ namespace Lantean.QBitTorrentClient.Models
LimitTcpOverhead = limitTcpOverhead;
LimitUtpRate = limitUtpRate;
ListenPort = listenPort;
SslEnabled = sslEnabled;
SslListenPort = sslListenPort;
Locale = locale;
Lsd = lsd;
MailNotificationAuthEnabled = mailNotificationAuthEnabled;
@@ -370,6 +386,7 @@ namespace Lantean.QBitTorrentClient.Models
SavePath = savePath;
SavePathChangedTmmEnabled = savePathChangedTmmEnabled;
SaveResumeDataInterval = saveResumeDataInterval;
SaveStatisticsInterval = saveStatisticsInterval;
ScanDirs = scanDirs;
ScheduleFromHour = scheduleFromHour;
ScheduleFromMin = scheduleFromMin;
@@ -387,12 +404,12 @@ namespace Lantean.QBitTorrentClient.Models
SocketReceiveBufferSize = socketReceiveBufferSize;
SocketSendBufferSize = socketSendBufferSize;
SsrfMitigation = ssrfMitigation;
StartPausedEnabled = startPausedEnabled;
StopTrackerTimeout = stopTrackerTimeout;
TempPath = tempPath;
TempPathEnabled = tempPathEnabled;
TorrentChangedTmmEnabled = torrentChangedTmmEnabled;
TorrentContentLayout = torrentContentLayout;
TorrentContentRemoveOption = torrentContentRemoveOption;
TorrentFileSizeLimit = torrentFileSizeLimit;
TorrentStopCondition = torrentStopCondition;
UpLimit = upLimit;
@@ -402,10 +419,12 @@ namespace Lantean.QBitTorrentClient.Models
UpnpLeaseDuration = upnpLeaseDuration;
UseCategoryPathsInManualMode = useCategoryPathsInManualMode;
UseHttps = useHttps;
IgnoreSslErrors = ignoreSslErrors;
UseSubcategories = useSubcategories;
UtpTcpMixedMode = utpTcpMixedMode;
ValidateHttpsTrackerCertificate = validateHttpsTrackerCertificate;
WebUiAddress = webUiAddress;
WebUiApiKey = webUiApiKey;
WebUiBanDuration = webUiBanDuration;
WebUiClickjackingProtectionEnabled = webUiClickjackingProtectionEnabled;
WebUiCsrfProtectionEnabled = webUiCsrfProtectionEnabled;
@@ -424,11 +443,17 @@ namespace Lantean.QBitTorrentClient.Models
WebUiUseCustomHttpHeadersEnabled = webUiUseCustomHttpHeadersEnabled;
WebUiUsername = webUiUsername;
WebUiPassword = webUiPassword;
ConfirmTorrentDeletion = confirmTorrentDeletion;
ConfirmTorrentRecheck = confirmTorrentRecheck;
StatusBarExternalIp = statusBarExternalIp;
}
[JsonPropertyName("add_to_top_of_queue")]
public bool AddToTopOfQueue { get; }
[JsonPropertyName("add_stopped_enabled")]
public bool AddStoppedEnabled { get; }
[JsonPropertyName("add_trackers")]
public string AddTrackers { get; }
@@ -450,6 +475,9 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("announce_ip")]
public string AnnounceIp { get; }
[JsonPropertyName("announce_port")]
public int AnnouncePort { get; }
[JsonPropertyName("announce_to_all_tiers")]
public bool AnnounceToAllTiers { get; }
@@ -663,6 +691,9 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("incomplete_files_ext")]
public bool IncompleteFilesExt { get; }
[JsonPropertyName("use_unwanted_folder")]
public bool UseUnwantedFolder { get; }
[JsonPropertyName("ip_filter_enabled")]
public bool IpFilterEnabled { get; }
@@ -684,6 +715,12 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("listen_port")]
public int ListenPort { get; }
[JsonPropertyName("ssl_enabled")]
public bool SslEnabled { get; }
[JsonPropertyName("ssl_listen_port")]
public int SslListenPort { get; }
[JsonPropertyName("locale")]
public string Locale { get; }
@@ -888,6 +925,9 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("save_resume_data_interval")]
public int SaveResumeDataInterval { get; }
[JsonPropertyName("save_statistics_interval")]
public int SaveStatisticsInterval { get; }
[JsonPropertyName("scan_dirs")]
public Dictionary<string, SaveLocation> ScanDirs { get; }
@@ -939,9 +979,6 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("ssrf_mitigation")]
public bool SsrfMitigation { get; }
[JsonPropertyName("start_paused_enabled")]
public bool StartPausedEnabled { get; }
[JsonPropertyName("stop_tracker_timeout")]
public int StopTrackerTimeout { get; }
@@ -957,6 +994,9 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("torrent_content_layout")]
public string TorrentContentLayout { get; }
[JsonPropertyName("torrent_content_remove_option")]
public string TorrentContentRemoveOption { get; }
[JsonPropertyName("torrent_file_size_limit")]
public int TorrentFileSizeLimit { get; }
@@ -984,6 +1024,9 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("use_https")]
public bool UseHttps { get; }
[JsonPropertyName("ignore_ssl_errors")]
public bool IgnoreSslErrors { get; }
[JsonPropertyName("use_subcategories")]
public bool UseSubcategories { get; }
@@ -996,6 +1039,9 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("web_ui_address")]
public string WebUiAddress { get; }
[JsonPropertyName("web_ui_api_key")]
public string WebUiApiKey { get; }
[JsonPropertyName("web_ui_ban_duration")]
public int WebUiBanDuration { get; }
@@ -1049,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,31 +12,51 @@
{
if (value is int intValue)
{
if (intValue == 0)
return Create(intValue);
}
else if (value is string 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 (intValue == 1)
else if (value == 1)
{
return new SaveLocation
{
IsDefaultFolder = true
};
}
throw new ArgumentOutOfRangeException(nameof(value));
}
else if (value is string stringValue)
public static SaveLocation Create(string? value)
{
if (stringValue == "0")
if (value is null)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
if (value == "0")
{
return new SaveLocation
{
IsWatchedFolder = true
};
}
else if (stringValue == "1")
else if (value == "1")
{
return new SaveLocation
{
@@ -47,14 +67,11 @@
{
return new SaveLocation
{
SavePath = stringValue
SavePath = value
};
}
}
throw new ArgumentOutOfRangeException(nameof(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

@@ -1,264 +1,219 @@
using Lantean.QBitTorrentClient.Converters;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Models
{
public record Torrent
{
[JsonConstructor]
public Torrent(
long? addedOn,
long? amountLeft,
bool? automaticTorrentManagement,
float? availability,
string? category,
long? completed,
long? completionOn,
string? contentPath,
long? downloadLimit,
long? downloadSpeed,
long? downloaded,
long? downloadedSession,
long? estimatedTimeOfArrival,
bool? firstLastPiecePriority,
bool? forceStart,
string hash,
string? infoHashV1,
string? infoHashV2,
long? lastActivity,
string? magnetUri,
float? maxRatio,
int? maxSeedingTime,
string? name,
int? numberComplete,
int? numberIncomplete,
int? numberLeeches,
int? numberSeeds,
int? priority,
float? progress,
float? ratio,
float? ratioLimit,
string? savePath,
long? seedingTime,
int? seedingTimeLimit,
long? seenComplete,
bool? sequentialDownload,
long? size,
string? state,
bool? superSeeding,
IReadOnlyList<string>? tags,
int? timeActive,
long? totalSize,
string? tracker,
long? uploadLimit,
long? uploaded,
long? uploadedSession,
long? uploadSpeed,
long? reannounce,
float? inactiveSeedingTimeLimit,
float? maxInactiveSeedingTime)
{
AddedOn = addedOn;
AmountLeft = amountLeft;
AutomaticTorrentManagement = automaticTorrentManagement;
Availability = availability;
Category = category;
Completed = completed;
CompletionOn = completionOn;
ContentPath = contentPath;
DownloadLimit = downloadLimit;
DownloadSpeed = downloadSpeed;
Downloaded = downloaded;
DownloadedSession = downloadedSession;
EstimatedTimeOfArrival = estimatedTimeOfArrival;
FirstLastPiecePriority = firstLastPiecePriority;
ForceStart = forceStart;
Hash = hash;
InfoHashV1 = infoHashV1;
InfoHashV2 = infoHashV2;
LastActivity = lastActivity;
MagnetUri = magnetUri;
MaxRatio = maxRatio;
MaxSeedingTime = maxSeedingTime;
Name = name;
NumberComplete = numberComplete;
NumberIncomplete = numberIncomplete;
NumberLeeches = numberLeeches;
NumberSeeds = numberSeeds;
Priority = priority;
Progress = progress;
Ratio = ratio;
RatioLimit = ratioLimit;
SavePath = savePath;
SeedingTime = seedingTime;
SeedingTimeLimit = seedingTimeLimit;
SeenComplete = seenComplete;
SequentialDownload = sequentialDownload;
Size = size;
State = state;
SuperSeeding = superSeeding;
Tags = tags ?? [];
TimeActive = timeActive;
TotalSize = totalSize;
Tracker = tracker;
UploadLimit = uploadLimit;
Uploaded = uploaded;
UploadedSession = uploadedSession;
UploadSpeed = uploadSpeed;
Reannounce = reannounce;
InactiveSeedingTimeLimit = inactiveSeedingTimeLimit;
MaxInactiveSeedingTime = maxInactiveSeedingTime;
}
[JsonPropertyName("added_on")]
public long? AddedOn { get; }
[JsonPropertyName("amount_left")]
public long? AmountLeft { get; }
[JsonPropertyName("auto_tmm")]
public bool? AutomaticTorrentManagement { get; }
[JsonPropertyName("availability")]
public float? Availability { get; }
[JsonPropertyName("category")]
public string? Category { get; }
[JsonPropertyName("completed")]
public long? Completed { get; }
[JsonPropertyName("completion_on")]
public long? CompletionOn { get; }
[JsonPropertyName("content_path")]
public string? ContentPath { get; }
[JsonPropertyName("dl_limit")]
public long? DownloadLimit { get; }
[JsonPropertyName("dlspeed")]
public long? DownloadSpeed { get; }
[JsonPropertyName("downloaded")]
public long? Downloaded { get; }
[JsonPropertyName("downloaded_session")]
public long? DownloadedSession { get; }
[JsonPropertyName("eta")]
public long? EstimatedTimeOfArrival { get; }
[JsonPropertyName("f_l_piece_prio")]
public bool? FirstLastPiecePriority { get; }
[JsonPropertyName("force_start")]
public bool? ForceStart { get; }
[JsonPropertyName("hash")]
public string Hash { get; }
public string Hash { get; init; } = string.Empty;
[JsonPropertyName("infohash_v1")]
public string? InfoHashV1 { get; }
public string? InfoHashV1 { get; init; }
[JsonPropertyName("infohash_v2")]
public string? InfoHashV2 { get; }
[JsonPropertyName("last_activity")]
public long? LastActivity { get; }
[JsonPropertyName("magnet_uri")]
public string? MagnetUri { get; }
[JsonPropertyName("max_ratio")]
public float? MaxRatio { get; }
[JsonPropertyName("max_seeding_time")]
public int? MaxSeedingTime { get; }
public string? InfoHashV2 { get; init; }
[JsonPropertyName("name")]
public string? Name { get; }
public string? Name { get; init; }
[JsonPropertyName("num_complete")]
public int? NumberComplete { get; }
[JsonPropertyName("num_incomplete")]
public int? NumberIncomplete { get; }
[JsonPropertyName("num_leechs")]
public int? NumberLeeches { get; }
[JsonPropertyName("num_seeds")]
public int? NumberSeeds { get; }
[JsonPropertyName("priority")]
public int? Priority { get; }
[JsonPropertyName("progress")]
public float? Progress { get; }
[JsonPropertyName("ratio")]
public float? Ratio { get; }
[JsonPropertyName("ratio_limit")]
public float? RatioLimit { get; }
[JsonPropertyName("save_path")]
public string? SavePath { get; }
[JsonPropertyName("seeding_time")]
public long? SeedingTime { get; }
[JsonPropertyName("seeding_time_limit")]
public int? SeedingTimeLimit { get; }
[JsonPropertyName("seen_complete")]
public long? SeenComplete { get; }
[JsonPropertyName("seq_dl")]
public bool? SequentialDownload { get; }
[JsonPropertyName("magnet_uri")]
public string? MagnetUri { get; init; }
[JsonPropertyName("size")]
public long? Size { get; }
public long? Size { get; init; }
[JsonPropertyName("progress")]
public float? Progress { get; init; }
[JsonPropertyName("dlspeed")]
public long? DownloadSpeed { get; init; }
[JsonPropertyName("upspeed")]
public long? UploadSpeed { get; init; }
[JsonPropertyName("priority")]
public int? Priority { get; init; }
[JsonPropertyName("num_seeds")]
public int? NumberSeeds { get; init; }
[JsonPropertyName("num_complete")]
public int? NumberComplete { get; init; }
[JsonPropertyName("num_leechs")]
public int? NumberLeeches { get; init; }
[JsonPropertyName("num_incomplete")]
public int? NumberIncomplete { get; init; }
[JsonPropertyName("ratio")]
public float? Ratio { get; init; }
[JsonPropertyName("popularity")]
public float? Popularity { get; init; }
[JsonPropertyName("eta")]
public long? EstimatedTimeOfArrival { get; init; }
[JsonPropertyName("state")]
public string? State { get; }
public string? State { get; init; }
[JsonPropertyName("super_seeding")]
public bool? SuperSeeding { get; }
[JsonPropertyName("seq_dl")]
public bool? SequentialDownload { get; init; }
[JsonPropertyName("f_l_piece_prio")]
public bool? FirstLastPiecePriority { get; init; }
[JsonPropertyName("category")]
public string? Category { get; init; }
[JsonPropertyName("tags")]
[JsonConverter(typeof(CommaSeparatedJsonConverter))]
public IReadOnlyList<string>? Tags { get; }
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
[JsonPropertyName("time_active")]
public int? TimeActive { get; }
[JsonPropertyName("super_seeding")]
public bool? SuperSeeding { get; init; }
[JsonPropertyName("total_size")]
public long? TotalSize { get; }
[JsonPropertyName("force_start")]
public bool? ForceStart { get; init; }
[JsonPropertyName("save_path")]
public string? SavePath { get; init; }
[JsonPropertyName("download_path")]
public string? DownloadPath { get; init; }
[JsonPropertyName("content_path")]
public string? ContentPath { get; init; }
[JsonPropertyName("root_path")]
public string? RootPath { get; init; }
[JsonPropertyName("added_on")]
public long? AddedOn { get; init; }
[JsonPropertyName("completion_on")]
public long? CompletionOn { get; init; }
[JsonPropertyName("tracker")]
public string? Tracker { get; }
public string? Tracker { get; init; }
[JsonPropertyName("trackers_count")]
public int? TrackersCount { get; init; }
[JsonPropertyName("dl_limit")]
public long? DownloadLimit { get; init; }
[JsonPropertyName("up_limit")]
public long? UploadLimit { get; }
public long? UploadLimit { get; init; }
[JsonPropertyName("downloaded")]
public long? Downloaded { get; init; }
[JsonPropertyName("uploaded")]
public long? Uploaded { get; }
public long? Uploaded { get; init; }
[JsonPropertyName("downloaded_session")]
public long? DownloadedSession { get; init; }
[JsonPropertyName("uploaded_session")]
public long? UploadedSession { get; }
public long? UploadedSession { get; init; }
[JsonPropertyName("upspeed")]
public long? UploadSpeed { get; }
[JsonPropertyName("amount_left")]
public long? AmountLeft { get; init; }
[JsonPropertyName("reannounce")]
public long? Reannounce { get; }
[JsonPropertyName("completed")]
public long? Completed { get; init; }
[JsonPropertyName("inactive_seeding_time_limit")]
public float? InactiveSeedingTimeLimit { get; }
[JsonPropertyName("connections_count")]
public int? ConnectionsCount { get; init; }
[JsonPropertyName("connections_limit")]
public int? ConnectionsLimit { get; init; }
[JsonPropertyName("max_ratio")]
public float? MaxRatio { get; init; }
[JsonPropertyName("max_seeding_time")]
public int? MaxSeedingTime { get; init; }
[JsonPropertyName("max_inactive_seeding_time")]
public float? MaxInactiveSeedingTime { get; }
public float? MaxInactiveSeedingTime { get; init; }
[JsonPropertyName("ratio_limit")]
public float? RatioLimit { get; init; }
[JsonPropertyName("seeding_time_limit")]
public int? SeedingTimeLimit { get; init; }
[JsonPropertyName("inactive_seeding_time_limit")]
public float? InactiveSeedingTimeLimit { get; init; }
[JsonPropertyName("share_limit_action")]
[JsonConverter(typeof(JsonStringEnumConverter))]
public ShareLimitAction? ShareLimitAction { get; init; }
[JsonPropertyName("seen_complete")]
public long? SeenComplete { get; init; }
[JsonPropertyName("last_activity")]
public long? LastActivity { get; init; }
[JsonPropertyName("total_size")]
public long? TotalSize { get; init; }
[JsonPropertyName("auto_tmm")]
public bool? AutomaticTorrentManagement { get; init; }
[JsonPropertyName("time_active")]
public int? TimeActive { get; init; }
[JsonPropertyName("seeding_time")]
public long? SeedingTime { get; init; }
[JsonPropertyName("availability")]
public float? Availability { get; init; }
[JsonPropertyName("reannounce")]
public long? Reannounce { get; init; }
[JsonPropertyName("comment")]
public string? Comment { get; init; }
[JsonPropertyName("has_metadata")]
public bool? HasMetadata { get; init; }
[JsonPropertyName("created_by")]
public string? CreatedBy { get; init; }
[JsonPropertyName("creation_date")]
public long? CreationDate { get; init; }
[JsonPropertyName("private")]
public bool? IsPrivate { get; init; }
[JsonPropertyName("total_wasted")]
public long? TotalWasted { get; init; }
[JsonPropertyName("pieces_num")]
public int? PiecesCount { get; init; }
[JsonPropertyName("piece_size")]
public long? PieceSize { get; init; }
[JsonPropertyName("pieces_have")]
public int? PiecesHave { get; init; }
[JsonPropertyName("has_tracker_warning")]
public bool? HasTrackerWarning { get; init; }
[JsonPropertyName("has_tracker_error")]
public bool? HasTrackerError { get; init; }
[JsonPropertyName("has_other_announce_error")]
public bool? HasOtherAnnounceError { get; init; }
}
}

View File

@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Models
{
public class TorrentCreationTaskRequest
{
public string SourcePath { get; set; } = string.Empty;
public string? TorrentFilePath { get; set; }
public int? PieceSize { get; set; }
public bool? Private { get; set; }
public bool? StartSeeding { get; set; }
public string? Comment { get; set; }
public string? Source { get; set; }
public IEnumerable<string>? Trackers { get; set; }
public IEnumerable<string>? UrlSeeds { get; set; }
public string? Format { get; set; }
public bool? OptimizeAlignment { get; set; }
public int? PaddedFileSizeLimit { get; set; }
}
public record TorrentCreationTaskStatus
{
[JsonConstructor]
public TorrentCreationTaskStatus(
string taskID,
string? sourcePath,
int? pieceSize,
bool? @private,
string? timeAdded,
string? format,
bool? optimizeAlignment,
int? paddedFileSizeLimit,
string? status,
string? comment,
string? torrentFilePath,
string? source,
IReadOnlyList<string>? trackers,
IReadOnlyList<string>? urlSeeds,
string? timeStarted,
string? timeFinished,
string? errorMessage,
double? progress)
{
TaskId = taskID;
SourcePath = sourcePath;
PieceSize = pieceSize;
Private = @private;
TimeAdded = timeAdded;
Format = format;
OptimizeAlignment = optimizeAlignment;
PaddedFileSizeLimit = paddedFileSizeLimit;
Status = status;
Comment = comment;
TorrentFilePath = torrentFilePath;
Source = source;
Trackers = trackers ?? Array.Empty<string>();
UrlSeeds = urlSeeds ?? Array.Empty<string>();
TimeStarted = timeStarted;
TimeFinished = timeFinished;
ErrorMessage = errorMessage;
Progress = progress;
}
[JsonPropertyName("taskID")]
public string TaskId { get; }
[JsonPropertyName("sourcePath")]
public string? SourcePath { get; }
[JsonPropertyName("pieceSize")]
public int? PieceSize { get; }
[JsonPropertyName("private")]
public bool? Private { get; }
[JsonPropertyName("timeAdded")]
public string? TimeAdded { get; }
[JsonPropertyName("format")]
public string? Format { get; }
[JsonPropertyName("optimizeAlignment")]
public bool? OptimizeAlignment { get; }
[JsonPropertyName("paddedFileSizeLimit")]
public int? PaddedFileSizeLimit { get; }
[JsonPropertyName("status")]
public string? Status { get; }
[JsonPropertyName("comment")]
public string? Comment { get; }
[JsonPropertyName("torrentFilePath")]
public string? TorrentFilePath { get; }
[JsonPropertyName("source")]
public string? Source { get; }
[JsonPropertyName("trackers")]
public IReadOnlyList<string> Trackers { get; }
[JsonPropertyName("urlSeeds")]
public IReadOnlyList<string> UrlSeeds { get; }
[JsonPropertyName("timeStarted")]
public string? TimeStarted { get; }
[JsonPropertyName("timeFinished")]
public string? TimeFinished { get; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; }
[JsonPropertyName("progress")]
public double? Progress { get; }
}
}

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Models
{
public record TorrentMetadata
{
[JsonConstructor]
public TorrentMetadata(
string? infoHashV1,
string? infoHashV2,
string? hash,
TorrentMetadataInfo? info,
IReadOnlyList<TorrentMetadataTracker>? trackers,
IReadOnlyList<string>? webSeeds,
string? createdBy,
long? creationDate,
string? comment)
{
InfoHashV1 = infoHashV1;
InfoHashV2 = infoHashV2;
Hash = hash;
Info = info;
Trackers = trackers ?? Array.Empty<TorrentMetadataTracker>();
WebSeeds = webSeeds ?? Array.Empty<string>();
CreatedBy = createdBy;
CreationDate = creationDate;
Comment = comment;
}
[JsonPropertyName("infohash_v1")]
public string? InfoHashV1 { get; }
[JsonPropertyName("infohash_v2")]
public string? InfoHashV2 { get; }
[JsonPropertyName("hash")]
public string? Hash { get; }
[JsonPropertyName("info")]
public TorrentMetadataInfo? Info { get; }
[JsonPropertyName("trackers")]
public IReadOnlyList<TorrentMetadataTracker> Trackers { get; }
[JsonPropertyName("webseeds")]
public IReadOnlyList<string> WebSeeds { get; }
[JsonPropertyName("created_by")]
public string? CreatedBy { get; }
[JsonPropertyName("creation_date")]
public long? CreationDate { get; }
[JsonPropertyName("comment")]
public string? Comment { get; }
}
public record TorrentMetadataInfo
{
[JsonConstructor]
public TorrentMetadataInfo(
IReadOnlyList<TorrentMetadataFile>? files,
long? length,
string? name,
long? pieceLength,
int? piecesCount,
bool? @private)
{
Files = files ?? Array.Empty<TorrentMetadataFile>();
Length = length;
Name = name;
PieceLength = pieceLength;
PiecesCount = piecesCount;
Private = @private;
}
[JsonPropertyName("files")]
public IReadOnlyList<TorrentMetadataFile> Files { get; }
[JsonPropertyName("length")]
public long? Length { get; }
[JsonPropertyName("name")]
public string? Name { get; }
[JsonPropertyName("piece_length")]
public long? PieceLength { get; }
[JsonPropertyName("pieces_num")]
public int? PiecesCount { get; }
[JsonPropertyName("private")]
public bool? Private { get; }
}
public record TorrentMetadataFile(
[property: JsonPropertyName("path")] string? Path,
[property: JsonPropertyName("length")] long? Length);
public record TorrentMetadataTracker(
[property: JsonPropertyName("url")] string? Url,
[property: JsonPropertyName("tier")] int? Tier);
}

View File

@@ -1,4 +1,6 @@
using System.Text.Json.Serialization;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Models
{
@@ -13,7 +15,10 @@ namespace Lantean.QBitTorrentClient.Models
int seeds,
int leeches,
int downloads,
string message)
string message,
long? nextAnnounce,
long? minAnnounce,
IReadOnlyList<TrackerEndpoint>? endpoints)
{
Url = url;
Status = status;
@@ -23,6 +28,9 @@ namespace Lantean.QBitTorrentClient.Models
Leeches = leeches;
Downloads = downloads;
Message = message;
NextAnnounce = nextAnnounce;
MinAnnounce = minAnnounce;
Endpoints = endpoints ?? Array.Empty<TrackerEndpoint>();
}
[JsonPropertyName("url")]
@@ -48,5 +56,27 @@ namespace Lantean.QBitTorrentClient.Models
[JsonPropertyName("msg")]
public string Message { get; }
[JsonPropertyName("next_announce")]
public long? NextAnnounce { get; }
[JsonPropertyName("min_announce")]
public long? MinAnnounce { get; }
[JsonPropertyName("endpoints")]
public IReadOnlyList<TrackerEndpoint> Endpoints { get; }
}
public record TrackerEndpoint(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("updating")] bool? Updating,
[property: JsonPropertyName("status")] TrackerStatus Status,
[property: JsonPropertyName("msg")] string? Message,
[property: JsonPropertyName("bt_version")] int? BitTorrentVersion,
[property: JsonPropertyName("num_peers")] int? Peers,
[property: JsonPropertyName("num_seeds")] int? Seeds,
[property: JsonPropertyName("num_leeches")] int? Leeches,
[property: JsonPropertyName("num_downloaded")] int? Downloads,
[property: JsonPropertyName("next_announce")] long? NextAnnounce,
[property: JsonPropertyName("min_announce")] long? MinAnnounce);
}

View File

@@ -7,5 +7,7 @@
Working = 2,
Updating = 3,
NotWorking = 4,
Error = 5,
Unreachable = 6
}
}

Some files were not shown because too many files have changed in this diff Show More