using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers; [ApiController] [Route("api/seeding-rules")] [Authorize] public class SeedingRulesController : ControllerBase { private readonly ILogger _logger; private readonly DataContext _dataContext; public SeedingRulesController( ILogger logger, DataContext dataContext) { _logger = logger; _dataContext = dataContext; } [HttpGet("{downloadClientId}")] public async Task GetSeedingRules(Guid downloadClientId) { await DataContext.Lock.WaitAsync(); try { var client = await _dataContext.DownloadClients .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == downloadClientId); if (client is null) { return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" }); } var rules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client); return Ok(rules); } catch (Exception ex) { _logger.LogError(ex, "Failed to retrieve seeding rules for client {ClientId}", downloadClientId); return StatusCode(500, new { Message = "Failed to retrieve seeding rules", Error = ex.Message }); } finally { DataContext.Lock.Release(); } } [HttpPost("{downloadClientId}")] public async Task CreateSeedingRule(Guid downloadClientId, [FromBody] SeedingRuleRequest ruleDto) { if (!ModelState.IsValid) { return BadRequest(ModelState); } await DataContext.Lock.WaitAsync(); try { var client = await _dataContext.DownloadClients .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == downloadClientId); if (client is null) { return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" }); } var existingRules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client); var duplicate = existingRules.FirstOrDefault(r => r.Name.Equals(ruleDto.Name.Trim(), StringComparison.OrdinalIgnoreCase) && r.PrivacyType == ruleDto.PrivacyType); if (duplicate is not null) { return BadRequest(new { Message = "A seeding rule with this name and privacy type already exists for this client" }); } var overlapError = GetPrivacyTypeOverlapError(ruleDto.Name.Trim(), ruleDto.PrivacyType, existingRules, excludeId: null); if (overlapError is not null) { return BadRequest(new { Message = overlapError }); } var rule = CreateRule(client.TypeName, client.Id, ruleDto); rule.Validate(); AddRuleToDbSet(rule); await _dataContext.SaveChangesAsync(); _logger.LogInformation("Created seeding rule: {RuleName} with ID: {RuleId} for client {ClientId}", rule.Name, rule.Id, downloadClientId); return CreatedAtAction(nameof(GetSeedingRules), new { downloadClientId }, rule); } catch (ValidationException ex) { _logger.LogWarning("Validation failed for seeding rule creation: {Message}", ex.Message); return BadRequest(new { Message = ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "Failed to create seeding rule: {RuleName} for client {ClientId}", ruleDto.Name, downloadClientId); return StatusCode(500, new { Message = "Failed to create seeding rule", Error = ex.Message }); } finally { DataContext.Lock.Release(); } } [HttpPut("{id}")] public async Task UpdateSeedingRule(Guid id, [FromBody] SeedingRuleRequest ruleDto) { if (!ModelState.IsValid) { return BadRequest(ModelState); } await DataContext.Lock.WaitAsync(); try { var (existingRule, _) = await SeedingRuleHelper.FindByIdAsync(_dataContext, id); if (existingRule is null) { return NotFound(new { Message = $"Seeding rule with ID {id} not found" }); } // Check for duplicate name+privacyType on the same client, excluding this rule var clientRules = await SeedingRuleHelper.GetForClientIdAsync(_dataContext, existingRule.DownloadClientConfigId); var duplicate = clientRules.FirstOrDefault(r => r.Id != id && r.Name.Equals(ruleDto.Name.Trim(), StringComparison.OrdinalIgnoreCase) && r.PrivacyType == ruleDto.PrivacyType); if (duplicate is not null) { return BadRequest(new { Message = "A seeding rule with this name and privacy type already exists for this client" }); } var overlapError = GetPrivacyTypeOverlapError(ruleDto.Name.Trim(), ruleDto.PrivacyType, clientRules, excludeId: id); if (overlapError is not null) { return BadRequest(new { Message = overlapError }); } existingRule.Name = ruleDto.Name.Trim(); existingRule.PrivacyType = ruleDto.PrivacyType; existingRule.MaxRatio = ruleDto.MaxRatio; existingRule.MinSeedTime = ruleDto.MinSeedTime; existingRule.MaxSeedTime = ruleDto.MaxSeedTime; existingRule.DeleteSourceFiles = ruleDto.DeleteSourceFiles; existingRule.Validate(); await _dataContext.SaveChangesAsync(); _logger.LogInformation("Updated seeding rule: {RuleName} with ID: {RuleId}", existingRule.Name, id); return Ok(existingRule); } catch (ValidationException ex) { _logger.LogWarning("Validation failed for seeding rule update: {Message}", ex.Message); return BadRequest(new { Message = ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "Failed to update seeding rule with ID: {RuleId}", id); return StatusCode(500, new { Message = "Failed to update seeding rule", Error = ex.Message }); } finally { DataContext.Lock.Release(); } } [HttpDelete("{id}")] public async Task DeleteSeedingRule(Guid id) { await DataContext.Lock.WaitAsync(); try { var (existingRule, _) = await SeedingRuleHelper.FindByIdAsync(_dataContext, id); if (existingRule is null) { return NotFound(new { Message = $"Seeding rule with ID {id} not found" }); } RemoveRuleFromDbSet(existingRule); await _dataContext.SaveChangesAsync(); _logger.LogInformation("Deleted seeding rule: {RuleName} with ID: {RuleId}", existingRule.Name, id); return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Failed to delete seeding rule with ID: {RuleId}", id); return StatusCode(500, new { Message = "Failed to delete seeding rule", Error = ex.Message }); } finally { DataContext.Lock.Release(); } } private static string? GetPrivacyTypeOverlapError( string name, TorrentPrivacyType privacyType, IEnumerable existingRules, Guid? excludeId) { if (privacyType == TorrentPrivacyType.Both) { var hasConflict = existingRules.Any(r => r.Id != excludeId && r.Name.Equals(name, StringComparison.OrdinalIgnoreCase) && r.PrivacyType != TorrentPrivacyType.Both); return hasConflict ? "A 'Both' rule cannot coexist with a Public or Private rule for the same category" : null; } else { var hasConflict = existingRules.Any(r => r.Id != excludeId && r.Name.Equals(name, StringComparison.OrdinalIgnoreCase) && r.PrivacyType == TorrentPrivacyType.Both); return hasConflict ? "A Public or Private rule cannot coexist with a 'Both' rule for the same category" : null; } } private ISeedingRule CreateRule(DownloadClientTypeName typeName, Guid clientId, SeedingRuleRequest dto) { return typeName switch { DownloadClientTypeName.qBittorrent => new QBitSeedingRule { DownloadClientConfigId = clientId, Name = dto.Name.Trim(), PrivacyType = dto.PrivacyType, MaxRatio = dto.MaxRatio, MinSeedTime = dto.MinSeedTime, MaxSeedTime = dto.MaxSeedTime, DeleteSourceFiles = dto.DeleteSourceFiles, }, DownloadClientTypeName.Deluge => new DelugeSeedingRule { DownloadClientConfigId = clientId, Name = dto.Name.Trim(), PrivacyType = dto.PrivacyType, MaxRatio = dto.MaxRatio, MinSeedTime = dto.MinSeedTime, MaxSeedTime = dto.MaxSeedTime, DeleteSourceFiles = dto.DeleteSourceFiles, }, DownloadClientTypeName.Transmission => new TransmissionSeedingRule { DownloadClientConfigId = clientId, Name = dto.Name.Trim(), PrivacyType = dto.PrivacyType, MaxRatio = dto.MaxRatio, MinSeedTime = dto.MinSeedTime, MaxSeedTime = dto.MaxSeedTime, DeleteSourceFiles = dto.DeleteSourceFiles, }, DownloadClientTypeName.uTorrent => new UTorrentSeedingRule { DownloadClientConfigId = clientId, Name = dto.Name.Trim(), PrivacyType = dto.PrivacyType, MaxRatio = dto.MaxRatio, MinSeedTime = dto.MinSeedTime, MaxSeedTime = dto.MaxSeedTime, DeleteSourceFiles = dto.DeleteSourceFiles, }, DownloadClientTypeName.rTorrent => new RTorrentSeedingRule { DownloadClientConfigId = clientId, Name = dto.Name.Trim(), PrivacyType = dto.PrivacyType, MaxRatio = dto.MaxRatio, MinSeedTime = dto.MinSeedTime, MaxSeedTime = dto.MaxSeedTime, DeleteSourceFiles = dto.DeleteSourceFiles, }, _ => throw new ArgumentOutOfRangeException(nameof(typeName), typeName, "Unsupported download client type") }; } private void AddRuleToDbSet(ISeedingRule rule) { switch (rule) { case QBitSeedingRule qbit: _dataContext.QBitSeedingRules.Add(qbit); break; case DelugeSeedingRule deluge: _dataContext.DelugeSeedingRules.Add(deluge); break; case TransmissionSeedingRule transmission: _dataContext.TransmissionSeedingRules.Add(transmission); break; case UTorrentSeedingRule utorrent: _dataContext.UTorrentSeedingRules.Add(utorrent); break; case RTorrentSeedingRule rtorrent: _dataContext.RTorrentSeedingRules.Add(rtorrent); break; } } private void RemoveRuleFromDbSet(ISeedingRule rule) { switch (rule) { case QBitSeedingRule qbit: _dataContext.QBitSeedingRules.Remove(qbit); break; case DelugeSeedingRule deluge: _dataContext.DelugeSeedingRules.Remove(deluge); break; case TransmissionSeedingRule transmission: _dataContext.TransmissionSeedingRules.Remove(transmission); break; case UTorrentSeedingRule utorrent: _dataContext.UTorrentSeedingRules.Remove(utorrent); break; case RTorrentSeedingRule rtorrent: _dataContext.RTorrentSeedingRules.Remove(rtorrent); break; } } }