using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using RdtClient.Data.Enums; using RdtClient.Data.Models.QBittorrent; using RdtClient.Service.Services; using Torrent = RdtClient.Data.Models.Data.Torrent; namespace RdtClient.Web.Controllers; /// /// This API behaves as a regular QBittorrent 4+ API /// Documentation is found here: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1) /// [ApiController] [Route("api/v2")] [Route("qbittorrent/api/v2")] public class QBittorrentController(ILogger logger, QBittorrent qBittorrent, IHttpClientFactory httpClientFactory, ISettings settings, Torrents torrents) : Controller { [AllowAnonymous] [Route("/version/api")] [HttpGet] [HttpPost] public ActionResult LegacyVersionApi() { // Returning 20 nudges older qB clients to use /api/v2 endpoints. return Content("20", "text/plain"); } [AllowAnonymous] [Route("auth/login")] [HttpGet] public async Task AuthLogin([FromQuery] QBAuthLoginRequest request) { logger.LogDebug($"Auth login"); if (settings.Current.General.AuthenticationType == AuthenticationType.None) { return Content("Ok.", "text/plain"); } if (String.IsNullOrWhiteSpace(request.UserName) || String.IsNullOrEmpty(request.Password)) { return Content("Fails.", "text/plain"); } var result = await qBittorrent.AuthLogin(request.UserName, request.Password); if (result) { return Content("Ok.", "text/plain"); } return Content("Fails.", "text/plain"); } [AllowAnonymous] [Route("auth/login")] [HttpPost] public async Task AuthLoginPost([FromForm] QBAuthLoginRequest request) { return await AuthLogin(request); } [Authorize(Policy = "AuthSetting")] [Route("auth/logout")] [HttpGet] [HttpPost] public async Task AuthLogout() { logger.LogDebug($"Auth logout"); await qBittorrent.AuthLogout(); return Ok(); } [AllowAnonymous] [Route("app/version")] [HttpGet] [HttpPost] public ActionResult AppVersion() { return Ok("v4.3.2"); } [AllowAnonymous] [Route("app/webapiVersion")] [HttpGet] [HttpPost] public ActionResult AppWebVersion() { return Ok("2.7"); } [AllowAnonymous] [Route("app/buildInfo")] [HttpGet] [HttpPost] public ActionResult AppBuildInfo() { var result = new AppBuildInfo { Bitness = 64, Boost = "1.75.0", Libtorrent = "1.2.11.0", Openssl = "1.1.1i", Qt = "5.15.2", Zlib = "1.2.11" }; return Ok(result); } [Authorize(Policy = "AuthSetting")] [Route("app/shutdown")] [HttpGet] [HttpPost] public ActionResult AppShutdown() { return Ok(); } [AllowAnonymous] [Route("app/preferences")] [HttpGet] [HttpPost] public async Task> AppPreferences() { var result = await qBittorrent.AppPreferences(); return Ok(result); } [Authorize(Policy = "AuthSetting")] [Route("app/setPreferences")] [HttpGet] [HttpPost] public ActionResult AppSetPreferences() { return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("app/defaultSavePath")] [HttpGet] [HttpPost] public ActionResult AppDefaultSavePath() { var result = settings.DefaultSavePath; return Ok(result); } [Authorize(Policy = "AuthSetting")] [Route("torrents/info")] [HttpGet] public async Task>> TorrentsInfo([FromQuery] QBTorrentsInfoRequest request) { var results = await qBittorrent.TorrentInfo(); results = results.Where(m => MatchesFilter(m, request.Filter)).ToList(); if (!String.IsNullOrWhiteSpace(request.Category)) { results = results.Where(m => m.Category == request.Category).ToList(); } if (!String.IsNullOrWhiteSpace(request.Hashes)) { var hashSet = new HashSet(request.Hashes.Split('|', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); results = results.Where(m => hashSet.Contains(m.Hash)).ToList(); } return Ok(results); } [Authorize(Policy = "AuthSetting")] [Route("torrents/info")] [HttpPost] public async Task>> TorrentsInfoPost([FromForm] QBTorrentsInfoRequest request) { return await TorrentsInfo(request); } [Authorize(Policy = "AuthSetting")] [Route("torrents/count")] [HttpGet] public async Task> TorrentsCount([FromQuery] QBTorrentsCountRequest request) { var results = await qBittorrent.TorrentInfo(); results = results.Where(m => MatchesFilter(m, request.Filter)).ToList(); return results.Count; } [Authorize(Policy = "AuthSetting")] [Route("torrents/count")] [HttpPost] public async Task> TorrentsCountPost([FromForm] QBTorrentsCountRequest request) { return await TorrentsCount(request); } [Authorize(Policy = "AuthSetting")] [Route("torrents/files")] [HttpGet] public async Task>> TorrentsFiles([FromQuery] QBTorrentsHashRequest request) { if (String.IsNullOrWhiteSpace(request.Hash)) { return BadRequest(); } var result = await qBittorrent.TorrentFileContents(request.Hash); if (result == null) { return NotFound(); } return Ok(result); } [Authorize(Policy = "AuthSetting")] [Route("torrents/files")] [HttpPost] public async Task>> TorrentsFilesPost([FromForm] QBTorrentsHashRequest request) { return await TorrentsFiles(request); } [Authorize(Policy = "AuthSetting")] [Route("torrents/properties")] [HttpGet] public async Task>> TorrentsProperties([FromQuery] QBTorrentsHashRequest request) { if (String.IsNullOrWhiteSpace(request.Hash)) { return BadRequest(); } var result = await qBittorrent.TorrentProperties(request.Hash); if (result == null) { return NotFound(); } return Ok(result); } [Authorize(Policy = "AuthSetting")] [Route("torrents/properties")] [HttpPost] public async Task>> TorrentsPropertiesPost([FromForm] QBTorrentsHashRequest request) { return await TorrentsProperties(request); } [Authorize(Policy = "AuthSetting")] [Route("torrents/pause")] [HttpGet] public async Task TorrentsPause([FromQuery] QBTorrentsHashesRequest request) { if (String.IsNullOrWhiteSpace(request.Hashes)) { return BadRequest(); } var hashes = request.Hashes.Split("|"); foreach (var hash in hashes) { await qBittorrent.TorrentPause(hash); } return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/pause")] [HttpPost] public async Task TorrentsPausePost([FromForm] QBTorrentsHashesRequest request) { return await TorrentsPause(request); } [Authorize(Policy = "AuthSetting")] [Route("torrents/resume")] [HttpGet] public async Task TorrentsResume([FromQuery] QBTorrentsHashesRequest request) { if (String.IsNullOrWhiteSpace(request.Hashes)) { return BadRequest(); } var hashes = request.Hashes.Split("|"); foreach (var hash in hashes) { await qBittorrent.TorrentResume(hash); } return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/resume")] [HttpPost] public async Task TorrentsResumePost([FromForm] QBTorrentsHashesRequest request) { return await TorrentsResume(request); } [Authorize(Policy = "AuthSetting")] [Route("torrents/setShareLimits")] [HttpGet] [HttpPost] public ActionResult TorrentsSetShareLimits() { return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/delete")] [HttpGet] public async Task TorrentsDelete([FromQuery] QBTorrentsDeleteRequest request) { if (String.IsNullOrWhiteSpace(request.Hashes)) { return BadRequest(); } logger.LogDebug("Delete {Hashes}", request.Hashes); var hashes = request.Hashes.Split("|"); foreach (var hash in hashes) { await qBittorrent.TorrentsDelete(hash, request.DeleteFiles); } return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/delete")] [HttpPost] public async Task TorrentsDeletePost([FromForm] QBTorrentsDeleteRequest request) { return await TorrentsDelete(request); } [Authorize(Policy = "AuthSetting")] [Route("torrents/add")] [HttpGet] public async Task TorrentsAdd([FromQuery] QBTorrentsAddRequest request) { if (String.IsNullOrWhiteSpace(request.Urls)) { return BadRequest(); } var urls = request.Urls.Split("\n"); foreach (var url in urls) { Torrent? torrent; if (url.StartsWith("magnet")) { torrent = await qBittorrent.TorrentsAddMagnet(url.Trim(), request.Category, null); } else if (url.StartsWith("http")) { var httpClient = httpClientFactory.CreateClient(); var result = await httpClient.GetByteArrayAsync(url); torrent = await qBittorrent.TorrentsAddFile(result, request.Category, null); } else { return BadRequest($"Invalid torrent link format {url}"); } var addResult = await WaitForTorrent(torrent.TorrentId); if (!addResult) { return Ok("Fails."); } } return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/add")] [HttpPost] public async Task TorrentsAddPost([FromForm] QBTorrentsAddRequest request) { foreach (var file in Request.Form.Files) { if (file.Length > 0) { await using var target = new MemoryStream(); await file.CopyToAsync(target); var fileBytes = target.ToArray(); var torrent = await qBittorrent.TorrentsAddFile(fileBytes, request.Category, request.Priority); var addResult = await WaitForTorrent(torrent.TorrentId); if (!addResult) { return Ok("Fails."); } } } if (request.Urls != null) { return await TorrentsAdd(request); } return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/filePrio")] [HttpGet] public async Task TorrentsFilePrio([FromQuery] QBTorrentsFilePrioRequest request) { if (String.IsNullOrWhiteSpace(request.Hash) || String.IsNullOrWhiteSpace(request.Id) || request.Priority == null) { return BadRequest(); } var fileIds = request.Id .Split('|', StringSplitOptions.RemoveEmptyEntries) .Select(value => Int32.TryParse(value, out var parsedValue) ? parsedValue : (Int32?)null) .Where(value => value.HasValue) .Select(value => value!.Value) .ToList(); if (fileIds.Count == 0) { return BadRequest(); } await qBittorrent.TorrentsFilePrio(request.Hash, fileIds, request.Priority.Value); return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/filePrio")] [HttpPost] public async Task TorrentsFilePrioPost([FromForm] QBTorrentsFilePrioRequest request) { return await TorrentsFilePrio(request); } [Authorize(Policy = "AuthSetting")] [Route("torrents/setCategory")] [HttpGet] public async Task TorrentsSetCategory([FromQuery] QBTorrentsSetCategoryRequest request) { if (String.IsNullOrWhiteSpace(request.Hashes)) { return BadRequest(); } var hashes = request.Hashes.Split("|"); foreach (var hash in hashes) { await qBittorrent.TorrentsSetCategory(hash, request.Category); } return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/setCategory")] [HttpPost] public async Task TorrentsSetCategoryPost([FromForm] QBTorrentsSetCategoryRequest request) { return await TorrentsSetCategory(request); } [Authorize(Policy = "AuthSetting")] [Route("torrents/categories")] [HttpGet] [HttpPost] public async Task>> TorrentsCategories() { var categories = await qBittorrent.TorrentsCategories(); return Ok(categories); } [Authorize(Policy = "AuthSetting")] [Route("torrents/createCategory")] [HttpGet] [HttpPost] public async Task TorrentsCreateCategory([FromForm] QBTorrentsCreateCategoryRequest request) { if (String.IsNullOrWhiteSpace(request.Category)) { return BadRequest("category name is empty"); } await qBittorrent.CategoryCreate(request.Category.Trim()); return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/createTags")] [HttpGet] [HttpPost] public ActionResult TorrentsCreateTags() { return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/removeCategories")] [HttpGet] [HttpPost] public async Task TorrentsRemoveCategories([FromForm] QBTorrentsRemoveCategoryRequest request) { if (String.IsNullOrWhiteSpace(request.Categories)) { return Ok(); } var categories = request.Categories.Split('\n', StringSplitOptions.RemoveEmptyEntries); foreach (var category in categories) { await qBittorrent.CategoryRemove(category.Trim()); } return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/setForcestart")] [HttpGet] [HttpPost] public ActionResult TorrentsSetForceStart() { return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/tags")] [HttpGet] [HttpPost] public ActionResult TorrentsTags() { return Ok(new List()); } [Authorize(Policy = "AuthSetting")] [Route("torrents/topPrio")] [HttpGet] public async Task TorrentsTopPrio([FromQuery] QBTorrentsHashesRequest request) { if (String.IsNullOrWhiteSpace(request.Hashes)) { return BadRequest(); } var hashes = request.Hashes.Split("|"); foreach (var hash in hashes) { await qBittorrent.TorrentsTopPrio(hash); } return Ok(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/topPrio")] [HttpPost] public async Task TorrentsTopPrioPost([FromForm] QBTorrentsHashesRequest request) { return await TorrentsTopPrio(request); } [Authorize(Policy = "AuthSetting")] [Route("sync/maindata")] [HttpGet] public async Task SyncMainData() { var result = await qBittorrent.SyncMainData(); return Ok(result); } [Authorize(Policy = "AuthSetting")] [Route("sync/maindata")] [HttpPost] public async Task SyncMainDataPost() { return await SyncMainData(); } [Authorize(Policy = "AuthSetting")] [Route("transfer/info")] [HttpGet] public ActionResult TransferInfo() { return Ok(qBittorrent.TransferInfo()); } [Authorize(Policy = "AuthSetting")] [Route("transfer/info")] [HttpPost] public ActionResult TransferInfoPost() { return TransferInfo(); } [Authorize(Policy = "AuthSetting")] [Route("torrents/trackers")] [HttpGet] public async Task>> TorrentsTrackers([FromQuery] QBTorrentsHashRequest request) { if (String.IsNullOrWhiteSpace(request.Hash)) { return BadRequest(); } var results = await qBittorrent.TorrentsTrackers(request.Hash); return Ok(results); } [Authorize(Policy = "AuthSetting")] [Route("torrents/trackers")] [HttpPost] public async Task>> TorrentsTrackersPost([FromForm] QBTorrentsHashRequest request) { return await TorrentsTrackers(request); } private static Boolean MatchesFilter(TorrentInfo torrent, String? filter) { if (String.IsNullOrWhiteSpace(filter) || filter.Equals("all", StringComparison.OrdinalIgnoreCase)) { return true; } return filter.ToLowerInvariant() switch { "downloading" => torrent.Progress < 1f && !String.Equals(torrent.State, "error", StringComparison.OrdinalIgnoreCase), "completed" => torrent.Progress >= 1f || torrent.CompletionOn.HasValue, "paused" => torrent.State?.StartsWith("paused", StringComparison.OrdinalIgnoreCase) == true, "active" => (torrent.Dlspeed ?? 0) > 0 || (torrent.Upspeed ?? 0) > 0, "inactive" => (torrent.Dlspeed ?? 0) <= 0 && (torrent.Upspeed ?? 0) <= 0, "resumed" => torrent.State?.StartsWith("paused", StringComparison.OrdinalIgnoreCase) != true && !String.Equals(torrent.State, "error", StringComparison.OrdinalIgnoreCase), "stalled" => String.Equals(torrent.State, "stalledDL", StringComparison.OrdinalIgnoreCase) || String.Equals(torrent.State, "stalledUP", StringComparison.OrdinalIgnoreCase), "stalled_uploading" => String.Equals(torrent.State, "stalledUP", StringComparison.OrdinalIgnoreCase), "stalled_downloading" => String.Equals(torrent.State, "stalledDL", StringComparison.OrdinalIgnoreCase), "errored" => String.Equals(torrent.State, "error", StringComparison.OrdinalIgnoreCase), _ => String.Equals(torrent.State, filter, StringComparison.OrdinalIgnoreCase) }; } private async Task WaitForTorrent(Guid torrentId) { while (true) { var torrent = await torrents.GetById(torrentId); if (torrent == null) { throw new($"Failed to add torrent: Not Found"); } if (torrent.RdStatus == TorrentStatus.Error || torrent.Error != null) { return false; } if (torrent.RdStatus != TorrentStatus.Queued) { return true; } await Task.Delay(1000); } } } public class QBAuthLoginRequest { public String? UserName { get; set; } public String? Password { get; set; } } public class QBTorrentsInfoRequest { public String? Filter { get; set; } public String? Category { get; set; } public String? Hashes { get; set; } } public class QBTorrentsCountRequest { public String? Filter { get; set; } } public class QBTorrentsHashRequest { public String? Hash { get; set; } } public class QBTorrentsFilePrioRequest { public String? Hash { get; set; } public String? Id { get; set; } public Int32? Priority { get; set; } } public class QBTorrentsDeleteRequest { public String? Hashes { get; set; } public Boolean DeleteFiles { get; set; } } public class QBTorrentsAddRequest { public String? Urls { get; set; } public String? Category { get; set; } public Int32? Priority { get; set; } } public class QBTorrentsSetCategoryRequest { public String? Hashes { get; set; } public String? Category { get; set; } } public class QBTorrentsCreateCategoryRequest { public String? Category { get; set; } } public class QBTorrentsRemoveCategoryRequest { public String? Categories { get; set; } } public class QBTorrentsHashesRequest { public String? Hashes { get; set; } }