using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MonoTorrent; using RdtClient.Data.Models.TorrentClient; using RdtClient.Service.Helpers; using RdtClient.Service.Services; using Torrent = RdtClient.Data.Models.Data.Torrent; using System.Text.Json.Serialization; using NSwag.Annotations; namespace RdtClient.Web.Controllers; /// /// Controller for managing torrents and their downloads /// [Authorize(Policy = "AuthSetting")] [Route("Api/Torrents")] public class TorrentsController(ILogger logger, Torrents torrents, TorrentRunner torrentRunner) : Controller { /// /// Retrieves all torrents and their associated downloads /// /// List of all torrents with their download status /// Returns the list of torrents [HttpGet] [Route("")] [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] public async Task>> GetAll() { var results = await torrents.Get(); // Prevent infinite recursion when serializing foreach (var file in results.SelectMany(torrent => torrent.Downloads)) { file.Torrent = null; } return Ok(results); } /// /// Retrieves a specific torrent by its ID /// /// The unique identifier of the torrent /// The requested torrent details /// Returns the requested torrent /// Torrent not found [HttpGet] [Route("Get/{torrentId:guid}")] [ProducesResponseType(typeof(Torrent), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetById(Guid torrentId) { var torrent = await torrents.GetById(torrentId); if (torrent?.Downloads != null) { foreach (var file in torrent.Downloads) { file.Torrent = null; } } return Ok(torrent); } /// /// Forces an immediate processing cycle for debugging purposes /// /// Success status /// Processing cycle completed successfully [HttpGet] [Route("Tick")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task Tick() { await torrentRunner.Tick(); return Ok(); } /// /// Adds a new torrent file with configuration /// /// The .torrent file to add /// Configuration for the torrent download /// Success status /// Torrent added successfully /// Invalid file or configuration provided [HttpPost] [Route("UploadFile")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(String), StatusCodes.Status400BadRequest)] public async Task UploadFile([OpenApiFile] IFormFile? file, [ModelBinder(BinderType = typeof(JsonModelBinder))] [FromForm] TorrentControllerUploadFileRequest? formData) { if (file == null || file.Length <= 0) { return BadRequest("Invalid torrent file"); } if (formData?.Torrent == null) { return BadRequest("Invalid Torrent"); } logger.LogDebug($"Add file"); var fileStream = file.OpenReadStream(); await using var memoryStream = new MemoryStream(); await fileStream.CopyToAsync(memoryStream); var bytes = memoryStream.ToArray(); await torrents.UploadFile(bytes, formData.Torrent); return Ok(); } /// /// Adds a new torrent using a magnet link /// /// The magnet link and torrent configuration /// Success status /// Magnet link processed successfully /// Invalid magnet link or configuration [HttpPost] [Route("UploadMagnet")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(String), StatusCodes.Status400BadRequest)] public async Task UploadMagnet([FromBody] TorrentControllerUploadMagnetRequest? request) { if (request == null) { return BadRequest(); } if (String.IsNullOrEmpty(request.MagnetLink)) { return BadRequest("Invalid magnet link"); } if (request.Torrent == null) { return BadRequest("Invalid Torrent"); } logger.LogDebug($"Add magnet"); await torrents.UploadMagnet(request.MagnetLink, request.Torrent); return Ok(); } /// /// Checks available files in a torrent file /// /// The .torrent file to analyze /// List of available files in the torrent /// Returns the list of available files /// Invalid torrent file provided [HttpPost] [Route("CheckFiles")] [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] [ProducesResponseType(typeof(String), StatusCodes.Status400BadRequest)] public async Task>> CheckFiles([FromForm] IFormFile? file) { if (file == null || file.Length <= 0) { return BadRequest("Invalid torrent file"); } var fileStream = file.OpenReadStream(); await using var memoryStream = new MemoryStream(); await fileStream.CopyToAsync(memoryStream); var bytes = memoryStream.ToArray(); var torrent = await MonoTorrent.Torrent.LoadAsync(bytes); var result = await torrents.GetAvailableFiles(torrent.InfoHashes.V1OrV2.ToHex()); return Ok(result); } /// /// Checks available files from a magnet link /// /// The magnet link to analyze /// List of available files in the torrent /// Returns the list of available files /// Invalid magnet link provided [HttpPost] [Route("CheckFilesMagnet")] [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] [ProducesResponseType(typeof(String), StatusCodes.Status400BadRequest)] public async Task>> CheckFilesMagnet([FromBody] TorrentControllerCheckFilesRequest? request) { if (request == null) { return BadRequest(); } if (String.IsNullOrEmpty(request.MagnetLink)) { return BadRequest("MagnetLink cannot be null or empty"); } var magnet = MagnetLink.Parse(request.MagnetLink); var result = await torrents.GetAvailableFiles(magnet.InfoHashes.V1OrV2.ToHex()); return Ok(result); } /// /// Deletes a torrent and optionally its associated data /// /// The unique identifier of the torrent to delete /// Delete options specifying what should be removed /// Success status /// Torrent deleted successfully /// Invalid request parameters [HttpPost] [Route("Delete/{torrentId:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Delete(Guid torrentId, [FromBody] TorrentControllerDeleteRequest? request) { if (request == null) { return BadRequest(); } logger.LogDebug("Delete {torrentId}", torrentId); await torrents.Delete(torrentId, request.DeleteData, request.DeleteRdTorrent, request.DeleteLocalFiles); return Ok(); } /// /// Retries a failed torrent download /// /// The unique identifier of the torrent to retry /// Success status /// Retry initiated successfully [HttpPost] [Route("Retry/{torrentId:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task Retry(Guid torrentId) { logger.LogDebug("Retry {torrentId}", torrentId); await torrents.UpdateRetry(torrentId, DateTimeOffset.UtcNow, 0); await torrents.RetryTorrent(torrentId, 0); return Ok(); } /// /// Retries a failed download within a torrent /// /// The unique identifier of the download to retry /// Success status /// Retry initiated successfully [HttpPost] [Route("RetryDownload/{downloadId:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task RetryDownload(Guid downloadId) { logger.LogDebug("Retry download {downloadId}", downloadId); await torrents.RetryDownload(downloadId); return Ok(); } /// /// Updates torrent configuration /// /// The updated torrent configuration /// Success status /// Torrent updated successfully /// Invalid torrent configuration [HttpPut] [Route("Update")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Update([FromBody] Torrent? torrent) { if (torrent == null) { return BadRequest(); } await torrents.Update(torrent); return Ok(); } /// /// Tests regex patterns against torrent files /// /// The regex patterns and magnet link to test /// Matching files and any regex errors /// Returns the regex test results /// Invalid request parameters [HttpPost] [Route("VerifyRegex")] [ProducesResponseType(typeof(RegexVerificationResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> VerifyRegex([FromBody] TorrentControllerVerifyRegexRequest? request) { if (request == null) { return Ok(); } var includeError = ""; var excludeError = ""; IList availableFiles; if (!String.IsNullOrWhiteSpace(request.MagnetLink)) { var magnet = MagnetLink.Parse(request.MagnetLink); availableFiles = await torrents.GetAvailableFiles(magnet.InfoHashes.V1OrV2.ToHex()); } else { return BadRequest(); } var selectedFiles = new List(); if (!String.IsNullOrWhiteSpace(request.IncludeRegex)) { foreach (var availableFile in availableFiles) { try { if (Regex.IsMatch(availableFile.Filename, request.IncludeRegex)) { selectedFiles.Add(availableFile); } } catch (Exception ex) { includeError = ex.Message; } } } else if (!String.IsNullOrWhiteSpace(request.ExcludeRegex)) { foreach (var availableFile in availableFiles) { try { if (!Regex.IsMatch(availableFile.Filename, request.ExcludeRegex)) { selectedFiles.Add(availableFile); } } catch (Exception ex) { excludeError = ex.Message; } } } else { selectedFiles = [.. availableFiles]; } return Ok(new RegexVerificationResult { IncludeError = includeError, ExcludeError = excludeError, SelectedFiles = selectedFiles }); } } /// /// Request model for uploading a torrent file /// public class TorrentControllerUploadFileRequest { /// /// Configuration for the torrent download /// [Required] public Torrent? Torrent { get; set; } } /// /// Request model for adding a magnet link /// public class TorrentControllerUploadMagnetRequest { /// /// The magnet URI to process /// [Required] public String? MagnetLink { get; set; } /// /// Configuration for the torrent download /// [Required] public Torrent? Torrent { get; set; } } /// /// Request model for deleting a torrent /// public class TorrentControllerDeleteRequest { /// /// Whether to delete the downloaded data /// public Boolean DeleteData { get; set; } /// /// Whether to remove the torrent from the Debrid service /// public Boolean DeleteRdTorrent { get; set; } /// /// Whether to delete local torrent files /// public Boolean DeleteLocalFiles { get; set; } } /// /// Request model for checking files in a magnet link /// public class TorrentControllerCheckFilesRequest { /// /// The magnet URI to analyze /// [Required] public String? MagnetLink { get; set; } } /// /// Request model for verifying regex patterns /// public class TorrentControllerVerifyRegexRequest { /// /// Pattern for including files /// public String? IncludeRegex { get; set; } /// /// Pattern for excluding files /// public String? ExcludeRegex { get; set; } /// /// Magnet link to test patterns against /// public String? MagnetLink { get; set; } } /// /// Response model for regex verification results /// public class RegexVerificationResult { /// /// Error message for the include regex pattern, if any /// [JsonPropertyName("includeError")] public String IncludeError { get; set; } = String.Empty; /// /// Error message for the exclude regex pattern, if any /// [JsonPropertyName("excludeError")] public String ExcludeError { get; set; } = String.Empty; /// /// Files that match the specified patterns /// [JsonPropertyName("selectedFiles")] public IList SelectedFiles { get; set; } = new List(); }