using Lantean.QBitTorrentClient.Models; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Http.Json; using System.Text.Json; namespace Lantean.QBitTorrentClient { public class ApiClient : IApiClient { private readonly HttpClient _httpClient; private readonly JsonSerializerOptions _options = SerializerOptions.Options; public ApiClient(HttpClient httpClient) { _httpClient = httpClient; } #region Authentication public async Task CheckAuthState() { try { var response = await _httpClient.GetAsync("app/version"); return response.StatusCode == HttpStatusCode.OK; } catch { return false; } } public async Task Login(string username, string password) { var content = new FormUrlEncodedBuilder() .Add("username", username) .Add("password", password) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("auth/login", content); await ThrowIfNotSuccessfulStatusCode(response); var responseContent = await response.Content.ReadAsStringAsync(); if (responseContent == "Fails.") { throw new HttpRequestException(null, null, HttpStatusCode.BadRequest); } } public async Task Logout() { var response = await _httpClient.PostAsync("auth/logout", null); await ThrowIfNotSuccessfulStatusCode(response); } #endregion Authentication #region Application public async Task GetApplicationVersion() { var response = await _httpClient.GetAsync("app/version"); await ThrowIfNotSuccessfulStatusCode(response); return await response.Content.ReadAsStringAsync(); } public async Task GetAPIVersion() { var response = await _httpClient.GetAsync("app/webapiVersion"); await ThrowIfNotSuccessfulStatusCode(response); return await response.Content.ReadAsStringAsync(); } public async Task GetBuildInfo() { var response = await _httpClient.GetAsync("app/buildInfo"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJson(response.Content); } public async Task Shutdown() { var response = await _httpClient.PostAsync("app/shutdown", null); await ThrowIfNotSuccessfulStatusCode(response); } public async Task GetApplicationPreferences() { var response = await _httpClient.GetAsync("app/preferences"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJson(response.Content); } public async Task SetApplicationPreferences(UpdatePreferences preferences) { preferences.Validate(); var json = JsonSerializer.Serialize(preferences, _options); var content = new FormUrlEncodedBuilder() .Add("json", json) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("app/setPreferences", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task> GetApplicationCookies() { var response = await _httpClient.GetAsync("app/cookies"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task SetApplicationCookies(IEnumerable 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 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(payload, _options); if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("apiKey", out var apiKeyElement)) { return apiKeyElement.GetString() ?? string.Empty; } return string.Empty; } public async Task GetDefaultSavePath() { var response = await _httpClient.GetAsync("app/defaultSavePath"); await ThrowIfNotSuccessfulStatusCode(response); return await response.Content.ReadAsStringAsync(); } public async Task> GetNetworkInterfaces() { var response = await _httpClient.GetAsync("app/networkInterfaceList"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task> GetNetworkInterfaceAddressList(string @interface) { var response = await _httpClient.GetAsync($"app/networkInterfaceAddressList?iface={@interface}"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } #endregion Application #region Client data public async Task> LoadClientData(IEnumerable? 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(response.Content); } public async Task StoreClientData(IReadOnlyDictionary 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> GetLog(bool? normal = null, bool? info = null, bool? warning = null, bool? critical = null, int? lastKnownId = null) { var query = new QueryBuilder(); if (normal is not null) { query.Add("normal", normal.Value); } if (info is not null) { query.Add("info", info.Value); } if (warning is not null) { query.Add("warning", warning.Value); } if (critical is not null) { query.Add("critical", critical.Value); } if (lastKnownId is not null) { query.Add("last_known_id", lastKnownId.Value); } var response = await _httpClient.GetAsync($"log/main", query); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task> GetPeerLog(int? lastKnownId = null) { var query = new QueryBuilder(); if (lastKnownId is not null) { query.Add("last_known_id", lastKnownId.Value); } var response = await _httpClient.GetAsync($"log/peers", query); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } #endregion Log #region Sync public async Task GetMainData(int requestId) { var response = await _httpClient.GetAsync($"sync/maindata?rid={requestId}"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJson(response.Content); } public async Task GetTorrentPeersData(string hash, int requestId) { var response = await _httpClient.GetAsync($"sync/torrentPeers?hash={hash}&rid={requestId}"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJson(response.Content); } #endregion Sync #region Transfer info public async Task GetGlobalTransferInfo() { var response = await _httpClient.GetAsync("transfer/info"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJson(response.Content); } public async Task GetAlternativeSpeedLimitsState() { var response = await _httpClient.GetAsync("transfer/speedLimitsMode"); await ThrowIfNotSuccessfulStatusCode(response); var value = await response.Content.ReadAsStringAsync(); return value == "1"; } public async Task ToggleAlternativeSpeedLimits() { var response = await _httpClient.PostAsync("transfer/toggleSpeedLimitsMode", null); await ThrowIfNotSuccessfulStatusCode(response); } public async Task GetGlobalDownloadLimit() { var response = await _httpClient.GetAsync("transfer/downloadLimit"); await ThrowIfNotSuccessfulStatusCode(response); var value = await response.Content.ReadAsStringAsync(); return long.Parse(value); } public async Task SetGlobalDownloadLimit(long limit) { var content = new FormUrlEncodedBuilder() .Add("limit", limit) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("transfer/setDownloadLimit", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task GetGlobalUploadLimit() { var response = await _httpClient.GetAsync("transfer/uploadLimit"); await ThrowIfNotSuccessfulStatusCode(response); var value = await response.Content.ReadAsStringAsync(); return long.Parse(value); } public async Task SetGlobalUploadLimit(long limit) { var content = new FormUrlEncodedBuilder() .Add("limit", limit) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("transfer/setUploadLimit", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task BanPeers(IEnumerable peers) { var content = new FormUrlEncodedBuilder() .AddPipeSeparated("peers", peers) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("transfer/banPeers", content); await ThrowIfNotSuccessfulStatusCode(response); } #endregion Transfer info #region Torrent management public async Task> 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) { query.Add("filter", filter); } if (category is not null) { query.Add("category", category); } if (tag is not null) { query.Add("tag", tag); } if (sort is not null) { query.Add("sort", sort); } if (reverse is not null) { query.Add("reverse", reverse.Value); } if (limit is not null) { query.Add("limit", limit.Value); } if (offset is not null) { query.Add("offset", offset.Value); } if (hashes.Length > 0) { query.Add("hashes", string.Join('|', hashes)); } if (isPrivate is not null) { 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); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task GetTorrentProperties(string hash) { var response = await _httpClient.GetAsync($"torrents/properties?hash={hash}"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJson(response.Content); } public async Task> GetTorrentTrackers(string hash) { var response = await _httpClient.GetAsync($"torrents/trackers?hash={hash}"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task> GetTorrentWebSeeds(string hash) { var response = await _httpClient.GetAsync($"torrents/webseeds?hash={hash}"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task AddTorrentWebSeeds(string hash, IEnumerable 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 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> GetTorrentContents(string hash, params int[] indexes) { var query = new QueryBuilder(); query.Add("hash", hash); if (indexes.Length > 0) { query.Add("indexes", string.Join('|', indexes)); } var response = await _httpClient.GetAsync("torrents/files", query); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task> GetTorrentPieceStates(string hash) { var response = await _httpClient.GetAsync($"torrents/pieceStates?hash={hash}"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task> GetTorrentPieceHashes(string hash) { var response = await _httpClient.GetAsync($"torrents/pieceHashes?hash={hash}"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task StopTorrents(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/stop", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task StartTorrents(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/start", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task DeleteTorrents(bool? all = null, bool deleteFiles = false, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .Add("deleteFiles", deleteFiles) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/delete", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task RecheckTorrents(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/recheck", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task ReannounceTorrents(bool? all = null, IEnumerable? 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); await ThrowIfNotSuccessfulStatusCode(response); } public async Task AddTorrent(AddTorrentParams addTorrentParams) { var content = new MultipartFormDataContent(); 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) { content.Add(new StreamContent(stream), "torrents", name); } } if (addTorrentParams.SkipChecking is not null) { content.AddString("skip_checking", addTorrentParams.SkipChecking.Value); } if (addTorrentParams.SequentialDownload is not null) { content.AddString("sequentialDownload", addTorrentParams.SequentialDownload.Value); } if (addTorrentParams.FirstLastPiecePriority is not null) { content.AddString("firstLastPiecePrio", addTorrentParams.FirstLastPiecePriority.Value); } if (addTorrentParams.AddToTopOfQueue is not null) { content.AddString("addToTopOfQueue", addTorrentParams.AddToTopOfQueue.Value); } if (addTorrentParams.Forced is not null) { content.AddString("forced", addTorrentParams.Forced.Value); } if (addTorrentParams.Stopped is not null) { content.AddString("stopped", addTorrentParams.Stopped.Value); } if (addTorrentParams.SavePath is not null) { content.AddString("savepath", addTorrentParams.SavePath); } if (addTorrentParams.DownloadPath is not null) { content.AddString("downloadPath", addTorrentParams.DownloadPath); } if (addTorrentParams.UseDownloadPath is not null) { content.AddString("useDownloadPath", addTorrentParams.UseDownloadPath.Value); } if (addTorrentParams.Category is not null) { content.AddString("category", addTorrentParams.Category); } if (addTorrentParams.Tags is not null) { content.AddString("tags", string.Join(',', addTorrentParams.Tags)); } if (addTorrentParams.RenameTorrent is not null) { content.AddString("rename", addTorrentParams.RenameTorrent); } if (addTorrentParams.UploadLimit is not null) { content.AddString("upLimit", addTorrentParams.UploadLimit.Value); } if (addTorrentParams.DownloadLimit is not null) { content.AddString("dlLimit", addTorrentParams.DownloadLimit.Value); } if (addTorrentParams.RatioLimit is not null) { content.AddString("ratioLimit", addTorrentParams.RatioLimit.Value); } if (addTorrentParams.SeedingTimeLimit is not null) { content.AddString("seedingTimeLimit", addTorrentParams.SeedingTimeLimit.Value); } if (addTorrentParams.InactiveSeedingTimeLimit is not null) { content.AddString("inactiveSeedingTimeLimit", addTorrentParams.InactiveSeedingTimeLimit.Value); } if (addTorrentParams.ShareLimitAction is not null) { content.AddString("shareLimitAction", addTorrentParams.ShareLimitAction.Value); } if (addTorrentParams.AutoTorrentManagement is not null) { content.AddString("autoTMM", addTorrentParams.AutoTorrentManagement.Value); } if (addTorrentParams.StopCondition is not null) { content.AddString("stopCondition", addTorrentParams.StopCondition.Value); } if (addTorrentParams.ContentLayout is not null) { content.AddString("contentLayout", addTorrentParams.ContentLayout.Value); } if (addTorrentParams.Downloader is not null) { 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); if (response.StatusCode == HttpStatusCode.Conflict) { var conflictMessage = await response.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(conflictMessage)) { conflictMessage = "All torrents failed to add."; } 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()); } return JsonSerializer.Deserialize(payload, _options) ?? new AddTorrentResult(0, 0, 0, Array.Empty()); } public async Task AddTrackersToTorrent(IEnumerable 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() .AddAllOrPipeSeparated("hash", all, hashes ?? Array.Empty()) .Add("urls", string.Join('\n', urls)) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/addTrackers", content); await ThrowIfNotSuccessfulStatusCode(response); } 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("url", url); 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(IEnumerable 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() .AddAllOrPipeSeparated("hash", all, hashes ?? Array.Empty()) .AddPipeSeparated("urls", urls) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/removeTrackers", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task AddPeers(IEnumerable hashes, IEnumerable peers) { var content = new FormUrlEncodedBuilder() .AddPipeSeparated("hashes", hashes) .AddPipeSeparated("urls", peers) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/addPeers", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task IncreaseTorrentPriority(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/increasePrio", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task DecreaseTorrentPriority(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/decreasePrio", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task MaxTorrentPriority(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/topPrio", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task MinTorrentPriority(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/bottomPrio", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task SetFilePriority(string hash, IEnumerable id, Priority priority) { var content = new FormUrlEncodedBuilder() .Add("hash", hash) .AddPipeSeparated("id", id) .Add("priority", priority) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/filePrio", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task> GetTorrentDownloadLimit(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/downloadLimit", content); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonDictionary(response.Content); } public async Task SetTorrentDownloadLimit(long limit, bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .Add("limit", limit) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/setDownloadLimit", content); await ThrowIfNotSuccessfulStatusCode(response); } 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); await ThrowIfNotSuccessfulStatusCode(response); } public async Task> GetTorrentUploadLimit(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/uploadLimit", content); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonDictionary(response.Content); } public async Task SetTorrentUploadLimit(long limit, bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .Add("limit", limit) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/setUploadLimit", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task SetTorrentLocation(string location, bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .Add("location", location) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/setLocation", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task SetTorrentName(string name, string hash) { var content = new FormUrlEncodedBuilder() .Add("hash", hash) .Add("name", name) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/rename", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task SetTorrentComment(IEnumerable 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() .AddAllOrPipeSeparated("hashes", all, hashes) .Add("category", category) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/setCategory", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task> GetAllCategories() { var response = await _httpClient.GetAsync("torrents/categories"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonDictionary(response.Content); } public async Task AddCategory(string category, string savePath) { var content = new FormUrlEncodedBuilder() .Add("category", category) .Add("savePath", savePath) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/createCategory", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task EditCategory(string category, string savePath) { var content = new FormUrlEncodedBuilder() .Add("category", category) .Add("savePath", savePath) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/editCategory", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task RemoveCategories(params string[] categories) { var content = new FormUrlEncodedBuilder() .Add("categories", string.Join('\n', categories)) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/removeCategories", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task AddTorrentTags(IEnumerable tags, bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .AddCommaSeparated("tags", tags) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/addTags", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task RemoveTorrentTags(IEnumerable tags, bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .AddCommaSeparated("tags", tags) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/removeTags", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task> GetAllTags() { var response = await _httpClient.GetAsync("torrents/tags"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task CreateTags(IEnumerable tags) { var content = new FormUrlEncodedBuilder() .AddCommaSeparated("tags", tags) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/createTags", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task DeleteTags(params string[] tags) { var content = new FormUrlEncodedBuilder() .AddCommaSeparated("tags", tags) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/deleteTags", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task SetAutomaticTorrentManagement(bool enable, bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .Add("enable", enable) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/setAutoManagement", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task ToggleSequentialDownload(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/toggleSequentialDownload", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task SetFirstLastPiecePriority(bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/toggleFirstLastPiecePrio", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task SetForceStart(bool value, bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .Add("value", value) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/setForceStart", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task SetSuperSeeding(bool value, bool? all = null, params string[] hashes) { var content = new FormUrlEncodedBuilder() .AddAllOrPipeSeparated("hashes", all, hashes) .Add("value", value) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/setSuperSeeding", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task RenameFile(string hash, string oldPath, string newPath) { var content = new FormUrlEncodedBuilder() .Add("hash", hash) .Add("oldPath", oldPath) .Add("newPath", newPath) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/renameFile", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task RenameFolder(string hash, string oldPath, string newPath) { var content = new FormUrlEncodedBuilder() .Add("hash", hash) .Add("oldPath", oldPath) .Add("newPath", newPath) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("torrents/renameFolder", content); await ThrowIfNotSuccessfulStatusCode(response); } public Task GetExportUrl(string hash) { return Task.FromResult($"{_httpClient.BaseAddress}torrents/export?hash={hash}"); } public async Task 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(payload, _options); } public async Task> 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(response.Content); } public async Task 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 AddTorrentCreationTask(TorrentCreationTaskRequest request) { if (request is null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(request.SourcePath)) throw new ArgumentException("SourcePath is required.", nameof(request)); var builder = new FormUrlEncodedBuilder() .Add("sourcePath", request.SourcePath); 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(payload, _options); if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("taskID", out var idElement)) { return idElement.GetString() ?? string.Empty; } return string.Empty; } public async Task> 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(response.Content); } public async Task 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) { var content = new FormUrlEncodedBuilder() .Add("path", path) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("rss/addFolder", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task AddRssFeed(string url, string? path = null) { var content = new FormUrlEncodedBuilder() .Add("url", url) .AddIfNotNullOrEmpty("path", path) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("rss/addFeed", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task RemoveRssItem(string path) { var content = new FormUrlEncodedBuilder() .Add("path", path) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("rss/removeItem", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task MoveRssItem(string itemPath, string destPath) { var content = new FormUrlEncodedBuilder() .Add("itemPath", itemPath) .Add("destPath", destPath) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("rss/moveItem", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task> GetAllRssItems(bool? withData = null) { var content = new QueryBuilder() .AddIfNotNullOrEmpty("withData", withData); var response = await _httpClient.GetAsync("rss/items", content); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonDictionary(response.Content); } public async Task MarkRssItemAsRead(string itemPath, string? articleId = null) { var content = new FormUrlEncodedBuilder() .Add("itemPath", itemPath) .AddIfNotNullOrEmpty("articleId", articleId) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("rss/markAsRead", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task RefreshRssItem(string itemPath) { var content = new FormUrlEncodedBuilder() .Add("itemPath", itemPath) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("rss/refreshItem", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task SetRssAutoDownloadingRule(string ruleName, AutoDownloadingRule ruleDef) { var content = new FormUrlEncodedBuilder() .Add("ruleName", ruleName) .Add("ruleDef", JsonSerializer.Serialize(ruleDef)) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("rss/setRule", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task RenameRssAutoDownloadingRule(string ruleName, string newRuleName) { var content = new FormUrlEncodedBuilder() .Add("ruleName", ruleName) .Add("newRuleName", newRuleName) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("rss/renameRule", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task RemoveRssAutoDownloadingRule(string ruleName) { var content = new FormUrlEncodedBuilder() .Add("ruleName", ruleName) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("rss/removeRule", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task> GetAllRssAutoDownloadingRules() { var response = await _httpClient.GetAsync("rss/rules"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonDictionary(response.Content); } public async Task>> GetRssMatchingArticles(string ruleName) { var query = new QueryBuilder() .Add("ruleName", ruleName); var response = await _httpClient.GetAsync($"rss/matchingArticles{query}"); await ThrowIfNotSuccessfulStatusCode(response); var dictionary = await GetJsonDictionary>(response.Content); return dictionary.ToDictionary(d => d.Key, d => (IReadOnlyList)d.Value.ToList().AsReadOnly()).AsReadOnly(); } #endregion RSS #region Search public async Task StartSearch(string pattern, IEnumerable plugins, string category = "all") { var content = new FormUrlEncodedBuilder() .Add("pattern", pattern) .AddPipeSeparated("plugins", plugins) .Add("category", category) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("search/start", content); await ThrowIfNotSuccessfulStatusCode(response); var obj = await GetJson>(response.Content); return obj["id"].GetInt32(); } public async Task StopSearch(int id) { var content = new FormUrlEncodedBuilder() .Add("id", id) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("search/stop", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task GetSearchStatus(int id) { var query = new QueryBuilder(); query.Add("id", id); var response = await _httpClient.GetAsync($"search/status{query}"); if (response.StatusCode == HttpStatusCode.NotFound) { return null; } await ThrowIfNotSuccessfulStatusCode(response); return (await GetJsonList(response.Content)).FirstOrDefault(); } public async Task> GetSearchesStatus() { var response = await _httpClient.GetAsync($"search/status"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task GetSearchResults(int id, int? limit = null, int? offset = null) { var query = new QueryBuilder(); query.Add("id", id); if (limit is not null) { query.Add("limit", limit.Value); } if (offset is not null) { query.Add("offset", offset.Value); } var response = await _httpClient.GetAsync($"search/results{query}"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJson(response.Content); } public async Task DeleteSearch(int id) { var content = new FormUrlEncodedBuilder() .Add("id", id) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("search/delete", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task> GetSearchPlugins() { var response = await _httpClient.GetAsync($"search/plugins"); await ThrowIfNotSuccessfulStatusCode(response); return await GetJsonList(response.Content); } public async Task InstallSearchPlugins(params string[] sources) { var content = new FormUrlEncodedBuilder() .AddPipeSeparated("sources", sources) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("search/installPlugin", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task UninstallSearchPlugins(params string[] names) { var content = new FormUrlEncodedBuilder() .AddPipeSeparated("names", names) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("search/uninstallPlugin", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task EnableSearchPlugins(params string[] names) { var content = new FormUrlEncodedBuilder() .AddPipeSeparated("names", names) .Add("enable", true) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("search/enablePlugin", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task DisableSearchPlugins(params string[] names) { var content = new FormUrlEncodedBuilder() .AddPipeSeparated("names", names) .Add("enable", false) .ToFormUrlEncodedContent(); var response = await _httpClient.PostAsync("search/enablePlugin", content); await ThrowIfNotSuccessfulStatusCode(response); } public async Task UpdateSearchPlugins() { var response = await _httpClient.PostAsync("search/updatePlugins", null); await ThrowIfNotSuccessfulStatusCode(response); } #endregion Search private async Task GetJson(HttpContent content) { return await content.ReadFromJsonAsync(_options) ?? throw new InvalidOperationException($"Unable to deserialize response as {typeof(T).Name}"); } private async Task> GetJsonList(HttpContent content) { try { var items = await GetJson>(content); return items.ToList().AsReadOnly(); } catch { return []; } } private async Task> GetJsonDictionary(HttpContent content) where TKey : notnull { try { var items = await GetJson>(content); return items.AsReadOnly(); } catch { return new Dictionary().AsReadOnly(); } } private async Task ThrowIfNotSuccessfulStatusCode(HttpResponseMessage response) { if (response.IsSuccessStatusCode) { return response; } var errorMessage = await response.Content.ReadAsStringAsync(); throw new HttpRequestException(errorMessage, null, response.StatusCode); } } }