diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 76a20199..83f064c7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -44,7 +44,7 @@ jobs: - name: Start services working-directory: e2e - run: docker compose -f docker-compose.e2e.yml up -d --build + run: make up env: PACKAGES_USERNAME: ${{ github.repository_owner }} PACKAGES_PAT: ${{ env.PACKAGES_PAT }} diff --git a/README.md b/README.md index e91976cf..92f2432c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or > - Search for **custom format score upgrades** with automatic score tracking. > - Clean up downloads that have been **seeding** for a certain amount of time. > - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support). +> - Scan configured directories for **files not claimed by any active torrent**, move them to a dedicated orphaned directory, and optionally auto-purge. > - Notify on strike or download removal. > - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr. diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs index a5860b70..763aa53d 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs @@ -4,6 +4,7 @@ using Cleanuparr.Infrastructure.Features.Arr; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Auth; using Cleanuparr.Infrastructure.Features.BlacklistSync; +using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadRemover; using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; @@ -47,6 +48,9 @@ public static class ServicesDI .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/OrphanedFilesConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/OrphanedFilesConfigRequest.cs new file mode 100644 index 00000000..feae89eb --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/OrphanedFilesConfigRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests; + +public sealed record OrphanedFilesConfigRequest +{ + public bool Enabled { get; init; } + + public List ScanDirectories { get; init; } = []; + + [Required] + public string OrphanedDirectory { get; init; } = string.Empty; + + public List ExcludePatterns { get; init; } = []; + + [Range(0, int.MaxValue)] + public int MinFileAgeHours { get; init; } = 24; + + [Range(1, int.MaxValue)] + public int? PurgeAfterHours { get; init; } +} diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/UnlinkedConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/UnlinkedConfigRequest.cs index ea09fea8..a1549790 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/UnlinkedConfigRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Contracts/Requests/UnlinkedConfigRequest.cs @@ -11,8 +11,4 @@ public sealed record UnlinkedConfigRequest public List IgnoredRootDirs { get; init; } = []; public List Categories { get; init; } = []; - - public string? DownloadDirectorySource { get; init; } - - public string? DownloadDirectoryTarget { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DownloadCleanerConfigController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DownloadCleanerConfigController.cs index 96039476..005d1e83 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DownloadCleanerConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DownloadCleanerConfigController.cs @@ -53,6 +53,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase var allUTorrentRules = await _dataContext.UTorrentSeedingRules.AsNoTracking().ToListAsync(); var allRTorrentRules = await _dataContext.RTorrentSeedingRules.AsNoTracking().ToListAsync(); var allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync(); + var allOrphanedFilesConfigs = await _dataContext.OrphanedFilesConfigs.AsNoTracking().ToListAsync(); var clients = new List(); @@ -61,6 +62,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase var seedingRules = SeedingRuleHelper.FilterForClient( client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules); var unlinkedConfig = allUnlinkedConfigs.FirstOrDefault(u => u.DownloadClientConfigId == client.Id); + var orphanedFilesConfig = allOrphanedFilesConfigs.FirstOrDefault(o => o.DownloadClientConfigId == client.Id); clients.Add(new { @@ -91,8 +93,17 @@ public sealed class DownloadCleanerConfigController : ControllerBase useTag = unlinkedConfig.UseTag, ignoredRootDirs = unlinkedConfig.IgnoredRootDirs, categories = unlinkedConfig.Categories, - downloadDirectorySource = unlinkedConfig.DownloadDirectorySource, - downloadDirectoryTarget = unlinkedConfig.DownloadDirectoryTarget, + } + : null, + orphanedFilesConfig = orphanedFilesConfig is not null + ? new + { + enabled = orphanedFilesConfig.Enabled, + scanDirectories = orphanedFilesConfig.ScanDirectories, + orphanedDirectory = orphanedFilesConfig.OrphanedDirectory, + excludePatterns = orphanedFilesConfig.ExcludePatterns, + minFileAgeHours = orphanedFilesConfig.MinFileAgeHours, + purgeAfterHours = orphanedFilesConfig.PurgeAfterHours, } : null, }); diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/OrphanedFilesConfigController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/OrphanedFilesConfigController.cs new file mode 100644 index 00000000..f6d897b2 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/OrphanedFilesConfigController.cs @@ -0,0 +1,123 @@ +using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers; + +[ApiController] +[Route("api/orphaned-files-config")] +[Authorize] +public sealed class OrphanedFilesConfigController : ControllerBase +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + + public OrphanedFilesConfigController( + ILogger logger, + DataContext dataContext) + { + _logger = logger; + _dataContext = dataContext; + } + + [HttpGet("{downloadClientId}")] + public async Task GetClientConfig(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 config = await _dataContext.OrphanedFilesConfigs + .AsNoTracking() + .FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId); + + return Ok(config); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("{downloadClientId}")] + public async Task UpdateClientConfig(Guid downloadClientId, [FromBody] OrphanedFilesConfigRequest dto) + { + 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 existing = await _dataContext.OrphanedFilesConfigs + .FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId); + + var candidate = (existing ?? new OrphanedFilesConfig { DownloadClientConfigId = downloadClientId }) with + { + Enabled = dto.Enabled, + ScanDirectories = dto.ScanDirectories, + OrphanedDirectory = dto.OrphanedDirectory, + ExcludePatterns = dto.ExcludePatterns, + MinFileAgeHours = dto.MinFileAgeHours, + PurgeAfterHours = dto.PurgeAfterHours, + }; + + var siblings = await _dataContext.OrphanedFilesConfigs + .AsNoTracking() + .Where(c => c.DownloadClientConfigId != downloadClientId) + .ToListAsync(); + + var otherDownloadClients = await _dataContext.DownloadClients + .AsNoTracking() + .Where(c => c.Id != downloadClientId) + .ToListAsync(); + + candidate.Validate(siblings, otherDownloadClients); + + if (existing is null) + { + _dataContext.OrphanedFilesConfigs.Add(candidate); + } + else + { + existing.Enabled = candidate.Enabled; + existing.ScanDirectories = candidate.ScanDirectories; + existing.OrphanedDirectory = candidate.OrphanedDirectory; + existing.ExcludePatterns = candidate.ExcludePatterns; + existing.MinFileAgeHours = candidate.MinFileAgeHours; + existing.PurgeAfterHours = candidate.PurgeAfterHours; + } + + await _dataContext.SaveChangesAsync(); + + _logger.LogInformation("Updated orphaned files client config for client {ClientId}", downloadClientId); + + return Ok(existing ?? candidate); + } + finally + { + DataContext.Lock.Release(); + } + } +} diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs index 0344560a..39345aef 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs @@ -5,7 +5,6 @@ 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; @@ -46,11 +45,6 @@ public class UnlinkedConfigController : ControllerBase return Ok(config); } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve unlinked config for client {ClientId}", downloadClientId); - return StatusCode(500, new { Message = "Failed to retrieve unlinked config", Error = ex.Message }); - } finally { DataContext.Lock.Release(); @@ -94,8 +88,6 @@ public class UnlinkedConfigController : ControllerBase existing.UseTag = dto.UseTag; existing.IgnoredRootDirs = dto.IgnoredRootDirs; existing.Categories = dto.Categories; - existing.DownloadDirectorySource = dto.DownloadDirectorySource; - existing.DownloadDirectoryTarget = dto.DownloadDirectoryTarget; existing.Validate(); @@ -105,16 +97,6 @@ public class UnlinkedConfigController : ControllerBase return Ok(existing); } - catch (ValidationException ex) - { - _logger.LogWarning("Validation failed for unlinked config update: {Message}", ex.Message); - return BadRequest(new { Message = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update unlinked config for client {ClientId}", downloadClientId); - return StatusCode(500, new { Message = "Failed to update unlinked config", Error = ex.Message }); - } finally { DataContext.Lock.Release(); diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs index ef871254..eeaf67ba 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs @@ -27,6 +27,10 @@ public sealed record CreateDownloadClientRequest public string? ExternalUrl { get; init; } + public string? DownloadDirectorySource { get; init; } + + public string? DownloadDirectoryTarget { get; init; } + public void Validate() { if (string.IsNullOrWhiteSpace(Name)) @@ -66,5 +70,7 @@ public sealed record CreateDownloadClientRequest Password = Password, UrlBase = UrlBase, ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null, + DownloadDirectorySource = DownloadDirectorySource, + DownloadDirectoryTarget = DownloadDirectoryTarget, }; } diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs index 3b07c4a4..b82e1ddf 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs @@ -27,6 +27,10 @@ public sealed record UpdateDownloadClientRequest public string? ExternalUrl { get; init; } + public string? DownloadDirectorySource { get; init; } + + public string? DownloadDirectoryTarget { get; init; } + public void Validate() { if (string.IsNullOrWhiteSpace(Name)) @@ -61,5 +65,7 @@ public sealed record UpdateDownloadClientRequest Password = Password.IsPlaceholder() ? existing.Password : Password, UrlBase = UrlBase, ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null, + DownloadDirectorySource = DownloadDirectorySource, + DownloadDirectoryTarget = DownloadDirectoryTarget, }; } diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs index 8db84258..a67b3093 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs @@ -66,6 +66,7 @@ public sealed class DownloadClientController : ControllerBase newClient.Validate(); var clientConfig = newClient.ToEntity(); + clientConfig.Validate(); _dataContext.DownloadClients.Add(clientConfig); await _dataContext.SaveChangesAsync(); @@ -100,6 +101,7 @@ public sealed class DownloadClientController : ControllerBase } var clientToPersist = updatedClient.ApplyTo(existingClient); + clientToPersist.Validate(); _dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist); await _dataContext.SaveChangesAsync(); diff --git a/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs b/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs index 0aecb1d3..41a3f274 100644 --- a/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs +++ b/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs @@ -61,8 +61,8 @@ public sealed class BlacklistSynchronizer : IHandler string currentHash = ComputeHash(excludedFileNames); - await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames); - await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash); + await _dryRunInterceptor.InterceptAsync(() => SyncBlacklist(currentHash, excludedFileNames)); + await _dryRunInterceptor.InterceptAsync(() => RemoveOldSyncDataAsync(currentHash)); _logger.LogDebug("Blacklist synchronization completed"); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/BlacklistSync/BlacklistSynchronizerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/BlacklistSync/BlacklistSynchronizerTests.cs index 8d7c09d5..7d0d8c30 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/BlacklistSync/BlacklistSynchronizerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/BlacklistSync/BlacklistSynchronizerTests.cs @@ -47,19 +47,8 @@ public class BlacklistSynchronizerTests : IDisposable _downloadServiceFactory = Substitute.For(); _dryRunInterceptor = Substitute.For(); - // Setup interceptor to execute the action with params using DynamicInvoke - _dryRunInterceptor.InterceptAsync(default!, default!) - .ReturnsForAnyArgs(ci => - { - var action = ci.ArgAt(0); - var parameters = ci.ArgAt(1); - var result = action.DynamicInvoke(parameters); - if (result is Task task) - { - return task; - } - return Task.CompletedTask; - }); + _dryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(ci => ci.ArgAt>(0).Invoke()); // Setup FakeHttpMessageHandler for FileReader _httpMessageHandler = new FakeHttpMessageHandler(); @@ -240,9 +229,8 @@ public class BlacklistSynchronizerTests : IDisposable // Act await _synchronizer.ExecuteAsync(); - // Assert - Verify interceptor was called (with Delegate, not Func) await _dryRunInterceptor.Received() - .InterceptAsync(Arg.Any(), Arg.Any()); + .InterceptAsync(Arg.Any>(), Arg.Any()); } #endregion diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceFixture.cs index a7931a62..ee514295 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceFixture.cs @@ -45,13 +45,8 @@ public class DelugeServiceFixture : IDisposable ClientWrapper = Substitute.For(); DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); } public DelugeService CreateSut(DownloadClientConfig? config = null) @@ -107,13 +102,8 @@ public class DelugeServiceFixture : IDisposable ClientWrapper = Substitute.For(); DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); } public void Dispose() diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceFixture.cs index 4dbe3790..f0acaf95 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceFixture.cs @@ -48,13 +48,8 @@ public class QBitServiceFixture : IDisposable // Setup default behavior for DryRunInterceptor to execute actions directly DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); SetupSeedingRuleEvaluator(); } @@ -114,13 +109,8 @@ public class QBitServiceFixture : IDisposable // Re-setup default DryRunInterceptor behavior DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); SetupSeedingRuleEvaluator(); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceFixture.cs index 14d646c2..20ca84f4 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceFixture.cs @@ -46,13 +46,8 @@ public class RTorrentServiceFixture : IDisposable ClientWrapper = Substitute.For(); DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); } public RTorrentService CreateSut(DownloadClientConfig? config = null) @@ -108,13 +103,8 @@ public class RTorrentServiceFixture : IDisposable ClientWrapper = Substitute.For(); DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); } public void Dispose() diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceFixture.cs index 21754e06..cab57721 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceFixture.cs @@ -45,13 +45,8 @@ public class TransmissionServiceFixture : IDisposable ClientWrapper = Substitute.For(); DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); } public TransmissionService CreateSut(DownloadClientConfig? config = null) @@ -107,13 +102,8 @@ public class TransmissionServiceFixture : IDisposable ClientWrapper = Substitute.For(); DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); } public void Dispose() diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceFixture.cs index b8a48637..c1b5f742 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceFixture.cs @@ -45,13 +45,8 @@ public class UTorrentServiceFixture : IDisposable ClientWrapper = Substitute.For(); DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); } public UTorrentService CreateSut(DownloadClientConfig? config = null) @@ -107,13 +102,8 @@ public class UTorrentServiceFixture : IDisposable ClientWrapper = Substitute.For(); DryRunInterceptor - .InterceptAsync(default!, default!) - .ReturnsForAnyArgs(callInfo => - { - var action = callInfo.ArgAt(0); - var parameters = callInfo.ArgAt(1); - return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); - }); + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); } public void Dispose() diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs index 79acf5af..7cc99f6f 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs @@ -67,9 +67,8 @@ public class QueueItemRemoverTests : IDisposable var dryRunInterceptor = Substitute.For(); dryRunInterceptor.IsDryRunEnabled().Returns(false); - // Setup interceptor for other uses (e.g., ArrClient deletion) dryRunInterceptor - .InterceptAsync(default!, default!) + .InterceptAsync(Arg.Any>(), Arg.Any()) .ReturnsForAnyArgs(Task.CompletedTask); _eventPublisher = new EventPublisher( diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs new file mode 100644 index 00000000..57c14108 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs @@ -0,0 +1,241 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.Jobs; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs; + +[Collection(JobHandlerCollection.Name)] +public sealed class DownloadCleanerOrphanedFilesTests : IDisposable +{ + private readonly JobHandlerFixture _fixture; + private readonly ILogger _logger; + private readonly string _tempRoot; + + public DownloadCleanerOrphanedFilesTests(JobHandlerFixture fixture) + { + _fixture = fixture; + _fixture.RecreateDataContext(); + _fixture.ResetMocks(); + _logger = _fixture.CreateLogger(); + _tempRoot = Path.Combine(Path.GetTempPath(), "cleanuparr-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + _fixture.DryRunInterceptor.When(x => x.Intercept(Arg.Any(), Arg.Any())) + .Do(ci => ((Action)ci.Args()[0]).Invoke()); + } + + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + GC.SuppressFinalize(this); + } + + private DownloadCleaner CreateSut() => new( + _logger, + _fixture.DataContext, + _fixture.Cache, + _fixture.MessageBus, + _fixture.ArrClientFactory, + _fixture.ArrQueueIterator, + _fixture.DownloadServiceFactory, + _fixture.EventPublisher, + _fixture.TimeProvider, + _fixture.SeedingRulesService, + _fixture.UnlinkedService, + _fixture.OrphanedFilesService); + + private async Task ExecuteWithTimeAdvance(DownloadCleaner sut) + { + var task = sut.ExecuteAsync(); + _fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10)); + await task; + } + + private static ITorrentItemWrapper MakeTorrent(string name, string savePath) + { + var t = Substitute.For(); + t.Name.Returns(name); + t.SavePath.Returns(savePath); + return t; + } + + private IDownloadService SetupDownloadService(DownloadClientConfig clientConfig, List torrents) + { + var svc = Substitute.For(); + svc.ClientConfig.Returns(clientConfig); + svc.LoginAsync().Returns(Task.CompletedTask); + svc.GetSeedingDownloads().Returns([]); + svc.GetAllTorrentsLite().Returns(torrents); + _fixture.DownloadServiceFactory.GetDownloadService(clientConfig).Returns(svc); + return svc; + } + + [Fact] + public async Task OrphanedFiles_NoEnabledClientConfigs_SkipsScan() + { + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + var dbClient = _fixture.DataContext.DownloadClients.First(); + SetupDownloadService(dbClient, []); + + var sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + _fixture.OrphanedFilesLogger.ReceivedLogContaining(LogLevel.Debug, "No orphaned files settings have been configured"); + } + + [Fact] + public async Task OrphanedFiles_OrphanedEntry_IsMovedWhenOrphanedDirectorySet() + { + var scanDir = Path.Combine(_tempRoot, "downloads"); + var orphanedDir = Path.Combine(_tempRoot, "orphaned"); + Directory.CreateDirectory(scanDir); + var orphanedFile = Path.Combine(scanDir, "orphan.mkv"); + File.WriteAllText(orphanedFile, "x"); + + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + var dbClient = _fixture.DataContext.DownloadClients.First(); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, dbClient, + scanDirectories: [scanDir], + orphanedDirectory: orphanedDir); + + SetupDownloadService(dbClient, []); + + var sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + File.Exists(orphanedFile).ShouldBeFalse(); + Directory.GetFiles(orphanedDir).ShouldContain(f => Path.GetFileName(f) == "orphan.mkv"); + } + + [Fact] + public async Task OrphanedFiles_TorrentClaimedEntry_IsNotMoved() + { + var scanDir = Path.Combine(_tempRoot, "downloads"); + Directory.CreateDirectory(scanDir); + var claimedDir = Path.Combine(scanDir, "claimed-show"); + Directory.CreateDirectory(claimedDir); + + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + var dbClient = _fixture.DataContext.DownloadClients.First(); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, dbClient, + scanDirectories: [scanDir], + orphanedDirectory: Path.Combine(_tempRoot, "orphaned")); + + var torrent = MakeTorrent("claimed-show", scanDir); + SetupDownloadService(dbClient, [torrent]); + + var sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + Directory.Exists(claimedDir).ShouldBeTrue(); + } + + [Fact] + public async Task OrphanedFiles_EntryMatchingExcludePattern_IsNotMoved() + { + var scanDir = Path.Combine(_tempRoot, "downloads"); + Directory.CreateDirectory(scanDir); + var skipped = Path.Combine(scanDir, "stuff.nfo"); + File.WriteAllText(skipped, "metadata"); + + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + var dbClient = _fixture.DataContext.DownloadClients.First(); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, dbClient, + scanDirectories: [scanDir], + orphanedDirectory: Path.Combine(_tempRoot, "orphaned"), + excludePatterns: ["*.nfo"]); + + SetupDownloadService(dbClient, []); + + var sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + File.Exists(skipped).ShouldBeTrue(); + } + + [Fact] + public async Task OrphanedFiles_EntryYoungerThanMinFileAgeHours_IsNotMoved() + { + var scanDir = Path.Combine(_tempRoot, "downloads"); + Directory.CreateDirectory(scanDir); + var fresh = Path.Combine(scanDir, "fresh.mkv"); + File.WriteAllText(fresh, "x"); + + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + var dbClient = _fixture.DataContext.DownloadClients.First(); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, dbClient, + scanDirectories: [scanDir], + orphanedDirectory: Path.Combine(_tempRoot, "orphaned"), + minFileAgeHours: 1); + + SetupDownloadService(dbClient, []); + + var sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + File.Exists(fresh).ShouldBeTrue(); + } + + [Fact] + public async Task OrphanedFiles_NameCollisionInOrphanedDirectory_AppendsTimestampSuffix() + { + var scanDir = Path.Combine(_tempRoot, "downloads"); + var orphanedDir = Path.Combine(_tempRoot, "orphaned"); + Directory.CreateDirectory(scanDir); + Directory.CreateDirectory(orphanedDir); + File.WriteAllText(Path.Combine(orphanedDir, "dupe.mkv"), "existing"); + File.WriteAllText(Path.Combine(scanDir, "dupe.mkv"), "new"); + + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + var dbClient = _fixture.DataContext.DownloadClients.First(); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, dbClient, + scanDirectories: [scanDir], + orphanedDirectory: orphanedDir); + + SetupDownloadService(dbClient, []); + + var sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + var files = Directory.GetFiles(orphanedDir).Select(Path.GetFileName).ToList(); + files.ShouldContain("dupe.mkv"); + files.Count(f => f!.StartsWith("dupe.mkv_")).ShouldBe(1); + } + + [Fact] + public async Task OrphanedFiles_OrphanedDirectorySelfReference_IsNeverFlagged() + { + var scanDir = Path.Combine(_tempRoot, "downloads"); + var orphanedDir = Path.Combine(scanDir, "orphaned"); + Directory.CreateDirectory(orphanedDir); + + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + var dbClient = _fixture.DataContext.DownloadClients.First(); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, dbClient, + scanDirectories: [scanDir], + orphanedDirectory: orphanedDir); + + SetupDownloadService(dbClient, []); + + var sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + Directory.Exists(orphanedDir).ShouldBeTrue(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs index 195c407d..c9670636 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs @@ -51,7 +51,9 @@ public class DownloadCleanerTests : IDisposable _fixture.DownloadServiceFactory, _fixture.EventPublisher, _fixture.TimeProvider, - _fixture.HardLinkFileService + _fixture.SeedingRulesService, + _fixture.UnlinkedService, + _fixture.OrphanedFilesService ); } @@ -530,7 +532,7 @@ public class DownloadCleanerTests : IDisposable await ExecuteWithTimeAdvance(sut); // Assert - _logger.ReceivedLogContaining(LogLevel.Information, "Evaluating"); + _fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Information, "Evaluating"); } #endregion @@ -579,7 +581,7 @@ public class DownloadCleanerTests : IDisposable await ExecuteWithTimeAdvance(sut); // Assert - _logger.ReceivedLogContaining(LogLevel.Information, "Evaluating"); + _fixture.SeedingRulesLogger.ReceivedLogContaining(LogLevel.Information, "Evaluating"); } #endregion @@ -728,7 +730,7 @@ public class DownloadCleanerTests : IDisposable await ExecuteWithTimeAdvance(sut); // Assert - _logger.ReceivedLogContaining(LogLevel.Error, "Failed to process unlinked downloads for"); + _fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Error, "Failed to process unlinked downloads for"); } [Fact] @@ -770,7 +772,7 @@ public class DownloadCleanerTests : IDisposable await ExecuteWithTimeAdvance(sut); // Assert - _logger.ReceivedLogContaining(LogLevel.Error, "Failed to create category"); + _fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Error, "Failed to create category"); } [Fact] @@ -815,7 +817,7 @@ public class DownloadCleanerTests : IDisposable await ExecuteWithTimeAdvance(sut); // Assert - _logger.ReceivedLogContaining(LogLevel.Error, "Failed to process unlinked downloads for"); + _fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Error, "Failed to process unlinked downloads for"); } [Fact] @@ -854,7 +856,7 @@ public class DownloadCleanerTests : IDisposable await ExecuteWithTimeAdvance(sut); // Assert - _logger.ReceivedLogContaining(LogLevel.Error, "Failed to clean downloads for"); + _fixture.SeedingRulesLogger.ReceivedLogContaining(LogLevel.Error, "Failed to clean downloads for"); } [Fact] @@ -899,7 +901,7 @@ public class DownloadCleanerTests : IDisposable await ExecuteWithTimeAdvance(sut); // Assert - _logger.ReceivedLogContaining(LogLevel.Error, "Failed to clean downloads for"); + _fixture.SeedingRulesLogger.ReceivedLogContaining(LogLevel.Error, "Failed to clean downloads for"); } [Fact] @@ -1064,7 +1066,7 @@ public class DownloadCleanerTests : IDisposable await ExecuteWithTimeAdvance(sut); // Assert - should log warning about no categories - _logger.ReceivedLogContaining(LogLevel.Warning, "no categories are configured"); + _fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Warning, "no categories are configured"); } #endregion diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs index 81005e67..5dada293 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs @@ -47,7 +47,9 @@ public class DownloadCleanerIntegrationTests : IDisposable _fixture.DownloadServiceFactory, _fixture.EventPublisher, _fixture.TimeProvider, - _fixture.HardLinkFileService); + _fixture.SeedingRulesService, + _fixture.UnlinkedService, + _fixture.OrphanedFilesService); } /// diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs index 3b641c84..6c2ba905 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs @@ -5,11 +5,13 @@ using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadRemover; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; using Cleanuparr.Infrastructure.Features.Files; using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Features.Jobs; using Cleanuparr.Infrastructure.Features.MalwareBlocker; using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Hubs; @@ -56,6 +58,9 @@ public class IntegrationTestFixture : IDisposable public IDryRunInterceptor DryRunInterceptor { get; private set; } public IEventPublisher EventPublisherInterface { get; private set; } = null!; public IHubContext HubContext { get; private set; } + public ISeedingRulesCleanupService SeedingRulesService { get; private set; } = null!; + public IUnlinkedDownloadsService UnlinkedService { get; private set; } = null!; + public IOrphanedFilesCleanupService OrphanedFilesService { get; private set; } = null!; // State public Guid JobRunId { get; private set; } @@ -91,7 +96,7 @@ public class IntegrationTestFixture : IDisposable // DryRunInterceptor returns false (not dry run) by default DryRunInterceptor.IsDryRunEnabled().Returns(false); - DryRunInterceptor.InterceptAsync(default!, default!).ReturnsForAnyArgs(Task.CompletedTask); + DryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()).ReturnsForAnyArgs(Task.CompletedTask); // Capture messages published to IBus (generic Publish overloads) MessageBus.Publish(default(QueueItemRemoveRequest)!, default) @@ -133,6 +138,19 @@ public class IntegrationTestFixture : IDisposable EventPublisher, EventsContext, DataContext); + + SeedingRulesService = new SeedingRulesCleanupService( + Substitute.For>(), + DataContext); + UnlinkedService = new UnlinkedDownloadsService( + Substitute.For>(), + DataContext, + HardLinkFileService); + OrphanedFilesService = new OrphanedFilesCleanupService( + Substitute.For>(), + DataContext, + TimeProvider, + DryRunInterceptor); } /// diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs index c3a6772b..72b0c663 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs @@ -1,10 +1,12 @@ using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.Files; using Cleanuparr.Infrastructure.Features.Jobs; using Cleanuparr.Infrastructure.Features.MalwareBlocker; +using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence; using MassTransit; using Microsoft.Extensions.Caching.Memory; @@ -29,7 +31,14 @@ public class JobHandlerFixture : IDisposable public IEventPublisher EventPublisher { get; private set; } public IBlocklistProvider BlocklistProvider { get; private set; } public IHardLinkFileService HardLinkFileService { get; private set; } + public IDryRunInterceptor DryRunInterceptor { get; private set; } public FakeTimeProvider TimeProvider { get; private set; } + public ISeedingRulesCleanupService SeedingRulesService { get; private set; } + public IUnlinkedDownloadsService UnlinkedService { get; private set; } + public IOrphanedFilesCleanupService OrphanedFilesService { get; private set; } + public ILogger SeedingRulesLogger { get; private set; } + public ILogger UnlinkedLogger { get; private set; } + public ILogger OrphanedFilesLogger { get; private set; } public JobHandlerFixture() { @@ -43,7 +52,9 @@ public class JobHandlerFixture : IDisposable EventPublisher = Substitute.For(); BlocklistProvider = Substitute.For(); HardLinkFileService = Substitute.For(); + DryRunInterceptor = Substitute.For(); TimeProvider = new FakeTimeProvider(); + RecreateCleanupServices(); // Setup default behaviors SetupDefaultBehaviors(); @@ -52,6 +63,25 @@ public class JobHandlerFixture : IDisposable ContextProvider.SetJobRunId(Guid.NewGuid()); } + /// + /// Builds real cleanup services bound to the current DataContext/mocks. + /// Tests can replace any of them with substitutes before constructing + /// the SUT. + /// + private void RecreateCleanupServices() + { + SeedingRulesLogger = Substitute.For>(); + UnlinkedLogger = Substitute.For>(); + OrphanedFilesLogger = Substitute.For>(); + SeedingRulesService = new SeedingRulesCleanupService(SeedingRulesLogger, DataContext); + UnlinkedService = new UnlinkedDownloadsService(UnlinkedLogger, DataContext, HardLinkFileService); + OrphanedFilesService = new OrphanedFilesCleanupService( + OrphanedFilesLogger, + DataContext, + TimeProvider, + DryRunInterceptor); + } + private void SetupDefaultBehaviors() { // EventPublisher methods return completed task by default @@ -105,6 +135,7 @@ public class JobHandlerFixture : IDisposable { DataContext?.Dispose(); DataContext = TestDataContextFactory.Create(seedData); + RecreateCleanupServices(); return DataContext; } @@ -119,8 +150,10 @@ public class JobHandlerFixture : IDisposable EventPublisher = Substitute.For(); BlocklistProvider = Substitute.For(); HardLinkFileService = Substitute.For(); + DryRunInterceptor = Substitute.For(); Cache.Clear(); TimeProvider = new FakeTimeProvider(); + RecreateCleanupServices(); SetupDefaultBehaviors(); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs index 02e76322..35ee90ca 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs @@ -368,4 +368,32 @@ public static class TestDataContextFactory return config; } + + public static OrphanedFilesConfig AddOrphanedFilesConfig( + DataContext context, + DownloadClientConfig downloadClient, + bool enabled = true, + List? scanDirectories = null, + string orphanedDirectory = "", + List? excludePatterns = null, + int minFileAgeHours = 0, + int? purgeAfterHours = null) + { + var config = new OrphanedFilesConfig + { + Id = Guid.NewGuid(), + DownloadClientConfigId = downloadClient.Id, + Enabled = enabled, + ScanDirectories = scanDirectories ?? [], + OrphanedDirectory = orphanedDirectory, + ExcludePatterns = excludePatterns ?? [], + MinFileAgeHours = minFileAgeHours, + PurgeAfterHours = purgeAfterHours, + }; + + context.OrphanedFilesConfigs.Add(config); + context.SaveChanges(); + + return config; + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs index 45bb1d59..ee46970d 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs @@ -30,14 +30,8 @@ public class NotificationPublisherTests _configService = Substitute.For(); _providerFactory = Substitute.For(); - // Setup dry run interceptor to call through - _dryRunInterceptor.InterceptAsync(default!, default!) - .ReturnsForAnyArgs(ci => - { - var action = ci.ArgAt(0); - var parameters = ci.ArgAt(1); - return action.DynamicInvoke(parameters) as Task ?? Task.CompletedTask; - }); + _dryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(ci => ci.ArgAt>(0).Invoke()); _publisher = new NotificationPublisher( _logger, @@ -504,8 +498,8 @@ public class NotificationPublisherTests // Assert await _dryRunInterceptor.Received(1).InterceptAsync( - Arg.Any>(), - Arg.Any<(NotificationEventType, NotificationContext)>()); + Arg.Any>(), + Arg.Any()); } #endregion @@ -517,7 +511,7 @@ public class NotificationPublisherTests { // Arrange // Setup dry run interceptor to throw when called - _dryRunInterceptor.InterceptAsync(Arg.Any(), Arg.Any()) + _dryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()) .ThrowsAsync(new Exception("Interceptor failed")); SetupContext(); @@ -533,7 +527,7 @@ public class NotificationPublisherTests public async Task NotifyQueueItemDeleted_WhenExceptionOccurs_LogsError() { // Arrange - _dryRunInterceptor.InterceptAsync(Arg.Any(), Arg.Any()) + _dryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()) .ThrowsAsync(new Exception("Error")); SetupContext(); @@ -549,7 +543,7 @@ public class NotificationPublisherTests public async Task NotifyDownloadCleaned_WhenExceptionOccurs_LogsError() { // Arrange - _dryRunInterceptor.InterceptAsync(Arg.Any(), Arg.Any()) + _dryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()) .ThrowsAsync(new Exception("Error")); SetupDownloadCleanerContext(); @@ -565,7 +559,7 @@ public class NotificationPublisherTests public async Task NotifyCategoryChanged_WhenExceptionOccurs_LogsError() { // Arrange - _dryRunInterceptor.InterceptAsync(Arg.Any(), Arg.Any()) + _dryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()) .ThrowsAsync(new Exception("Error")); SetupDownloadCleanerContext(); @@ -625,7 +619,7 @@ public class NotificationPublisherTests public async Task NotifySearchItemGrabbed_WhenExceptionOccurs_LogsError() { // Arrange - _dryRunInterceptor.InterceptAsync(Arg.Any(), Arg.Any()) + _dryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()) .ThrowsAsync(new Exception("Error")); // Act diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs index 27a9c54b..d111a987 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs @@ -176,7 +176,7 @@ public abstract class ArrClient : IArrClient using HttpRequestMessage request = new(HttpMethod.Delete, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); + HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); response?.Dispose(); string logMessage; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs index 41f6d413..ea9a96ce 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs @@ -66,7 +66,7 @@ public class LidarrClient : ArrClient, ILidarrClient try { - HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); + HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); response?.Dispose(); _logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext)); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs index 551e835f..8787fc34 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs @@ -72,7 +72,7 @@ public class RadarrClient : ArrClient, IRadarrClient try { - HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); + HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); if (response is null) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs index b14eda46..0b985cc3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs @@ -72,7 +72,7 @@ public class ReadarrClient : ArrClient, IReadarrClient try { - HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); + HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); response?.Dispose(); _logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext)); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs index 58350c6f..c46fedd6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs @@ -70,7 +70,7 @@ public class SonarrClient : ArrClient, ISonarrClient try { - HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); + HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); if (response is not null) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs index 9d7e6310..eb8e8457 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs @@ -68,7 +68,7 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client try { - HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); + HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); response?.Dispose(); _logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext)); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs index 736af098..bdd383d3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs @@ -73,7 +73,7 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client try { - HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); + HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); response?.Dispose(); _logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext)); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs b/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs index 906c49b3..15f4c90b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs @@ -61,8 +61,8 @@ public sealed class BlacklistSynchronizer : IHandler string currentHash = ComputeHash(excludedFileNames); - await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames); - await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash); + await _dryRunInterceptor.InterceptAsync(() => SyncBlacklist(currentHash, excludedFileNames)); + await _dryRunInterceptor.InterceptAsync(() => RemoveOldSyncDataAsync(currentHash)); _logger.LogDebug("Blacklist synchronization completed"); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IOrphanedFilesCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IOrphanedFilesCleanupService.cs new file mode 100644 index 00000000..c50dd5a3 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IOrphanedFilesCleanupService.cs @@ -0,0 +1,18 @@ +using Cleanuparr.Infrastructure.Features.DownloadClient; + +namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; + +/// +/// Scans configured directories for files that aren't claimed by any active +/// torrent and moves them to a dedicated orphaned directory. Optionally +/// purges old entries from the orphaned directory. +/// +public interface IOrphanedFilesCleanupService +{ + /// + /// Processes orphaned files for every enabled per-client configuration. + /// Claims are computed across all download clients to stay safe with + /// cross-seeded torrents. + /// + Task ProcessAsync(IReadOnlyList downloadServices, CancellationToken cancellationToken); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/ISeedingRulesCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/ISeedingRulesCleanupService.cs new file mode 100644 index 00000000..4e47efd0 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/ISeedingRulesCleanupService.cs @@ -0,0 +1,15 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Infrastructure.Features.DownloadClient; + +namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; + +/// +/// Loads and applies per-client seeding rules to clean completed downloads. +/// +public interface ISeedingRulesCleanupService +{ + /// + /// Evaluates the seeding rules against the client's downloads and removes those that match. + /// + Task CleanAsync(IDownloadService downloadService, List clientDownloads); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IUnlinkedDownloadsService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IUnlinkedDownloadsService.cs new file mode 100644 index 00000000..811b4862 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IUnlinkedDownloadsService.cs @@ -0,0 +1,17 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Infrastructure.Features.DownloadClient; + +namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; + +/// +/// Handles downloads that have lost their hard links by moving them to a +/// dedicated category or tag so they can be cleaned up separately. +/// +public interface IUnlinkedDownloadsService +{ + /// + /// Re-categorises downloads with no hard links according to the supplied + /// configuration. + /// + Task ProcessAsync(IDownloadService downloadService, List clientDownloads); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/OrphanedFilesCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/OrphanedFilesCleanupService.cs new file mode 100644 index 00000000..4b560d8b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/OrphanedFilesCleanupService.cs @@ -0,0 +1,311 @@ +using System.IO.Enumeration; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Cleanuparr.Shared.Helpers; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; + +/// +public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly TimeProvider _timeProvider; + private readonly IDryRunInterceptor _dryRunInterceptor; + + public OrphanedFilesCleanupService( + ILogger logger, + DataContext dataContext, + TimeProvider timeProvider, + IDryRunInterceptor dryRunInterceptor) + { + _logger = logger; + _dataContext = dataContext; + _timeProvider = timeProvider; + _dryRunInterceptor = dryRunInterceptor; + } + + public async Task ProcessAsync(IReadOnlyList downloadServices, CancellationToken cancellationToken) + { + HashSet activeClientIds = downloadServices.Select(s => s.ClientConfig.Id).ToHashSet(); + + if (activeClientIds.Count is 0) + { + _logger.LogWarning("Skipping orphaned-files scan because no download services are available"); + return; + } + + List orphanedFilesConfigs = await _dataContext.OrphanedFilesConfigs + .AsNoTracking() + .Include(x => x.DownloadClientConfig) + .Where(x => x.Enabled + && x.DownloadClientConfig.Enabled + && activeClientIds.Contains(x.DownloadClientConfigId)) + .ToListAsync(cancellationToken); + + if (orphanedFilesConfigs.Count is 0) + { + _logger.LogDebug("No orphaned files settings have been configured"); + return; + } + + // Build set of all content paths claimed by active torrents across ALL download clients + // to avoid false positives from cross-seeded clients. + HashSet claimedPaths = new(StringComparer.OrdinalIgnoreCase); + + foreach (IDownloadService downloadService in downloadServices) + { + await AddClaimedPathsAsync(downloadService, claimedPaths); + } + + _logger.LogDebug("{count} claimed paths across all clients", claimedPaths.Count); + + foreach (OrphanedFilesConfig clientConfig in orphanedFilesConfigs) + { + if (clientConfig.ScanDirectories.Count is 0) + { + _logger.LogWarning("skip | no scan directories configured for client {name}", clientConfig.DownloadClientConfig.Name); + continue; + } + + string normalizedOrphanedDir = Path.GetFullPath(clientConfig.OrphanedDirectory) + .TrimEnd(Path.DirectorySeparatorChar); + + foreach (string scanDir in clientConfig.ScanDirectories) + { + if (!Directory.Exists(scanDir)) + { + _logger.LogWarning("Scan directory does not exist: {dir}", scanDir); + continue; + } + + _logger.LogDebug("Scanning {dir}", scanDir); + + try + { + ProcessDirectory(scanDir, claimedPaths, clientConfig, normalizedOrphanedDir, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error scanning {dir} for client {name}", scanDir, clientConfig.DownloadClientConfig.Name); + } + } + + PurgeOrphanedDirectory(clientConfig, cancellationToken); + } + } + + private async Task AddClaimedPathsAsync(IDownloadService downloadService, HashSet claimedPaths) + { + var downloadClient = downloadService.ClientConfig; + try + { + var torrents = await downloadService.GetAllTorrentsLite(); + + foreach (var torrent in torrents) + { + if (string.IsNullOrEmpty(torrent.SavePath)) + { + continue; + } + + string remappedSavePath = PathHelper.NormalizeAndRemap( + torrent.SavePath, + downloadClient.DownloadDirectorySource, + downloadClient.DownloadDirectoryTarget + ).TrimEnd(Path.DirectorySeparatorChar); + + claimedPaths.Add(remappedSavePath); + + if (string.IsNullOrEmpty(torrent.Name)) + { + continue; + } + + string contentPath = PathHelper.NormalizeAndRemap( + Path.Combine(torrent.SavePath, torrent.Name), + downloadClient.DownloadDirectorySource, + downloadClient.DownloadDirectoryTarget + ); + + claimedPaths.Add(contentPath.TrimEnd(Path.DirectorySeparatorChar)); + } + + _logger.LogDebug("Loaded {count} torrent paths from {name}", torrents.Count, downloadClient.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get torrents from client {name}", downloadClient.Name); + } + } + + private void ProcessDirectory( + string directory, + HashSet claimedPaths, + OrphanedFilesConfig clientConfig, + string normalizedOrphanedDir, + CancellationToken cancellationToken) + { + foreach (string filePath in Directory.EnumerateFileSystemEntries(directory, "*", new EnumerationOptions { RecurseSubdirectories = false })) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + string normalizedPath = Path.GetFullPath(filePath).TrimEnd(Path.DirectorySeparatorChar); + + // Skip reparse points (symlinks/junctions) + if ((File.GetAttributes(normalizedPath) & FileAttributes.ReparsePoint) != 0) + { + _logger.LogWarning("skip | reparse point | {path}", normalizedPath); + continue; + } + + if (normalizedPath.Equals(normalizedOrphanedDir, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("skip | orphaned directory itself | {path}", normalizedPath); + continue; + } + + if (claimedPaths.Contains(normalizedPath)) + { + _logger.LogDebug("skip | claimed by torrent | {path}", normalizedPath); + continue; + } + + string entryName = Path.GetFileName(normalizedPath); + if (clientConfig.ExcludePatterns.Any(pattern => FileSystemName.MatchesSimpleExpression(pattern, entryName, ignoreCase: true))) + { + _logger.LogDebug("skip | excluded by pattern | {path}", normalizedPath); + continue; + } + + if (clientConfig.MinFileAgeHours > 0) + { + DateTime lastWrite = File.GetLastWriteTimeUtc(normalizedPath); + DateTime created = File.GetCreationTimeUtc(normalizedPath); + DateTime mostRecent = lastWrite > created ? lastWrite : created; + double ageHours = (_timeProvider.GetUtcNow().UtcDateTime - mostRecent).TotalHours; + + if (ageHours < clientConfig.MinFileAgeHours) + { + _logger.LogDebug( + "skip | too recent ({age:F1}h < {min}h) | {path}", + ageHours, clientConfig.MinFileAgeHours, normalizedPath); + continue; + } + } + + _logger.LogInformation("orphaned entry found | {path}", normalizedPath); + + string capturedEntry = normalizedPath; + string capturedOrphanedDir = normalizedOrphanedDir; + _dryRunInterceptor.Intercept(() => MoveToOrphanedDirectory(capturedEntry, capturedOrphanedDir)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to handle orphaned entry: {path}", filePath); + } + } + } + + private void MoveToOrphanedDirectory(string path, string orphanedDirectory) + { + string entryName = Path.GetFileName(path); + string destination = Path.Combine(orphanedDirectory, entryName); + + if (Path.Exists(destination)) + { + const int maxAttempts = 100; + string timestamp = _timeProvider.GetUtcNow().UtcDateTime.ToString("yyyyMMddHHmmss"); + destination = Path.Combine(orphanedDirectory, $"{entryName}_{timestamp}"); + + int counter = 1; + while (Path.Exists(destination)) + { + if (counter > maxAttempts) + { + throw new InvalidOperationException($"Could not find a free destination name for orphaned entry after {maxAttempts} attempts: {path}"); + } + + destination = Path.Combine(orphanedDirectory, $"{entryName}_{timestamp}_{counter}"); + counter++; + } + } + + Directory.CreateDirectory(orphanedDirectory); + + DateTime now = _timeProvider.GetUtcNow().UtcDateTime; + + if (Directory.Exists(path)) + { + Directory.Move(path, destination); + Directory.SetLastWriteTimeUtc(destination, now); + } + else + { + File.Move(path, destination); + File.SetLastWriteTimeUtc(destination, now); + } + + _logger.LogInformation("orphaned entry moved | {source} -> {dest}", path, destination); + } + + private void PurgeOrphanedDirectory(OrphanedFilesConfig clientConfig, CancellationToken cancellationToken) + { + if (!clientConfig.PurgeAfterHours.HasValue) + { + return; + } + + if (!Directory.Exists(clientConfig.OrphanedDirectory)) + { + return; + } + + DateTime cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-clientConfig.PurgeAfterHours.Value); + + foreach (string filePath in Directory.EnumerateFileSystemEntries(clientConfig.OrphanedDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + + DateTime lastWrite = File.GetLastWriteTimeUtc(filePath); + if (lastWrite > cutoff) + { + continue; + } + + try + { + int hours = clientConfig.PurgeAfterHours.Value; + + if (Directory.Exists(filePath)) + { + Directory.Delete(filePath, recursive: true); + } + else + { + File.Delete(filePath); + } + + _logger.LogInformation("Purged old orphaned entry ({hours}h+) | {path}", hours, filePath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to purge orphaned entry: {path}", filePath); + } + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/SeedingRulesCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/SeedingRulesCleanupService.cs new file mode 100644 index 00000000..c310219e --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/SeedingRulesCleanupService.cs @@ -0,0 +1,69 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; + +/// +public sealed class SeedingRulesCleanupService : ISeedingRulesCleanupService +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + + public SeedingRulesCleanupService(ILogger logger, DataContext dataContext) + { + _logger = logger; + _dataContext = dataContext; + } + + public async Task CleanAsync(IDownloadService downloadService, List clientDownloads) + { + try + { + DownloadClientConfig config = downloadService.ClientConfig; + List seedingRules = config.TypeName switch + { + DownloadClientTypeName.qBittorrent => (await _dataContext.QBitSeedingRules + .Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast().ToList(), + DownloadClientTypeName.Deluge => (await _dataContext.DelugeSeedingRules + .Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast().ToList(), + DownloadClientTypeName.Transmission => (await _dataContext.TransmissionSeedingRules + .Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast().ToList(), + DownloadClientTypeName.uTorrent => (await _dataContext.UTorrentSeedingRules + .Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast().ToList(), + DownloadClientTypeName.rTorrent => (await _dataContext.RTorrentSeedingRules + .Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast().ToList(), + _ => [] + }; + + if (seedingRules.Count is 0) + { + _logger.LogDebug("No seeding rules found for {clientName}", downloadService.ClientConfig.Name); + return; + } + + List? downloadsToClean = downloadService + .FilterDownloadsToBeCleanedAsync(clientDownloads, seedingRules); + + if (downloadsToClean?.Count is null or 0) + { + return; + } + + _logger.LogInformation("Evaluating {count} downloads for cleanup", downloadsToClean.Count); + + await downloadService.CleanDownloadsAsync(downloadsToClean, seedingRules); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to clean downloads for {clientName}", downloadService.ClientConfig.Name); + } + + _logger.LogInformation("Finished cleanup evaluation"); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/UnlinkedDownloadsService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/UnlinkedDownloadsService.cs new file mode 100644 index 00000000..e77f1ee2 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/UnlinkedDownloadsService.cs @@ -0,0 +1,80 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.Files; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; + +/// +public sealed class UnlinkedDownloadsService : IUnlinkedDownloadsService +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly IHardLinkFileService _hardLinkFileService; + + public UnlinkedDownloadsService( + ILogger logger, + DataContext dataContext, + IHardLinkFileService hardLinkFileService) + { + _logger = logger; + _dataContext = dataContext; + _hardLinkFileService = hardLinkFileService; + } + + public async Task ProcessAsync(IDownloadService downloadService, List clientDownloads) + { + UnlinkedConfig? unlinkedConfig = await _dataContext.UnlinkedConfigs + .AsNoTracking() + .FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadService.ClientConfig.Id); + + if (unlinkedConfig is not { Enabled: true }) + { + return; + } + + if (unlinkedConfig.Categories.Count is 0) + { + _logger.LogWarning("Unlinked config is enabled but no categories are configured for {name}", downloadService.ClientConfig.Name); + return; + } + + try + { + if (unlinkedConfig.IgnoredRootDirs.Count > 0) + { + _hardLinkFileService.PopulateFileCounts(unlinkedConfig.IgnoredRootDirs); + } + + List? downloadsToChangeCategory = downloadService + .FilterDownloadsToChangeCategoryAsync(clientDownloads, unlinkedConfig); + + if (downloadsToChangeCategory?.Count is null or 0) + { + return; + } + + _logger.LogInformation("Evaluating {count} downloads for hardlinks", downloadsToChangeCategory.Count); + + try + { + await downloadService.CreateCategoryAsync(unlinkedConfig.TargetCategory); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create category {category}", unlinkedConfig.TargetCategory); + } + + await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, unlinkedConfig); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process unlinked downloads for {clientName}", downloadService.ClientConfig.Name); + } + + _logger.LogInformation("Finished hardlinks evaluation"); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs index 51b11e0b..eb43734d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs @@ -125,7 +125,7 @@ public partial class DelugeService _logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name); - await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities); + await _dryRunInterceptor.InterceptAsync(() => ChangeFilesPriority(hash, sortedPriorities)); return result; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs index cb8f8c32..ed994df3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs @@ -26,6 +26,21 @@ public partial class DelugeService .ToList(); } + /// + public override async Task> GetAllTorrentsLite() + { + var downloads = await _client.GetStatusForAllTorrents(); + if (downloads is null) + { + return []; + } + + return downloads + .Where(x => !string.IsNullOrEmpty(x.Hash)) + .Select(ITorrentItemWrapper (x) => new DelugeItemWrapper(x)) + .ToList(); + } + public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => downloads ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) @@ -56,7 +71,7 @@ public partial class DelugeService _logger.LogDebug("Creating category {name}", name); - await _dryRunInterceptor.InterceptAsync(CreateLabel, name); + await _dryRunInterceptor.InterceptAsync(() => CreateLabel(name)); } public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) @@ -93,9 +108,10 @@ public partial class DelugeService ProcessFiles(contents?.Contents, (_, file) => { - string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadLocation, file.Path).Split(['\\', '/'])); - - filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget); + string filePath = PathHelper.NormalizeAndRemap( + Path.Combine(torrent.Info.DownloadLocation, file.Path), + _downloadClientConfig.DownloadDirectorySource, + _downloadClientConfig.DownloadDirectoryTarget); if (file.Priority <= 0) { @@ -130,7 +146,7 @@ public partial class DelugeService continue; } - await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, unlinkedConfig.TargetCategory); + await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory)); _logger.LogInformation("category changed for {name}", torrent.Name); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs index c10eb373..ec378eae 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs @@ -74,6 +74,9 @@ public abstract class DownloadService : IDownloadService /// public abstract Task> GetSeedingDownloads(); + /// + public abstract Task> GetAllTorrentsLite(); + /// public abstract List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs index e3fd08a8..83590c65 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs @@ -30,6 +30,13 @@ public interface IDownloadService : IDisposable /// A list of downloads that are seeding. Task> GetSeedingDownloads(); + /// + /// Fetches all torrents regardless of their state, without per-torrent tracker or properties calls. + /// Used by the orphaned files cleanup to identify which paths are claimed by active torrents. + /// + /// A list of all torrents. + Task> GetAllTorrentsLite(); + /// /// Filters downloads that should be cleaned. /// diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs index d10a67eb..0cddcf90 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs @@ -120,7 +120,7 @@ public partial class QBitService foreach (int fileIndex in unwantedFiles) { - await _dryRunInterceptor.InterceptAsync(MarkFileAsSkipped, hash, fileIndex); + await _dryRunInterceptor.InterceptAsync(() => MarkFileAsSkipped(hash, fileIndex)); } return result; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs index 9c8e4433..b2edca59 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs @@ -33,6 +33,21 @@ public partial class QBitService return result; } + /// + public override async Task> GetAllTorrentsLite() + { + var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery()); + if (torrentList is null) + { + return []; + } + + return torrentList + .Where(x => !string.IsNullOrEmpty(x.Hash)) + .Select(ITorrentItemWrapper (t) => new QBitItemWrapper(t, [], false)) + .ToList(); + } + /// public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => downloads @@ -76,7 +91,7 @@ public partial class QBitService _logger.LogDebug("Creating category {name}", name); - await _dryRunInterceptor.InterceptAsync(CreateCategory, name); + await _dryRunInterceptor.InterceptAsync(() => CreateCategory(name)); } public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) @@ -116,9 +131,10 @@ public partial class QBitService break; } - string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.SavePath, file.Name).Split(['\\', '/'])); - - filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget); + string filePath = PathHelper.NormalizeAndRemap( + Path.Combine(torrent.Info.SavePath, file.Name), + _downloadClientConfig.DownloadDirectorySource, + _downloadClientConfig.DownloadDirectoryTarget); if (file.Priority is TorrentContentPriority.Skip) { @@ -153,7 +169,7 @@ public partial class QBitService continue; } - await _dryRunInterceptor.InterceptAsync(ChangeCategory, torrent.Hash, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag); + await _dryRunInterceptor.InterceptAsync(() => ChangeCategory(torrent.Hash, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag)); await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs index 879ff1f2..46e3d041 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs @@ -125,7 +125,7 @@ public partial class RTorrentService foreach (var (index, priority) in priorityUpdates) { - await _dryRunInterceptor.InterceptAsync(SetFilePriority, hash, index, priority); + await _dryRunInterceptor.InterceptAsync(() => SetFilePriority(hash, index, priority)); } return result; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs index 59ca02c5..38e2ffb4 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs @@ -21,6 +21,17 @@ public partial class RTorrentService .ToList(); } + /// + public override async Task> GetAllTorrentsLite() + { + var downloads = await _client.GetAllTorrentsAsync(); + + return downloads + .Where(x => !string.IsNullOrEmpty(x.Hash)) + .Select(ITorrentItemWrapper (x) => new RTorrentItemWrapper(x)) + .ToList(); + } + public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => downloads ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) @@ -90,10 +101,10 @@ public partial class RTorrentService foreach (var file in files) { - string filePath = string.Join(Path.DirectorySeparatorChar, - Path.Combine(torrent.Info.Directory ?? torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/'])); - - filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget); + string filePath = PathHelper.NormalizeAndRemap( + Path.Combine(torrent.Info.Directory ?? torrent.Info.BasePath ?? "", file.Path), + _downloadClientConfig.DownloadDirectorySource, + _downloadClientConfig.DownloadDirectoryTarget); if (file.Priority <= 0) { @@ -129,7 +140,7 @@ public partial class RTorrentService continue; } - await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, unlinkedConfig.TargetCategory); + await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory)); _logger.LogInformation("category changed for {name}", torrent.Name); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs index 77e7baa9..92e0cf14 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs @@ -101,7 +101,7 @@ public partial class TransmissionService _logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name); - await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, download.Id, unwantedFiles.ToArray()); + await _dryRunInterceptor.InterceptAsync(() => SetUnwantedFiles(download.Id, unwantedFiles.ToArray())); return result; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs index 2821b315..e7ff8aac 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs @@ -20,6 +20,16 @@ public partial class TransmissionService .ToList() ?? []; } + /// + public override async Task> GetAllTorrentsLite() + { + var result = await _client.TorrentGetAsync(Fields); + return result?.Torrents + ?.Where(x => !string.IsNullOrEmpty(x.HashString)) + .Select(ITorrentItemWrapper (x) => new TransmissionItemWrapper(x)) + .ToList() ?? []; + } + /// public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) { @@ -85,9 +95,10 @@ public partial class TransmissionService continue; } - string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadDir, file.Name).Split(['\\', '/'])); - - filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget); + string filePath = PathHelper.NormalizeAndRemap( + Path.Combine(torrent.Info.DownloadDir, file.Name), + _downloadClientConfig.DownloadDirectorySource, + _downloadClientConfig.DownloadDirectoryTarget); long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0); @@ -119,7 +130,7 @@ public partial class TransmissionService string currentCategory = torrent.Category ?? string.Empty; string newLocation = torrent.Info.GetNewLocationByAppend(unlinkedConfig.TargetCategory); - await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, torrent.Info.Id, newLocation); + await _dryRunInterceptor.InterceptAsync(() => ChangeDownloadLocation(torrent.Info.Id, newLocation)); _logger.LogInformation("category changed for {name}", torrent.Name); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClient.cs index 1a2ebb49..80f17984 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClient.cs @@ -243,22 +243,24 @@ public sealed class UTorrentClient // Get valid token and cookie from cache-aware authenticator var token = await _authenticator.GetValidTokenAsync(); var guidCookie = await _authenticator.GetValidGuidCookieAsync(); - + request.Token = token; - + return await _httpService.SendRawRequestAsync(request, guidCookie); } - catch (UTorrentAuthenticationException) + catch (Exception firstAttemptError) when ( + firstAttemptError is UTorrentAuthenticationException || + (firstAttemptError is UTorrentException && firstAttemptError.Message.Contains("BadRequest", StringComparison.OrdinalIgnoreCase))) { - // On authentication failure, invalidate cache and retry once + // µTorrent returns BadRequest when the token or GUID cookie no longer matches the running server try { await _authenticator.InvalidateSessionAsync(); var token = await _authenticator.GetValidTokenAsync(); var guidCookie = await _authenticator.GetValidGuidCookieAsync(); - + request.Token = token; - + return await _httpService.SendRawRequestAsync(request, guidCookie); } catch (Exception ex) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs index 4d77ce19..6a9db4b0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs @@ -95,7 +95,7 @@ public partial class UTorrentService result.DeleteReason = DeleteReason.AllFilesBlocked; } - await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, fileIndexes); + await _dryRunInterceptor.InterceptAsync(() => ChangeFilesPriority(hash, fileIndexes)); return result; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs index 864c162f..39fc8f4e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs @@ -25,6 +25,17 @@ public partial class UTorrentService return result; } + /// + public override async Task> GetAllTorrentsLite() + { + var torrents = await _client.GetTorrentsAsync(); + + return torrents + .Where(x => !string.IsNullOrEmpty(x.Hash)) + .Select(ITorrentItemWrapper (x) => new UTorrentItemWrapper(x, new UTorrentProperties())) + .ToList(); + } + public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => downloads ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) @@ -73,9 +84,10 @@ public partial class UTorrentService foreach (var file in files ?? []) { - string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.SavePath, file.Name).Split(['\\', '/'])); - - filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget); + string filePath = PathHelper.NormalizeAndRemap( + Path.Combine(torrent.Info.SavePath, file.Name), + _downloadClientConfig.DownloadDirectorySource, + _downloadClientConfig.DownloadDirectoryTarget); if (file.Priority <= 0) { @@ -111,7 +123,7 @@ public partial class UTorrentService continue; } - await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, unlinkedConfig.TargetCategory); + await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory)); await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs index e333dd3b..bcb0b77d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs @@ -5,14 +5,13 @@ using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; -using Cleanuparr.Infrastructure.Features.Files; +using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Persistence.Models.Configuration.General; using MassTransit; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using LogContext = Serilog.Context.LogContext; @@ -21,9 +20,11 @@ namespace Cleanuparr.Infrastructure.Features.Jobs; public sealed class DownloadCleaner : GenericHandler { - private readonly HashSet _downloadsProcessedByArrs = []; + private readonly HashSet _downloadsProcessedByArrs = new(StringComparer.OrdinalIgnoreCase); private readonly TimeProvider _timeProvider; - private readonly IHardLinkFileService _hardLinkFileService; + private readonly ISeedingRulesCleanupService _seedingRulesService; + private readonly IUnlinkedDownloadsService _unlinkedService; + private readonly IOrphanedFilesCleanupService _orphanedFilesService; public DownloadCleaner( ILogger logger, @@ -35,19 +36,23 @@ public sealed class DownloadCleaner : GenericHandler IDownloadServiceFactory downloadServiceFactory, IEventPublisher eventPublisher, TimeProvider timeProvider, - IHardLinkFileService hardLinkFileService + ISeedingRulesCleanupService seedingRulesService, + IUnlinkedDownloadsService unlinkedService, + IOrphanedFilesCleanupService orphanedFilesService ) : base( logger, dataContext, cache, messageBus, arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher ) { _timeProvider = timeProvider; - _hardLinkFileService = hardLinkFileService; + _seedingRulesService = seedingRulesService; + _unlinkedService = unlinkedService; + _orphanedFilesService = orphanedFilesService; } protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default) { - var downloadServices = await GetInitializedDownloadServicesAsync(); + IReadOnlyList downloadServices = await GetInitializedDownloadServicesAsync(); if (downloadServices.Count is 0) { @@ -55,21 +60,45 @@ public sealed class DownloadCleaner : GenericHandler return; } - var config = ContextProvider.Get(); + try + { + await RunCleanupAsync(downloadServices, cancellationToken); + } + finally + { + foreach (IDownloadService downloadService in downloadServices) + { + try + { + downloadService.Dispose(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dispose download service {name}", downloadService.ClientConfig.Name); + } + } + } + } + + private async Task RunCleanupAsync(IReadOnlyList downloadServices, CancellationToken cancellationToken) + { + DownloadCleanerConfig config = ContextProvider.Get(); List ignoredDownloads = ContextProvider.Get(nameof(GeneralConfig)).IgnoredDownloads; ignoredDownloads.AddRange(config.IgnoredDownloads); - var downloadServiceToDownloadsMap = new Dictionary>(); + Dictionary> downloadServiceToDownloadsMap = new(); + List loggedInServices = new(); - foreach (var downloadService in downloadServices) + foreach (IDownloadService downloadService in downloadServices) { - using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString()); - using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name); + using IDisposable _ = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString()); + using IDisposable _2 = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name); try { await downloadService.LoginAsync(); + loggedInServices.Add(downloadService); List clientDownloads = await downloadService.GetSeedingDownloads(); if (clientDownloads.Count > 0) @@ -83,208 +112,88 @@ public sealed class DownloadCleaner : GenericHandler } } - if (downloadServiceToDownloadsMap.Count is 0) - { - _logger.LogInformation("No seeding downloads found"); - return; - } - int totalDownloads = downloadServiceToDownloadsMap.Values.Sum(x => x.Count); _logger.LogTrace("Found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count); - // wait for the downloads to appear in the arr queue - await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider, cancellationToken); - - await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Sonarr)), true); - await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Radarr)), true); - await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Lidarr)), true); - await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Readarr)), true); - await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Whisparr)), true); - - foreach (var pair in downloadServiceToDownloadsMap) + if (downloadServiceToDownloadsMap.Count > 0) { - List filteredDownloads = []; + // wait for the downloads to appear in the arr queue + await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider, cancellationToken); - foreach (ITorrentItemWrapper download in pair.Value) + await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Sonarr)), true); + await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Radarr)), true); + await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Lidarr)), true); + await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Readarr)), true); + await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Whisparr)), true); + + foreach (KeyValuePair> pair in downloadServiceToDownloadsMap) { - if (download.IsIgnored(ignoredDownloads)) + List filteredDownloads = []; + + foreach (ITorrentItemWrapper download in pair.Value) { - _logger.LogDebug("skip | download is ignored | {name}", download.Name); - continue; + if (download.IsIgnored(ignoredDownloads)) + { + _logger.LogDebug("skip | download is ignored | {name}", download.Name); + continue; + } + + if (_downloadsProcessedByArrs.Contains(download.Hash)) + { + _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); + continue; + } + + filteredDownloads.Add(download); } - if (_downloadsProcessedByArrs.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) - { - _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); - continue; - } - - filteredDownloads.Add(download); + downloadServiceToDownloadsMap[pair.Key] = filteredDownloads; } - downloadServiceToDownloadsMap[pair.Key] = filteredDownloads; - } - - // Process each client with its own per-client config - foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap) - { - using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString()); - using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name); - - var seedingRules = await LoadSeedingRulesForClient(downloadService.ClientConfig); - var unlinkedConfig = await LoadUnlinkedConfigForClient(downloadService.ClientConfig.Id); - - if (unlinkedConfig is { Enabled: true }) + foreach ((IDownloadService downloadService, List clientDownloads) in downloadServiceToDownloadsMap) { - if (unlinkedConfig.Categories.Count > 0) - { - await ChangeUnlinkedCategoriesForClientAsync(downloadService, clientDownloads, unlinkedConfig); - } - else - { - _logger.LogWarning("Unlinked config is enabled but no categories are configured for {name}, skipping", downloadService.ClientConfig.Name); - } - } + using IDisposable _ = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString()); + using IDisposable _2 = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name); - if (seedingRules.Count > 0) - { - await CleanDownloadsForClientAsync(downloadService, clientDownloads, seedingRules); + await _unlinkedService.ProcessAsync(downloadService, clientDownloads); + await _seedingRulesService.CleanAsync(downloadService, clientDownloads); } } - - foreach (var downloadService in downloadServices) + else { - downloadService.Dispose(); + _logger.LogInformation("No seeding downloads found, skipping seeding-rule and unlinked-category processing"); + } + + try + { + await _orphanedFilesService.ProcessAsync(loggedInServices, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process orphaned files"); } } protected override async Task ProcessInstanceAsync(ArrInstance instance) { - using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString()); - using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name); + using IDisposable _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString()); + using IDisposable _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name); IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version); - await _arrArrQueueIterator.Iterate(arrClient, instance, async items => + await _arrArrQueueIterator.Iterate(arrClient, instance, items => { - var groups = items + List> groups = items .Where(x => !string.IsNullOrEmpty(x.DownloadId)) .GroupBy(x => x.DownloadId) .ToList(); foreach (QueueRecord record in groups.Select(group => group.First())) { - _downloadsProcessedByArrs.Add(record.DownloadId.ToLowerInvariant()); + _downloadsProcessedByArrs.Add(record.DownloadId); } + + return Task.CompletedTask; }); } - - private async Task ChangeUnlinkedCategoriesForClientAsync( - IDownloadService downloadService, - List clientDownloads, - UnlinkedConfig unlinkedConfig) - { - if (unlinkedConfig.IgnoredRootDirs.Count > 0) - { - _hardLinkFileService.PopulateFileCounts(unlinkedConfig.IgnoredRootDirs); - } - - try - { - var downloadsToChangeCategory = downloadService - .FilterDownloadsToChangeCategoryAsync(clientDownloads, unlinkedConfig); - - if (downloadsToChangeCategory?.Count is null or 0) - { - return; - } - - _logger.LogInformation("Evaluating {count} downloads for hardlinks", downloadsToChangeCategory.Count); - - try - { - await downloadService.CreateCategoryAsync(unlinkedConfig.TargetCategory); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to create category {category}", unlinkedConfig.TargetCategory); - } - - await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, unlinkedConfig); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to process unlinked downloads for {clientName}", downloadService.ClientConfig.Name); - } - - _logger.LogInformation("Finished hardlinks evaluation"); - } - - private async Task CleanDownloadsForClientAsync( - IDownloadService downloadService, - List clientDownloads, - List seedingRules) - { - try - { - var downloadsToClean = downloadService - .FilterDownloadsToBeCleanedAsync(clientDownloads, seedingRules); - - if (downloadsToClean?.Count is null or 0) - { - return; - } - - _logger.LogInformation("Evaluating {count} downloads for cleanup", downloadsToClean.Count); - - await downloadService.CleanDownloadsAsync(downloadsToClean, seedingRules); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to clean downloads for {clientName}", downloadService.ClientConfig.Name); - } - - _logger.LogInformation("Finished cleanup evaluation"); - } - - private async Task> LoadSeedingRulesForClient(Persistence.Models.Configuration.DownloadClientConfig clientConfig) - { - await DataContext.Lock.WaitAsync(); - try - { - return clientConfig.TypeName switch - { - DownloadClientTypeName.qBittorrent => (await _dataContext.QBitSeedingRules - .Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast().ToList(), - DownloadClientTypeName.Deluge => (await _dataContext.DelugeSeedingRules - .Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast().ToList(), - DownloadClientTypeName.Transmission => (await _dataContext.TransmissionSeedingRules - .Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast().ToList(), - DownloadClientTypeName.uTorrent => (await _dataContext.UTorrentSeedingRules - .Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast().ToList(), - DownloadClientTypeName.rTorrent => (await _dataContext.RTorrentSeedingRules - .Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast().ToList(), - _ => [] - }; - } - finally - { - DataContext.Lock.Release(); - } - } - - private async Task LoadUnlinkedConfigForClient(Guid clientId) - { - await DataContext.Lock.WaitAsync(); - try - { - return await _dataContext.UnlinkedConfigs - .AsNoTracking() - .FirstOrDefaultAsync(u => u.DownloadClientConfigId == clientId); - } - finally - { - DataContext.Lock.Release(); - } - } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs index f0666a4f..5960ac52 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs @@ -110,7 +110,7 @@ public class NotificationPublisher : INotificationPublisher private async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context) { - await _dryRunInterceptor.InterceptAsync(SendNotificationInternalAsync, (eventType, context)); + await _dryRunInterceptor.InterceptAsync(() => SendNotificationInternalAsync((eventType, context))); } private async Task SendNotificationInternalAsync((NotificationEventType eventType, NotificationContext context) parameters) diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs index 75710143..554c817f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs @@ -58,6 +58,7 @@ public class DynamicHttpClientProvider : IDynamicHttpClientProvider var clientType = downloadClientConfig.TypeName switch { DownloadClientTypeName.Deluge => HttpClientType.Deluge, + DownloadClientTypeName.uTorrent => HttpClientType.UTorrent, _ => HttpClientType.WithRetry }; diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs index 59bc317a..b2111c40 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs @@ -74,6 +74,17 @@ public class DynamicHttpClientConfiguration : IConfigureNamedOptions + certValidationService.ShouldByPassValidationError(config.CertificateValidationType, sender, certificate, chain, policy), + }; + break; + case HttpClientType.Default: default: // Use default handler with certificate validation diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfig.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfig.cs index cafc6d19..62a5e9c7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfig.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfig.cs @@ -36,5 +36,6 @@ public enum HttpClientType { Default, WithRetry, - Deluge + Deluge, + UTorrent, } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs b/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs index c845bc56..c2fddb90 100644 --- a/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs +++ b/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs @@ -1,90 +1,105 @@ -using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.General; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Interceptors; -public class DryRunInterceptor : IDryRunInterceptor +public partial class DryRunInterceptor : IDryRunInterceptor { private readonly ILogger _logger; private readonly DataContext _dataContext; - + + [GeneratedRegex(@"(\w+)\s*\(")] + private static partial Regex MethodNameRegex(); + public DryRunInterceptor(ILogger logger, DataContext dataContext) { _logger = logger; _dataContext = dataContext; } - - public void Intercept(Action action) + + public void Intercept( + Action action, + [CallerArgumentExpression(nameof(action))] string? expression = null) { - MethodInfo methodInfo = action.Method; - - var config = _dataContext.GeneralConfigs - .AsNoTracking() - .First(); - - if (config.DryRun) + if (IsDryRun(expression)) { - _logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name); return; } action(); } - - public async Task InterceptAsync(Delegate action, params object[] parameters) + + public async Task InterceptAsync( + Func action, + [CallerArgumentExpression(nameof(action))] string? expression = null) { - MethodInfo methodInfo = action.Method; - - var config = await _dataContext.GeneralConfigs - .AsNoTracking() - .FirstAsync(); - - if (config.DryRun) + if (await IsDryRunAsync(expression)) { - _logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name); return; } - object? result = action.DynamicInvoke(parameters); - - if (result is Task task) - { - await task; - } + await action(); } - - public async Task InterceptAsync(Delegate action, params object[] parameters) + + public async Task InterceptAsync( + Func> action, + [CallerArgumentExpression(nameof(action))] string? expression = null) { - MethodInfo methodInfo = action.Method; - - var config = await _dataContext.GeneralConfigs - .AsNoTracking() - .FirstAsync(); - - if (config.DryRun) + if (await IsDryRunAsync(expression)) { - _logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name); return default; } - object? result = action.DynamicInvoke(parameters); - - if (result is Task task) - { - return await task; - } - - return default; + return await action(); } public async Task IsDryRunEnabled() { - var config = await _dataContext.GeneralConfigs + GeneralConfig config = await _dataContext.GeneralConfigs .AsNoTracking() .FirstAsync(); return config.DryRun; } + + private bool IsDryRun(string? expression) + { + GeneralConfig config = _dataContext.GeneralConfigs + .AsNoTracking() + .First(); + + if (!config.DryRun) + { + return false; + } + + _logger.LogInformation("[DRY RUN] skipping method: {name}", ExtractMethodName(expression)); + return true; + } + + private async Task IsDryRunAsync(string? expression) + { + if (!await IsDryRunEnabled()) + { + return false; + } + + _logger.LogInformation("[DRY RUN] skipping method: {name}", ExtractMethodName(expression)); + return true; + } + + private static string ExtractMethodName(string? expression) + { + if (string.IsNullOrWhiteSpace(expression)) + { + return "unknown"; + } + + Match match = MethodNameRegex().Match(expression); + return match.Success ? match.Groups[1].Value : expression; + } } diff --git a/code/backend/Cleanuparr.Infrastructure/Interceptors/IDryRunInterceptor.cs b/code/backend/Cleanuparr.Infrastructure/Interceptors/IDryRunInterceptor.cs index f27cbe0a..4c4a37bd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Interceptors/IDryRunInterceptor.cs +++ b/code/backend/Cleanuparr.Infrastructure/Interceptors/IDryRunInterceptor.cs @@ -1,12 +1,48 @@ -namespace Cleanuparr.Infrastructure.Interceptors; +using System.Runtime.CompilerServices; +namespace Cleanuparr.Infrastructure.Interceptors; + +/// +/// Wraps mutating operations so they can be short-circuited when dry-run mode is enabled. +/// Callers pass a lambda; the call-site expression is captured for logging via +/// and a method name is extracted from it. +/// public interface IDryRunInterceptor { - void Intercept(Action action); - - Task InterceptAsync(Delegate action, params object[] parameters); + /// + /// Executes unless dry-run mode is enabled, in which case the + /// operation is skipped and the call is logged. + /// + /// The synchronous operation to execute. + /// Auto-populated call-site expression used to log the skipped method name. + void Intercept( + Action action, + [CallerArgumentExpression(nameof(action))] string? expression = null); - Task InterceptAsync(Delegate action, params object[] parameters); + /// + /// Awaits unless dry-run mode is enabled, in which case the + /// operation is skipped and the call is logged. + /// + /// The asynchronous operation to execute. + /// Auto-populated call-site expression used to log the skipped method name. + Task InterceptAsync( + Func action, + [CallerArgumentExpression(nameof(action))] string? expression = null); + /// + /// Awaits and returns its result unless dry-run mode is enabled, + /// in which case the operation is skipped and default(T) is returned. + /// + /// The result type returned by . + /// The asynchronous operation to execute. + /// Auto-populated call-site expression used to log the skipped method name. + /// The result of , or default when dry-run mode is enabled. + Task InterceptAsync( + Func> action, + [CallerArgumentExpression(nameof(action))] string? expression = null); + + /// + /// Returns whether dry-run mode is currently enabled in the persisted general configuration. + /// Task IsDryRunEnabled(); -} \ No newline at end of file +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/OrphanedFilesConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/OrphanedFilesConfigTests.cs new file mode 100644 index 00000000..72e368ee --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/OrphanedFilesConfigTests.cs @@ -0,0 +1,335 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner; + +public sealed class OrphanedFilesConfigTests +{ + #region Defaults + + [Fact] + public void Defaults_EnabledIsFalse() + { + new OrphanedFilesConfig().Enabled.ShouldBeFalse(); + } + + [Fact] + public void Defaults_ScanDirectoriesIsEmpty() + { + new OrphanedFilesConfig().ScanDirectories.ShouldBeEmpty(); + } + + [Fact] + public void Defaults_OrphanedDirectoryIsEmpty() + { + new OrphanedFilesConfig().OrphanedDirectory.ShouldBe(string.Empty); + } + + [Fact] + public void Defaults_ExcludePatternsIsEmpty() + { + new OrphanedFilesConfig().ExcludePatterns.ShouldBeEmpty(); + } + + [Fact] + public void Defaults_MinFileAgeHoursIs24() + { + new OrphanedFilesConfig().MinFileAgeHours.ShouldBe(24); + } + + [Fact] + public void Defaults_PurgeAfterHoursIsNull() + { + new OrphanedFilesConfig().PurgeAfterHours.ShouldBeNull(); + } + + #endregion + + #region Validate - Self-validation + + [Fact] + public void Validate_WhenDisabled_DoesNotThrow() + { + var config = new OrphanedFilesConfig { Enabled = false, ScanDirectories = [] }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WhenEnabledWithNoScanDirs_ThrowsValidationException() + { + var config = new OrphanedFilesConfig { Enabled = true, ScanDirectories = [], OrphanedDirectory = "/downloads/orphaned" }; + + Should.Throw(() => config.Validate()) + .Message.ShouldContain("scan directory"); + } + + [Fact] + public void Validate_WhenEnabledWithoutOrphanedDirectory_ThrowsValidationException() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads"], + OrphanedDirectory = string.Empty, + }; + + Should.Throw(() => config.Validate()) + .Message.ShouldContain("Orphaned directory"); + } + + [Fact] + public void Validate_WhenEnabledWithScanDirsAndOrphanedDir_DoesNotThrow() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads"], + OrphanedDirectory = "/downloads-orphaned", + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Cross-client overlap (H1) + + [Fact] + public void Validate_ScanDirMatchesSiblingScanDir_ThrowsValidationException() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/complete"], + OrphanedDirectory = "/downloads/orphaned-a", + }; + + var sibling = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/complete"], + OrphanedDirectory = "/downloads/orphaned-b", + }; + + Should.Throw(() => config.Validate([sibling])) + .Message.ShouldContain("overlap"); + } + + [Fact] + public void Validate_ScanDirIsSubpathOfSiblingScanDir_ThrowsValidationException() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/complete/movies"], + OrphanedDirectory = "/downloads/orphaned-a", + }; + + var sibling = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/complete"], + OrphanedDirectory = "/downloads/orphaned-b", + }; + + Should.Throw(() => config.Validate([sibling])) + .Message.ShouldContain("overlap"); + } + + [Fact] + public void Validate_SiblingScanDirIsSubpathOfScanDir_ThrowsValidationException() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads"], + OrphanedDirectory = "/orphaned-a", + }; + + var sibling = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/complete"], + OrphanedDirectory = "/orphaned-b", + }; + + Should.Throw(() => config.Validate([sibling])) + .Message.ShouldContain("overlap"); + } + + [Fact] + public void Validate_ScanDirMatchesSiblingOrphanedDir_ThrowsValidationException() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/orphaned"], + OrphanedDirectory = "/downloads/orphaned-a", + }; + + var sibling = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/other"], + OrphanedDirectory = "/downloads/orphaned", + }; + + Should.Throw(() => config.Validate([sibling])) + .Message.ShouldContain("overlap"); + } + + [Fact] + public void Validate_OrphanedDirMatchesSiblingScanDir_ThrowsValidationException() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/client1"], + OrphanedDirectory = "/downloads/shared", + }; + + var sibling = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/shared"], + OrphanedDirectory = "/orphaned-b", + }; + + Should.Throw(() => config.Validate([sibling])) + .Message.ShouldContain("overlap"); + } + + [Fact] + public void Validate_NonOverlappingPaths_DoesNotThrow() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/client1"], + OrphanedDirectory = "/downloads/orphaned1", + }; + + var sibling = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/client2"], + OrphanedDirectory = "/downloads/orphaned2", + }; + + Should.NotThrow(() => config.Validate([sibling])); + } + + [Fact] + public void Validate_PathsWithMixedSeparators_DetectsOverlap() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/complete"], + OrphanedDirectory = "/orphaned-a", + }; + + var sibling = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["\\downloads\\complete"], + OrphanedDirectory = "/orphaned-b", + }; + + Should.Throw(() => config.Validate([sibling])); + } + + [Fact] + public void Validate_EmptySiblingsList_DoesNotThrow() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/downloads/complete"], + OrphanedDirectory = "/orphaned", + }; + + Should.NotThrow(() => config.Validate([])); + } + + #endregion + + #region Validate - Cross-client download directory + + [Fact] + public void Validate_ScanDirOverlapsOtherClientDownloadTarget_ThrowsValidationException() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/data/downloads"], + OrphanedDirectory = "/data/orphaned", + }; + + var otherClient = MakeDownloadClient("Other", "/data/downloads/movies"); + + Should.Throw(() => config.Validate([], [otherClient])) + .Message.ShouldContain("overlap"); + } + + [Fact] + public void Validate_OrphanedDirOverlapsOtherClientDownloadTarget_ThrowsValidationException() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/data/downloads"], + OrphanedDirectory = "/data/other-downloads", + }; + + var otherClient = MakeDownloadClient("Other", "/data/other-downloads/movies"); + + Should.Throw(() => config.Validate([], [otherClient])) + .Message.ShouldContain("overlap"); + } + + [Fact] + public void Validate_NoOverlapWithOtherClientDownloadTarget_DoesNotThrow() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/data/downloads-a"], + OrphanedDirectory = "/data/orphaned", + }; + + var otherClient = MakeDownloadClient("Other", "/data/downloads-b"); + + Should.NotThrow(() => config.Validate([], [otherClient])); + } + + [Fact] + public void Validate_OtherClientWithoutDownloadTarget_IsIgnored() + { + var config = new OrphanedFilesConfig + { + Enabled = true, + ScanDirectories = ["/data/downloads"], + OrphanedDirectory = "/data/orphaned", + }; + + var otherClient = MakeDownloadClient("Other", null); + + Should.NotThrow(() => config.Validate([], [otherClient])); + } + + private static DownloadClientConfig MakeDownloadClient(string name, string? downloadDirectoryTarget) => new() + { + Name = name, + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + DownloadDirectoryTarget = downloadDirectoryTarget, + }; + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/UnlinkedConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/UnlinkedConfigTests.cs index 3f299f32..d3a826d1 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/UnlinkedConfigTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/UnlinkedConfigTests.cs @@ -7,8 +7,6 @@ namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner; public sealed class UnlinkedConfigTests { - #region Default Values - [Fact] public void Defaults_EnabledIsFalse() { @@ -30,24 +28,6 @@ public sealed class UnlinkedConfigTests config.Categories.ShouldBeEmpty(); } - [Fact] - public void Defaults_DownloadDirectorySourceIsNull() - { - var config = new UnlinkedConfig(); - config.DownloadDirectorySource.ShouldBeNull(); - } - - [Fact] - public void Defaults_DownloadDirectoryTargetIsNull() - { - var config = new UnlinkedConfig(); - config.DownloadDirectoryTarget.ShouldBeNull(); - } - - #endregion - - #region Validate - Disabled - [Fact] public void Validate_WhenDisabled_DoesNotThrow() { @@ -61,10 +41,6 @@ public sealed class UnlinkedConfigTests Should.NotThrow(() => config.Validate()); } - #endregion - - #region Validate - Enabled - [Fact] public void Validate_WhenEnabled_WithValidConfig_DoesNotThrow() { @@ -134,76 +110,6 @@ public sealed class UnlinkedConfigTests exception.Message.ShouldBe("Empty unlinked category filter found"); } - #endregion - - #region Validate - Directory Mapping - - [Fact] - public void Validate_WhenEnabled_WithOnlySourceSet_ThrowsValidationException() - { - var config = new UnlinkedConfig - { - Enabled = true, - TargetCategory = "cleanuparr-unlinked", - Categories = ["movies"], - DownloadDirectorySource = "/downloads", - DownloadDirectoryTarget = null - }; - - var exception = Should.Throw(() => config.Validate()); - exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty"); - } - - [Fact] - public void Validate_WhenEnabled_WithOnlyTargetSet_ThrowsValidationException() - { - var config = new UnlinkedConfig - { - Enabled = true, - TargetCategory = "cleanuparr-unlinked", - Categories = ["movies"], - DownloadDirectorySource = null, - DownloadDirectoryTarget = "/data/downloads" - }; - - var exception = Should.Throw(() => config.Validate()); - exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty"); - } - - [Fact] - public void Validate_WhenEnabled_WithBothDirsSet_DoesNotThrow() - { - var config = new UnlinkedConfig - { - Enabled = true, - TargetCategory = "cleanuparr-unlinked", - Categories = ["movies"], - DownloadDirectorySource = "/downloads", - DownloadDirectoryTarget = "/data/downloads" - }; - - Should.NotThrow(() => config.Validate()); - } - - [Fact] - public void Validate_WhenEnabled_WithBothDirsEmpty_DoesNotThrow() - { - var config = new UnlinkedConfig - { - Enabled = true, - TargetCategory = "cleanuparr-unlinked", - Categories = ["movies"], - DownloadDirectorySource = null, - DownloadDirectoryTarget = null - }; - - Should.NotThrow(() => config.Validate()); - } - - #endregion - - #region Validate - Ignored Root Dirs - [Fact] public void Validate_WhenEnabled_WithNonExistentIgnoredRootDir_ThrowsValidationException() { @@ -244,9 +150,6 @@ public sealed class UnlinkedConfigTests IgnoredRootDirs = [""] }; - // Empty strings are filtered out, so this should not throw Should.NotThrow(() => config.Validate()); } - - #endregion } diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs index 8ba5761a..2223d5c9 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs @@ -108,6 +108,76 @@ public sealed class DownloadClientConfigTests #endregion + #region Validate - Directory Mapping + + [Fact] + public void Validate_WithOnlyDownloadDirectorySourceSet_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + DownloadDirectorySource = "/downloads", + DownloadDirectoryTarget = null + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty"); + } + + [Fact] + public void Validate_WithOnlyDownloadDirectoryTargetSet_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + DownloadDirectorySource = null, + DownloadDirectoryTarget = "/data/downloads" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty"); + } + + [Fact] + public void Validate_WithBothDownloadDirectoriesSet_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + DownloadDirectorySource = "/downloads", + DownloadDirectoryTarget = "/data/downloads" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithBothDownloadDirectoriesEmpty_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + DownloadDirectorySource = null, + DownloadDirectoryTarget = null + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + #region Url Property Tests [Fact] diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index c92637b6..c3251306 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -76,6 +76,8 @@ public class DataContext : DbContext public DbSet BlacklistSyncConfigs { get; set; } + public DbSet OrphanedFilesConfigs { get; set; } + public DbSet SeekerConfigs { get; set; } public DbSet SeekerInstanceConfigs { get; set; } @@ -369,6 +371,20 @@ public class DataContext : DbContext entity.HasIndex(u => u.DownloadClientConfigId).IsUnique(); }); + // Configure per-client orphaned files config relationship + modelBuilder.Entity(entity => + { + entity.HasOne(c => c.DownloadClientConfig) + .WithMany() + .HasForeignKey(c => c.DownloadClientConfigId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(c => c.DownloadClientConfigId).IsUnique(); + + entity.Property(c => c.ScanDirectories).HasConversion(jsonListConverter); + entity.Property(c => c.ExcludePatterns).HasConversion(jsonListConverter); + }); + // Configure BlacklistSyncState relationships and indexes modelBuilder.Entity(entity => { diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260527073208_AddOrphanedFilesCleanup.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260527073208_AddOrphanedFilesCleanup.Designer.cs new file mode 100644 index 00000000..577641b8 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260527073208_AddOrphanedFilesCleanup.Designer.cs @@ -0,0 +1,2180 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20260527073208_AddOrphanedFilesCleanup")] + partial class AddOrphanedFilesCleanup + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_deluge_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id"); + + b.ToTable("deluge_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExcludePatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("exclude_patterns"); + + b.Property("MinFileAgeHours") + .HasColumnType("INTEGER") + .HasColumnName("min_file_age_hours"); + + b.Property("OrphanedDirectory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("orphaned_directory"); + + b.Property("PurgeAfterHours") + .HasColumnType("INTEGER") + .HasColumnName("purge_after_hours"); + + b.Property("ScanDirectories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("scan_directories"); + + b.HasKey("Id") + .HasName("pk_orphaned_files_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_orphaned_files_configs_download_client_config_id"); + + b.ToTable("orphaned_files_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_q_bit_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id"); + + b.ToTable("q_bit_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_r_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("r_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_transmission_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id"); + + b.ToTable("transmission_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_u_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("u_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.PrimitiveCollection("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_root_dirs"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("INTEGER") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_unlinked_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_unlinked_configs_download_client_config_id"); + + b.ToTable("unlinked_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadDirectorySource") + .HasColumnType("TEXT") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("TEXT") + .HasColumnName("download_directory_target"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchItemGrabbed") + .HasColumnType("INTEGER") + .HasColumnName("on_search_item_grabbed"); + + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_change_category"); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("INTEGER") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("INTEGER") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("LastUpgradedAt") + .HasColumnType("TEXT") + .HasColumnName("last_upgraded_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSearchedAt") + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + b.ToTable("seeker_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orphaned_files_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_entries_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_queue_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_command_trackers_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260527073208_AddOrphanedFilesCleanup.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260527073208_AddOrphanedFilesCleanup.cs new file mode 100644 index 00000000..d545e4c5 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260527073208_AddOrphanedFilesCleanup.cs @@ -0,0 +1,142 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddOrphanedFilesCleanup : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "download_directory_source", + table: "download_clients", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "download_directory_target", + table: "download_clients", + type: "TEXT", + nullable: true); + + // Migrate existing path remapping values from unlinked_configs to download_clients. + // Safe because unlinked_configs has a unique index on download_client_config_id. + migrationBuilder.Sql(@" + UPDATE download_clients + SET + download_directory_source = ( + SELECT download_directory_source + FROM unlinked_configs + WHERE unlinked_configs.download_client_config_id = download_clients.id + AND download_directory_source IS NOT NULL + LIMIT 1 + ), + download_directory_target = ( + SELECT download_directory_target + FROM unlinked_configs + WHERE unlinked_configs.download_client_config_id = download_clients.id + AND download_directory_target IS NOT NULL + LIMIT 1 + ) + WHERE EXISTS ( + SELECT 1 FROM unlinked_configs + WHERE unlinked_configs.download_client_config_id = download_clients.id + AND download_directory_source IS NOT NULL + ) + "); + + migrationBuilder.DropColumn( + name: "download_directory_source", + table: "unlinked_configs"); + + migrationBuilder.DropColumn( + name: "download_directory_target", + table: "unlinked_configs"); + + migrationBuilder.CreateTable( + name: "orphaned_files_configs", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + download_client_config_id = table.Column(type: "TEXT", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false), + scan_directories = table.Column(type: "TEXT", nullable: false), + orphaned_directory = table.Column(type: "TEXT", nullable: false), + exclude_patterns = table.Column(type: "TEXT", nullable: false), + min_file_age_hours = table.Column(type: "INTEGER", nullable: false), + purge_after_hours = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_orphaned_files_configs", x => x.id); + table.ForeignKey( + name: "fk_orphaned_files_configs_download_clients_download_client_config_id", + column: x => x.download_client_config_id, + principalTable: "download_clients", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_orphaned_files_configs_download_client_config_id", + table: "orphaned_files_configs", + column: "download_client_config_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "orphaned_files_configs"); + + migrationBuilder.AddColumn( + name: "download_directory_source", + table: "unlinked_configs", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "download_directory_target", + table: "unlinked_configs", + type: "TEXT", + nullable: true); + + migrationBuilder.Sql(@" + UPDATE unlinked_configs + SET + download_directory_source = ( + SELECT download_directory_source + FROM download_clients + WHERE download_clients.id = unlinked_configs.download_client_config_id + AND download_directory_source IS NOT NULL + LIMIT 1 + ), + download_directory_target = ( + SELECT download_directory_target + FROM download_clients + WHERE download_clients.id = unlinked_configs.download_client_config_id + AND download_directory_target IS NOT NULL + LIMIT 1 + ) + WHERE EXISTS ( + SELECT 1 FROM download_clients + WHERE download_clients.id = unlinked_configs.download_client_config_id + AND download_directory_source IS NOT NULL + ) + "); + + migrationBuilder.DropColumn( + name: "download_directory_source", + table: "download_clients"); + + migrationBuilder.DropColumn( + name: "download_directory_target", + table: "download_clients"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index 08c952a8..b64ccf03 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -204,6 +204,54 @@ namespace Cleanuparr.Persistence.Migrations.Data b.ToTable("download_cleaner_configs", (string)null); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExcludePatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("exclude_patterns"); + + b.Property("MinFileAgeHours") + .HasColumnType("INTEGER") + .HasColumnName("min_file_age_hours"); + + b.Property("OrphanedDirectory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("orphaned_directory"); + + b.Property("PurgeAfterHours") + .HasColumnType("INTEGER") + .HasColumnName("purge_after_hours"); + + b.Property("ScanDirectories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("scan_directories"); + + b.HasKey("Id") + .HasName("pk_orphaned_files_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_orphaned_files_configs_download_client_config_id"); + + b.ToTable("orphaned_files_configs", (string)null); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => { b.Property("Id") @@ -480,14 +528,6 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("download_client_config_id"); - b.Property("DownloadDirectorySource") - .HasColumnType("TEXT") - .HasColumnName("download_directory_source"); - - b.Property("DownloadDirectoryTarget") - .HasColumnType("TEXT") - .HasColumnName("download_directory_target"); - b.Property("Enabled") .HasColumnType("INTEGER") .HasColumnName("enabled"); @@ -523,6 +563,14 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("id"); + b.Property("DownloadDirectorySource") + .HasColumnType("TEXT") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("TEXT") + .HasColumnName("download_directory_target"); + b.Property("Enabled") .HasColumnType("INTEGER") .HasColumnName("enabled"); @@ -1831,6 +1879,18 @@ namespace Cleanuparr.Persistence.Migrations.Data b.Navigation("DownloadClientConfig"); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orphaned_files_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => { b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/OrphanedFilesConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/OrphanedFilesConfig.cs new file mode 100644 index 00000000..c9455d61 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/OrphanedFilesConfig.cs @@ -0,0 +1,182 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; + +/// +/// Per-download-client configuration for the orphaned files scanner. +/// +public sealed record OrphanedFilesConfig : IConfig +{ + /// + /// Unique identifier for this config row. + /// + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Owning download client identifier. + /// + public Guid DownloadClientConfigId { get; set; } + + /// + /// Navigation back to the owning download client. + /// + public DownloadClientConfig DownloadClientConfig { get; set; } = null!; + + /// + /// Whether the orphaned files scanner is enabled for this client. + /// + public bool Enabled { get; set; } + + /// + /// Absolute paths to scan for orphaned files. Each top-level entry is + /// checked against the client's active torrents. + /// + public List ScanDirectories { get; set; } = []; + + /// + /// Destination directory where orphaned entries are moved. + /// + [Required] + public string OrphanedDirectory { get; set; } = string.Empty; + + /// + /// Glob patterns that exclude entries from being treated as orphaned + /// (e.g. "*.nfo", ".DS_Store"). + /// + public List ExcludePatterns { get; set; } = []; + + /// + /// Minimum age in hours an entry must have before it can be considered + /// orphaned. Protects in-flight downloads that the client has not yet + /// registered as a torrent. Set to 0 to disable the age check. + /// + [Range(0, int.MaxValue)] + public int MinFileAgeHours { get; set; } = 24; + + /// + /// If set, entries in older than this many + /// hours are permanently deleted. Null leaves them indefinitely. + /// + [Range(1, int.MaxValue)] + public int? PurgeAfterHours { get; set; } + + /// + /// Self-validation with no cross-client checks. + /// + public void Validate() => Validate([], []); + + /// + /// Validates this config and ensures its scan/orphaned paths do not + /// overlap with any sibling client's orphaned-files config or another + /// client's download directory target. + /// + public void Validate( + IReadOnlyList siblings, + IReadOnlyList? otherDownloadClients = null) + { + otherDownloadClients ??= []; + + if (!Enabled) + { + return; + } + + if (ScanDirectories.Count == 0) + { + throw new ValidationException("At least one scan directory is required when orphaned files cleanup is enabled for this client"); + } + + if (string.IsNullOrWhiteSpace(OrphanedDirectory)) + { + throw new ValidationException("Orphaned directory is required when orphaned files cleanup is enabled for this client"); + } + + foreach (var scanDir in ScanDirectories) + { + var normalized = NormalizePath(scanDir); + + foreach (var sibling in siblings) + { + foreach (var otherScanDir in sibling.ScanDirectories) + { + CheckOverlap(normalized, NormalizePath(otherScanDir), "scan directory", "another client's scan directory"); + } + + if (!string.IsNullOrWhiteSpace(sibling.OrphanedDirectory)) + { + CheckOverlap(normalized, NormalizePath(sibling.OrphanedDirectory), "scan directory", "another client's orphaned directory"); + } + } + + foreach (var otherClient in otherDownloadClients) + { + if (!string.IsNullOrWhiteSpace(otherClient.DownloadDirectoryTarget)) + { + CheckOverlap(normalized, NormalizePath(otherClient.DownloadDirectoryTarget), "scan directory", $"another client's download directory ({otherClient.Name})"); + } + } + } + + var normalizedOrphaned = NormalizePath(OrphanedDirectory); + var sep = Path.DirectorySeparatorChar.ToString(); + + foreach (var scanDir in ScanDirectories) + { + var normalizedScan = NormalizePath(scanDir); + + if (string.Equals(normalizedScan, normalizedOrphaned, StringComparison.OrdinalIgnoreCase)) + { + throw new ValidationException( + $"Orphaned directory '{normalizedOrphaned}' cannot equal scan directory '{normalizedScan}'."); + } + + if (normalizedScan.StartsWith(normalizedOrphaned + sep, StringComparison.OrdinalIgnoreCase)) + { + throw new ValidationException( + $"Orphaned directory '{normalizedOrphaned}' cannot be an ancestor of scan directory '{normalizedScan}'."); + } + } + + foreach (var sibling in siblings) + { + foreach (var otherScanDir in sibling.ScanDirectories) + { + CheckOverlap(normalizedOrphaned, NormalizePath(otherScanDir), "orphaned directory", "another client's scan directory"); + } + + if (!string.IsNullOrWhiteSpace(sibling.OrphanedDirectory)) + { + CheckOverlap(normalizedOrphaned, NormalizePath(sibling.OrphanedDirectory), "orphaned directory", "another client's orphaned directory"); + } + } + + foreach (var otherClient in otherDownloadClients) + { + if (!string.IsNullOrWhiteSpace(otherClient.DownloadDirectoryTarget)) + { + CheckOverlap(normalizedOrphaned, NormalizePath(otherClient.DownloadDirectoryTarget), "orphaned directory", $"another client's download directory ({otherClient.Name})"); + } + } + } + + private static void CheckOverlap(string a, string b, string aLabel, string bLabel) + { + var sep = Path.DirectorySeparatorChar.ToString(); + + if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase) + || a.StartsWith(b + sep, StringComparison.OrdinalIgnoreCase) + || b.StartsWith(a + sep, StringComparison.OrdinalIgnoreCase)) + { + throw new ValidationException( + $"Path overlap detected: {aLabel} '{a}' overlaps with {bLabel} '{b}'. Scan directories and orphaned directories must not overlap across clients."); + } + } + + private static string NormalizePath(string path) => + string.Join(Path.DirectorySeparatorChar, path.Split(['\\', '/'])) + .TrimEnd(Path.DirectorySeparatorChar); +} diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/UnlinkedConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/UnlinkedConfig.cs index 62672930..6ffe2f94 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/UnlinkedConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadCleaner/UnlinkedConfig.cs @@ -24,18 +24,6 @@ public sealed record UnlinkedConfig : IConfig public List Categories { get; set; } = []; - /// - /// The path prefix reported by the download client (e.g., "/downloads"). - /// When set, this prefix is replaced with when resolving file paths. - /// - public string? DownloadDirectorySource { get; set; } - - /// - /// The actual local mount path (e.g., "/downloads-other"). - /// Replaces in file paths for hardlink checking. - /// - public string? DownloadDirectoryTarget { get; set; } - public void Validate() { if (!Enabled) @@ -63,11 +51,6 @@ public sealed record UnlinkedConfig : IConfig throw new ValidationException("Empty unlinked category filter found"); } - if (!string.IsNullOrEmpty(DownloadDirectorySource) != !string.IsNullOrEmpty(DownloadDirectoryTarget)) - { - throw new ValidationException("Both download directory source and target must be set, or both must be empty"); - } - foreach (var dir in IgnoredRootDirs.Where(d => !string.IsNullOrEmpty(d))) { if (!Directory.Exists(dir)) diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs index 23984555..0ce634d8 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs @@ -78,6 +78,18 @@ public sealed record DownloadClientConfig [JsonIgnore] public Uri ExternalOrInternalUrl => ExternalUrl ?? Url; + /// + /// The path prefix reported by the download client (e.g., "/downloads"). + /// Replaced with when resolving file paths across all features. + /// + public string? DownloadDirectorySource { get; set; } + + /// + /// The actual local mount path (e.g., "/data/downloads"). + /// Replaces in file paths for hardlink checking and orphan detection. + /// + public string? DownloadDirectoryTarget { get; set; } + /// /// Validates the configuration /// @@ -87,10 +99,15 @@ public sealed record DownloadClientConfig { throw new ValidationException($"Client name cannot be empty"); } - + if (Host is null) { throw new ValidationException($"Host cannot be empty"); } + + if (!string.IsNullOrWhiteSpace(DownloadDirectorySource) != !string.IsNullOrWhiteSpace(DownloadDirectoryTarget)) + { + throw new ValidationException("Both download directory source and target must be set, or both must be empty"); + } } } diff --git a/code/backend/Cleanuparr.Shared/Helpers/PathHelper.cs b/code/backend/Cleanuparr.Shared/Helpers/PathHelper.cs index 4c0c001b..5d0bba7d 100644 --- a/code/backend/Cleanuparr.Shared/Helpers/PathHelper.cs +++ b/code/backend/Cleanuparr.Shared/Helpers/PathHelper.cs @@ -21,7 +21,8 @@ public static class PathHelper return filePath; } - var normSource = source.TrimEnd('/', '\\') + Path.DirectorySeparatorChar; + // Normalize separators so Windows source paths (backslashes) match Linux-normalized filePaths + var normSource = source.Replace('\\', Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var normTarget = target.TrimEnd('/', '\\'); // Exact match: filePath is exactly the source directory (no trailing separator) @@ -38,4 +39,14 @@ public static class PathHelper return filePath; } + + /// + /// Normalizes path separators to the host's and then + /// applies . + /// + public static string NormalizeAndRemap(string path, string? source, string? target) + { + string normalized = string.Join(Path.DirectorySeparatorChar, path.Split(['\\', '/'])); + return RemapPath(normalized, source, target); + } } diff --git a/code/frontend/package-lock.json b/code/frontend/package-lock.json index 114a6ea6..da7f77c0 100644 --- a/code/frontend/package-lock.json +++ b/code/frontend/package-lock.json @@ -279,13 +279,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2102.5", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.5.tgz", - "integrity": "sha512-9xE7G177R9G9Kte+4AtbEMlEeZUupnvdBUMVBlZRa/n4UDUyAkB/vj58KrzRCCIVQ/ypHVMwUilaDTO484dd+g==", + "version": "0.2102.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.12.tgz", + "integrity": "sha512-w9FSMHYeeHkk0kRSAOCvNqEVyOHqpC1SUf3iN7tDnXBOA0dtc6JYvJU7O4joiwf7wMPZDK8LKc/6eu8/Tx87Fw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.5", + "@angular-devkit/core": "21.2.12", "rxjs": "7.8.2" }, "bin": { @@ -298,9 +298,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.2.5", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.5.tgz", - "integrity": "sha512-9z9w7UxKKVmib5QHFZTOfJpAiSudqQwwEZFpQy31yaXR3tJw85xO5owi+66sgTpEvNh9Ix2THhcUq//ToP/0VA==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.12.tgz", + "integrity": "sha512-nXms0jVWwHOJK+z6vHvhw7HYFBelxh2gEnkij0OQMABXZN5hoUlTD0DDP1lYR7hQNi8Yb2Ar0UN9ihyUFVM5Kg==", "license": "MIT", "dependencies": { "ajv": "8.18.0", @@ -325,13 +325,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "21.2.5", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.5.tgz", - "integrity": "sha512-gEg84eipTX6lcpNTDVUXBBwp0vs3rXM319Qom+sCLOKBGyqE0mvb1RM1WwfNcyOqeSMQC/vLUwRKqnP0wg1UDg==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.12.tgz", + "integrity": "sha512-29xe6C9nwHejV9zBcu0js7NmzLWuCFzBGBTmL6eD4JN1NcxEZ/nO1JuaGINjPjzb/UDXPZIqEwHbnFNcGS5v1A==", "license": "MIT", "peer": true, "dependencies": { - "@angular-devkit/core": "21.2.5", + "@angular-devkit/core": "21.2.12", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -344,9 +344,9 @@ } }, "node_modules/@angular-eslint/builder": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.3.1.tgz", - "integrity": "sha512-1f1Lyp5e7OH6txiV224HaY3G1uRCj91OSKq7hT2Vw9NRw6zWFc1anBpDeLVjpL9ptUxzUGIQR5jEV54hOPayoQ==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.4.0.tgz", + "integrity": "sha512-3kgGmrVaCYbLtDjC8g4BmMBbdz4thsOB8/NYly8JtXM8EuDZEk5Pz6VTRpJR02ARprwayraTTmhyvq6OGBlQ9w==", "dev": true, "license": "MIT", "dependencies": { @@ -360,21 +360,21 @@ } }, "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.3.1.tgz", - "integrity": "sha512-jjbnJPUXQeQBJ8RM+ahlbt4GH2emVN8JvG3AhFbPci1FrqXi9cOOfkbwLmvpoyTli4LF8gy7g4ctFqnlRgqryw==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.4.0.tgz", + "integrity": "sha512-/3H4BPbQ1BHJkkrUsfusZtmHc+qiFWBBZ9UDPWah4xZMjflexOK9U4GYeH7nMjcuyqFnIlMMeJJNwNLGt/hmdg==", "dev": true, "license": "MIT" }, "node_modules/@angular-eslint/eslint-plugin": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.3.1.tgz", - "integrity": "sha512-08NNTxwawRLTWPLl8dg1BnXMwimx93y4wMEwx2aWQpJbIt4pmNvwJzd+NgoD/Ag2VdLS/gOMadhJH5fgaYKsPQ==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.4.0.tgz", + "integrity": "sha512-mow2DMj+xBvGl5t7jzC34R8YfbHbaGNyCNFzpovtl9qc0JbuqLyg6htmt8xb05f8ZjATOr4nz0ESt6HV4c51hw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.3.1", - "@angular-eslint/utils": "21.3.1", + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { @@ -384,19 +384,19 @@ } }, "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.3.1.tgz", - "integrity": "sha512-ndPWJodkcEOu2PVUxlUwyz4D2u3r9KO7veWmStVNOLeNrICJA+nQvrz2BWCu0l48rO0K5ezsy0JFcQDVwE/5mw==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.4.0.tgz", + "integrity": "sha512-sJEHx2WYnvOgPpzP1eHnUdRS06zgKmRxbiIR0JiCcaSen5iv1HlsMieXy//FS9TtNW+abHOy4UtDuGuSPflPFA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.3.1", - "@angular-eslint/utils": "21.3.1", + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", "aria-query": "5.3.2", "axobject-query": "4.1.0" }, "peerDependencies": { - "@angular-eslint/template-parser": "21.3.1", + "@angular-eslint/template-parser": "21.4.0", "@typescript-eslint/types": "^7.11.0 || ^8.0.0", "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", @@ -404,14 +404,14 @@ } }, "node_modules/@angular-eslint/template-parser": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.3.1.tgz", - "integrity": "sha512-moERVCTekQKOvR8RMuEOtWSO3VS1qrzA3keI1dPto/JVB8Nqp9w3R5ZpEoXHzh4zgEryosxmPgdi6UczJe2ouQ==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.4.0.tgz", + "integrity": "sha512-BaUSLSyS+43fzDoJkTMkGqNdCXq3fGnUZsfXTmrlZPJf5AYFbgAlAPGZXDJyoNWw43fux+DafdlrlKcYUSgSIw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.3.1", + "@angular-eslint/bundled-angular-compiler": "21.4.0", "eslint-scope": "9.1.2" }, "peerDependencies": { @@ -420,13 +420,13 @@ } }, "node_modules/@angular-eslint/utils": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.3.1.tgz", - "integrity": "sha512-Q3SGA1/36phZhmsp1mYrKzp/jcmqofRr861MYn46FaWIKSYXBYRzl+H3FIJKBu5CE36Bggu6hbNpwGPuUp+MCg==", + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.4.0.tgz", + "integrity": "sha512-7pi+Ga7QmdH5Ig/diau6fR5L4yubgKr9TOjdCg7OeuE/zo0O3osTCNT6JOodzS/iQM1kSCJFDoIBKFeUOttiNw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.3.1" + "@angular-eslint/bundled-angular-compiler": "21.4.0" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", @@ -435,9 +435,9 @@ } }, "node_modules/@angular/animations": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.6.tgz", - "integrity": "sha512-SPzTOlkyVagPdb7OMe9hw3dnpMGq2p/nADatzNfRUMXwit8AU8VaiPIrFRsCD52sAL1zDDj60gKsk/dprzIyFA==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.14.tgz", + "integrity": "sha512-9WLnsJE0xqtd1rVtHMvsAUxFy3OdPks4bdmUIqyw23X/je7ytUALAGWNadffcZBwRpa1A6TUnLr9X4+Draz3kw==", "license": "MIT", "peer": true, "dependencies": { @@ -447,18 +447,18 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.6" + "@angular/core": "21.2.14" } }, "node_modules/@angular/build": { - "version": "21.2.5", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.5.tgz", - "integrity": "sha512-AfE09K+pkgS3VB84R74XG/XB9LQmO6Q6YfpssjDwMnWGwDGGwUGydXn8AKdhnhI4mM2nFKoe+QYszFgrzu5HeQ==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.12.tgz", + "integrity": "sha512-zYfo21RuldDWXnshuPfWYtmh5ltlO9+XFHpNObdIInQTFxKD6grLNVNOblFFpi+oIIm4Km+CGSXvBHs/aH0ufA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.5", + "@angular-devkit/architect": "0.2102.12", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -482,7 +482,7 @@ "source-map-support": "0.5.21", "tinyglobby": "0.2.15", "undici": "7.24.4", - "vite": "7.3.1", + "vite": "7.3.2", "watchpack": "2.5.1" }, "engines": { @@ -501,7 +501,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.5", + "@angular/ssr": "^21.2.12", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -551,9 +551,9 @@ } }, "node_modules/@angular/cdk": { - "version": "21.2.4", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.4.tgz", - "integrity": "sha512-Zv+q9Z/wVWTt0ckuO3gnU7PbpCLTr1tKPEsofLGGzDufA5/85aBLn2UiLcjlY6wQ+V3EMqANhGo/8XJgvBEYFA==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.12.tgz", + "integrity": "sha512-wB4FLlAdYzQp5htHVKn+fXlNxkFSNw89jPfsJKc15UiadCay6GdzYASLyLqtbk6D4Jz1pBHUpI2ib3mjkCcwxg==", "license": "MIT", "dependencies": { "parse5": "^8.0.0", @@ -567,20 +567,20 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.5", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.5.tgz", - "integrity": "sha512-nLpyqXQ0s96jC/vR8CsKM3q94/F/nZwtbjM3E6g5lXpKe7cHfJkCfERPexx+jzzYP5JBhtm+u61aH6auu9KYQw==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.12.tgz", + "integrity": "sha512-oLEL1C1fI39b1eQo5f2cyQhQfE+QMv7dm8z2MmxbP7YR7jAdQPVfGU8CXECR5g7mrYi9WgvIRKB+9Oeq2aH6Jw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@angular-devkit/architect": "0.2102.5", - "@angular-devkit/core": "21.2.5", - "@angular-devkit/schematics": "21.2.5", + "@angular-devkit/architect": "0.2102.12", + "@angular-devkit/core": "21.2.12", + "@angular-devkit/schematics": "21.2.12", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.5", + "@schematics/angular": "21.2.12", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -603,9 +603,9 @@ } }, "node_modules/@angular/common": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.6.tgz", - "integrity": "sha512-2FcpZ1h6AZ4JwCIlnpHCYrbRTGQTOj/RFXkuX/qw7K6cFmJGfWFMmr++xWtHZEvUddfbR9hqDo+v1mkqEKE/Kw==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.14.tgz", + "integrity": "sha512-J6K7cE7uKOKmg4+sxLeGfsmaYDjP5l1XCiMMI0WPT0t68uxLk8g3MzV5Trqfb6ZnRxWcfp9c4c+XxAvMBB7ymA==", "license": "MIT", "peer": true, "dependencies": { @@ -615,14 +615,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.6", + "@angular/core": "21.2.14", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.6.tgz", - "integrity": "sha512-shGkb/aAIPbG8oSYkVJ0msGlRdDVcJBVaUVx2KenMltifQjfLn5N8DFMAzOR6haaA3XeugFExxKqmvySjrVq+A==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.14.tgz", + "integrity": "sha512-8mqgwRYfn2Z1vg/5YVt60dDBattnZL45nNJd2vTMwAiDTzhWhgKgRWKOeVL0aj2JqHeHiwuIlrLnz46acJMulQ==", "license": "MIT", "peer": true, "dependencies": { @@ -633,9 +633,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.6.tgz", - "integrity": "sha512-CiPmat4+D+hWXMTAY++09WeII/5D0r6iTjdLdaTq8tlo0uJcrOlazib4CpA94kJ2CRdzfhmC1H+ttwBI1xIlTg==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.14.tgz", + "integrity": "sha512-h+WQfPKFxaDfDhMqUUdOQ1TsDMccav8kLFERmKTRfD4MNOczSMpOMyeXJHCL0Rq4I8WDQvaBJGMG7DXRDefSog==", "dev": true, "license": "MIT", "peer": true, @@ -657,7 +657,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.6", + "@angular/compiler": "21.2.14", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -667,9 +667,9 @@ } }, "node_modules/@angular/core": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.6.tgz", - "integrity": "sha512-svgK5DhFlQlS+sMybXftn08rHHRiDGY/uIKT5LZUaKgyffnkPb8uClpMIW0NzANtU8qs8pwgDZFoJw85Ia3oqQ==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.14.tgz", + "integrity": "sha512-Z1Ivjh7L2lT//8LA7vQ3tj7Rg6wl2XRA5kPSAukgn8u0Yu0XxG8NE8KG0Eypb3v9CEcbwATwpgnxzbJFZ8TFcw==", "license": "MIT", "peer": true, "dependencies": { @@ -679,7 +679,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.6", + "@angular/compiler": "21.2.14", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -693,9 +693,9 @@ } }, "node_modules/@angular/forms": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.6.tgz", - "integrity": "sha512-i8BoWxBAm0g2xOMcQ8wTdj07gqMPIFYIyefCOo0ezcGj5XhYjd+C2UrYnKsup0aMZqqEAO1l2aZbmfHx9xLheQ==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.14.tgz", + "integrity": "sha512-HQYIybyMt0CrI31rW6vXbiDsSM2DDtTcOVeT/nWDRNCoqBrREDg8rVsm2Y+fUMsiQVJNa6dCXPwvYhjzJ4r7ug==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -705,16 +705,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.6", - "@angular/core": "21.2.6", - "@angular/platform-browser": "21.2.6", + "@angular/common": "21.2.14", + "@angular/core": "21.2.14", + "@angular/platform-browser": "21.2.14", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.6.tgz", - "integrity": "sha512-LW1vPXVHvy71LBahn+fSzPlWQl25kJIdcXq+ptG7HsMVgbPQ3/vvkKXAHYaRdppLGCFL+v+3dQGHYLNLiYL9qg==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.14.tgz", + "integrity": "sha512-34tBwxh86yN2YifBDhCesm6N+nn9WcbuXjRwfo0mTme15OZ/zt56yw7v1mcK3UFLegIIALtsIgpXXrPWWQoKkA==", "license": "MIT", "peer": true, "dependencies": { @@ -724,9 +724,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "21.2.6", - "@angular/common": "21.2.6", - "@angular/core": "21.2.6" + "@angular/animations": "21.2.14", + "@angular/common": "21.2.14", + "@angular/core": "21.2.14" }, "peerDependenciesMeta": { "@angular/animations": { @@ -735,9 +735,9 @@ } }, "node_modules/@angular/router": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.6.tgz", - "integrity": "sha512-0ajhkKYeOqHQEEH88+Q0HrheR3helwTvdTqD/0gTaapCe+HOoC+SYwmzzsYP2zwAxBNQEg4JHOGKQ30X9/gwgw==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.14.tgz", + "integrity": "sha512-Yo3LdgcqkfMu2/Ycl8o/4QjCBqZhtA+a7B8JVdW5cWdrpFTxKCOrzm+YRUMuIFmH5nzSv9oGnUuz64uk1+7r5Q==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -746,20 +746,20 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.6", - "@angular/core": "21.2.6", - "@angular/platform-browser": "21.2.6", + "@angular/common": "21.2.14", + "@angular/core": "21.2.14", + "@angular/platform-browser": "21.2.14", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -768,9 +768,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -826,14 +826,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -856,14 +856,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -883,9 +883,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -893,29 +893,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -938,9 +938,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -948,9 +948,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -958,9 +958,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -968,27 +968,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -998,33 +998,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1032,35 +1032,35 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "peer": true, @@ -1069,9 +1069,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, "dependencies": { @@ -1572,9 +1572,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1646,9 +1646,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1670,9 +1670,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1766,9 +1766,9 @@ "optional": true }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "dev": true, "license": "MIT", "engines": { @@ -1779,29 +1779,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2195,9 +2209,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -2432,9 +2446,9 @@ } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -2446,9 +2460,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -2460,9 +2474,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -2474,9 +2488,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -2488,9 +2502,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -2502,9 +2516,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -2839,9 +2853,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { @@ -2857,9 +2871,9 @@ } }, "node_modules/@ng-icons/core": { - "version": "33.2.0", - "resolved": "https://registry.npmjs.org/@ng-icons/core/-/core-33.2.0.tgz", - "integrity": "sha512-BdAzCKZzLKuRPbZLBTmuBuCVI0Fk0WM+sPNzmhWv3PG+yXUKPpRt3O3dCIxqO5djb9S/x50OJkfOJH+IBxLUgg==", + "version": "33.2.2", + "resolved": "https://registry.npmjs.org/@ng-icons/core/-/core-33.2.2.tgz", + "integrity": "sha512-MwxREFbCx5p+x+Lx26V1FkHFpqHR/6g1TmBG7aYx+FFe1DIfrxUVXM+LW/Mviv3dN5sLE/Fi0ULP79Gz9uG0SQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2873,9 +2887,9 @@ } }, "node_modules/@ng-icons/tabler-icons": { - "version": "33.2.0", - "resolved": "https://registry.npmjs.org/@ng-icons/tabler-icons/-/tabler-icons-33.2.0.tgz", - "integrity": "sha512-qs0ezR36qaLxsbQQcYy4btPz1stVCv1nlghLfUA1pPdAmevfsYq69DoywRXfHkY8cCctqjwsyMg0lpKbNvzpPA==", + "version": "33.2.2", + "resolved": "https://registry.npmjs.org/@ng-icons/tabler-icons/-/tabler-icons-33.2.2.tgz", + "integrity": "sha512-2wGi9Up26FHPYPwC/LPURg1WsqL7Qdjp8D8iP3SLxeKlJfoladxUHand+t3ee8SVbGfRKMuvij4XUg52sQfOHg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2917,9 +2931,9 @@ } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2970,9 +2984,9 @@ } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3664,9 +3678,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -3678,9 +3692,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -3692,9 +3706,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -3706,9 +3720,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -3720,9 +3734,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -3734,9 +3748,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -3748,9 +3762,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -3762,9 +3776,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -3776,9 +3790,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], @@ -3790,9 +3804,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -3804,9 +3818,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], @@ -3818,9 +3832,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -3832,9 +3846,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], @@ -3846,9 +3860,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -3860,9 +3874,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -3874,9 +3888,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -3888,9 +3902,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -3902,9 +3916,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], @@ -3916,9 +3930,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -3930,9 +3944,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -3944,9 +3958,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -3958,9 +3972,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -3972,9 +3986,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -3986,9 +4000,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -4000,9 +4014,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -4014,14 +4028,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "21.2.5", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.5.tgz", - "integrity": "sha512-orOiXcG86t34ejqbkm7ZHEkGfwTU/ySYFgY7BOQdaYFCoNQXxtU87fZoHckJ2xYpVitoKTvbf1bxDDphXb3ycw==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.12.tgz", + "integrity": "sha512-eHoAbxd6Kdw9YIQeZO/6lBXTmKKi10t4WTujY8CM5v4qv1zoJu9yiwVeQp9y3e7/Sybz5Ec3m4FmQ0Tw8iVDiA==", "license": "MIT", "peer": true, "dependencies": { - "@angular-devkit/core": "21.2.5", - "@angular-devkit/schematics": "21.2.5", + "@angular-devkit/core": "21.2.12", + "@angular-devkit/schematics": "21.2.12", "jsonc-parser": "3.3.1" }, "engines": { @@ -4044,9 +4058,9 @@ } }, "node_modules/@sigstore/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.0.tgz", - "integrity": "sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4054,9 +4068,9 @@ } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.0.tgz", - "integrity": "sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", + "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4096,14 +4110,14 @@ } }, "node_modules/@sigstore/verify": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", - "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { @@ -4117,47 +4131,47 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -4171,9 +4185,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -4187,9 +4201,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -4203,9 +4217,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -4219,9 +4233,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -4235,9 +4249,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], @@ -4251,9 +4265,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], @@ -4267,9 +4281,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -4283,9 +4297,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], @@ -4299,9 +4313,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -4316,10 +4330,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -4328,9 +4342,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -4344,9 +4358,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -4360,16 +4374,16 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", - "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "postcss": "^8.5.6", - "tailwindcss": "4.2.2" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" } }, "node_modules/@tufjs/canonical-json": { @@ -4397,9 +4411,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "license": "MIT", "optional": true, "dependencies": { @@ -4414,9 +4428,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -4428,20 +4442,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", - "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/type-utils": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4451,23 +4465,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.2", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", - "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -4479,18 +4493,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", - "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.2", - "@typescript-eslint/types": "^8.57.2", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -4501,18 +4515,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4523,9 +4537,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -4536,21 +4550,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4561,13 +4575,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "peer": true, @@ -4580,21 +4594,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4604,21 +4618,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4629,17 +4643,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4907,9 +4921,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.12", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", - "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4973,9 +4987,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -4986,9 +5000,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -5007,11 +5021,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -5060,9 +5074,9 @@ } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5120,9 +5134,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -5342,9 +5356,9 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "dev": true, "license": "MIT", "engines": { @@ -5596,9 +5610,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.328", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", - "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "version": "1.5.362", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.362.tgz", + "integrity": "sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==", "dev": true, "license": "ISC" }, @@ -5620,13 +5634,13 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -5696,9 +5710,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -5890,9 +5904,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5914,9 +5928,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -6097,9 +6111,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", "dev": true, "license": "MIT", "engines": { @@ -6159,13 +6173,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -6198,9 +6212,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -6392,9 +6406,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { "node": ">=18" @@ -6536,9 +6550,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { @@ -6549,9 +6563,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "dev": true, "license": "MIT", "peer": true, @@ -6560,9 +6574,9 @@ } }, "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", "dependencies": { @@ -6573,9 +6587,9 @@ } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -6763,9 +6777,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { @@ -6887,18 +6901,18 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "dev": true, "license": "MIT", "funding": { @@ -7283,7 +7297,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -7519,9 +7532,9 @@ } }, "node_modules/make-fetch-happen": { - "version": "15.0.5", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", - "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", "dev": true, "license": "ISC", "dependencies": { @@ -7615,13 +7628,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -7781,9 +7794,9 @@ "license": "MIT" }, "node_modules/msgpackr": { - "version": "1.11.9", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.9.tgz", - "integrity": "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw==", + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", "dev": true, "license": "MIT", "optional": true, @@ -7792,9 +7805,9 @@ } }, "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7806,12 +7819,12 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, "node_modules/mute-stream": { @@ -7825,9 +7838,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -7888,21 +7901,21 @@ } }, "node_modules/node-gyp": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", - "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", + "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { @@ -7938,6 +7951,16 @@ "node": ">=20" } }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -7955,11 +7978,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "9.0.0", @@ -8313,12 +8339,12 @@ } }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -8366,12 +8392,12 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -8424,9 +8450,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -8434,9 +8460,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, "license": "MIT", "funding": { @@ -8495,9 +8521,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -8515,7 +8541,7 @@ "license": "MIT", "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8568,9 +8594,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -8814,9 +8840,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8988,9 +9014,9 @@ } }, "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -9004,34 +9030,41 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -9240,14 +9273,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -9308,18 +9341,18 @@ } }, "node_modules/sigstore": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", - "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", "dev": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9367,13 +9400,13 @@ } }, "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -9484,9 +9517,9 @@ } }, "node_modules/stdin-discarder": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", - "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", "license": "MIT", "engines": { "node": ">=18" @@ -9496,9 +9529,9 @@ } }, "node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -9553,15 +9586,15 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", "engines": { "node": ">=6" @@ -9572,9 +9605,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -9695,18 +9728,36 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -9825,9 +9876,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "peer": true, @@ -10042,9 +10093,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "engines": { "node": ">=8.3.0" diff --git a/code/frontend/src/app/app.config.ts b/code/frontend/src/app/app.config.ts index 1b1459df..79b8ffd4 100644 --- a/code/frontend/src/app/app.config.ts +++ b/code/frontend/src/app/app.config.ts @@ -53,6 +53,7 @@ import { tablerGripVertical, tablerFilter, tablerPalette, + tablerFileOff, } from '@ng-icons/tabler-icons'; import { routes } from './app.routes'; @@ -116,6 +117,7 @@ export const appConfig: ApplicationConfig = { tablerGripVertical, tablerFilter, tablerPalette, + tablerFileOff, }), ], }; diff --git a/code/frontend/src/app/core/api/download-cleaner.api.ts b/code/frontend/src/app/core/api/download-cleaner.api.ts index 1b0f1f53..7450422a 100644 --- a/code/frontend/src/app/core/api/download-cleaner.api.ts +++ b/code/frontend/src/app/core/api/download-cleaner.api.ts @@ -1,7 +1,12 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { DownloadCleanerConfig, SeedingRule, UnlinkedConfigModel } from '@shared/models/download-cleaner-config.model'; +import { + DownloadCleanerConfig, + SeedingRule, + UnlinkedConfigModel, + OrphanedFilesConfig, +} from '@shared/models/download-cleaner-config.model'; @Injectable({ providedIn: 'root' }) export class DownloadCleanerApi { @@ -37,11 +42,12 @@ export class DownloadCleanerApi { } // Unlinked config - getUnlinkedConfig(clientId: string): Observable { - return this.http.get(`/api/unlinked-config/${clientId}`); - } - updateUnlinkedConfig(clientId: string, config: Partial): Observable { return this.http.put(`/api/unlinked-config/${clientId}`, config); } + + // Per-client orphaned files config + updateOrphanedFilesConfig(clientId: string, config: Partial): Observable { + return this.http.put(`/api/orphaned-files-config/${clientId}`, config); + } } diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index b555203c..beb55ec0 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -89,6 +89,12 @@ export class DocumentationService { 'downloadDirectoryTarget': 'download-directory-source-and-local-directory-target', 'unlinkedIgnoredRootDir': 'ignored-root-directory', 'unlinkedCategories': 'unlinked-categories', + 'orphanedFilesEnabled': 'enabled-per-client', + 'orphanedFilesScanDirectories': 'scan-directories', + 'orphanedFilesOrphanedDirectory': 'orphaned-directory', + 'orphanedFilesExcludePatterns': 'exclude-patterns', + 'orphanedFilesMinFileAgeHours': 'min-file-age', + 'orphanedFilesPurgeAfterHours': 'purge-orphaned-after', }, 'malware-blocker': { 'enabled': 'enable-malware-blocker', diff --git a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html index 693b56ef..352d64aa 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html +++ b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html @@ -55,7 +55,7 @@ }
- {{ saved() ? 'Saved!' : 'Save Settings' }} + {{ saved() ? 'Saved!' : 'Save' }}
@@ -178,19 +178,6 @@
- - - -
- - {{ unlinkedSaved() ? 'Saved!' : 'Save Unlinked Config' }} + {{ unlinkedSaved() ? 'Saved!' : 'Save' }} + + + + + + +
+ + @if (client.orphanedFilesConfig?.enabled) { +
+ + + + + + } +
+ + {{ orphanedFilesSaved() ? 'Saved!' : 'Save' }}
diff --git a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts index 5c36dc7b..7af1b651 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts +++ b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts @@ -15,7 +15,8 @@ import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { DownloadCleanerConfig, SeedingRule, ClientCleanerConfig, UnlinkedConfigModel, - createDefaultUnlinkedConfig, + OrphanedFilesConfig, + createDefaultUnlinkedConfig, createDefaultOrphanedFilesConfig, } from '@shared/models/download-cleaner-config.model'; import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model'; import { ScheduleUnit, TorrentPrivacyType, DownloadClientTypeName } from '@shared/models/enums'; @@ -62,6 +63,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { ); private readonly savedSnapshot = signal(''); + private readonly orphanedFilesSnapshots = signal>({}); readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS; readonly privacyTypeOptions = PRIVACY_TYPE_OPTIONS; @@ -71,6 +73,8 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { readonly saved = signal(false); readonly unlinkedSaving = signal(false); readonly unlinkedSaved = signal(false); + readonly orphanedFilesSaving = signal(false); + readonly orphanedFilesSaved = signal(false); readonly rulesReloading = signal(false); private readonly unlinkedSnapshots = signal>({}); @@ -115,6 +119,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { readonly seedingRulesExpanded = signal(false); readonly unlinkedExpanded = signal(false); + readonly orphanedFilesExpanded = signal(false); // Seeding rule modal readonly ruleModalVisible = signal(false); @@ -148,25 +153,35 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { } readonly scheduleEveryError = computed(() => { - if (this.useAdvancedScheduling()) return undefined; + if (this.useAdvancedScheduling()) { + return undefined; + } const unit = this.scheduleUnit() as ScheduleUnit; const options = ScheduleOptions[unit] ?? []; - if (!options.includes(this.scheduleEvery() as number)) return 'Please select a value'; + if (!options.includes(this.scheduleEvery() as number)) { + return 'Please select a value'; + } return undefined; }); readonly cronError = computed(() => { - if (this.useAdvancedScheduling() && !this.cronExpression().trim()) return 'Cron expression is required'; + if (this.useAdvancedScheduling() && !this.cronExpression().trim()) { + return 'Cron expression is required'; + } return undefined; }); readonly ruleNameError = computed(() => { - if (!this.ruleName().trim()) return 'Name is required'; + if (!this.ruleName().trim()) { + return 'Name is required'; + } return undefined; }); readonly ruleCategoriesError = computed(() => { - if (this.ruleCategories().length === 0) return 'At least one category is required'; + if (this.ruleCategories().length === 0) { + return 'At least one category is required'; + } return undefined; }); @@ -179,25 +194,67 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { readonly unlinkedCategoriesError = computed(() => { const client = this.selectedClient(); - if (!client?.unlinkedConfig?.enabled) return undefined; + if (!client?.unlinkedConfig?.enabled) { + return undefined; + } if ((client.unlinkedConfig.categories ?? []).length === 0) { return 'At least one category is required'; } return undefined; }); + readonly orphanedFilesScanDirsError = computed(() => { + const client = this.selectedClient(); + if (!client?.orphanedFilesConfig?.enabled) { + return undefined; + } + if ((client.orphanedFilesConfig.scanDirectories ?? []).length === 0) { + return 'At least one scan directory is required'; + } + return undefined; + }); + + readonly orphanedFilesOrphanedDirError = computed(() => { + const client = this.selectedClient(); + if (!client?.orphanedFilesConfig?.enabled) { + return undefined; + } + if (!client.orphanedFilesConfig.orphanedDirectory?.trim()) { + return 'Orphaned directory is required'; + } + return undefined; + }); + readonly unlinkedDirty = computed(() => { const client = this.selectedClient(); - if (!client) return false; - const saved = this.unlinkedSnapshots()[client.downloadClientId]; - if (!saved) return false; + if (!client) { + return false; + } + const saved = this.unlinkedSnapshots()[client.downloadClientId] + ?? JSON.stringify(createDefaultUnlinkedConfig()); return saved !== JSON.stringify(client.unlinkedConfig); }); + readonly orphanedFilesDirty = computed(() => { + const client = this.selectedClient(); + if (!client) { + return false; + } + const saved = this.orphanedFilesSnapshots()[client.downloadClientId] + ?? JSON.stringify(createDefaultOrphanedFilesConfig()); + return saved !== JSON.stringify(client.orphanedFilesConfig); + }); + readonly hasGlobalErrors = computed(() => { - if (this.scheduleEveryError()) return true; - if (this.cronError()) return true; - if (this.chipInputs().some(c => c.hasUncommittedInput())) return true; + if (this.scheduleEveryError()) { + return true; + } + if (this.cronError()) { + return true; + } + if (this.chipInputs().some(c => c.hasUncommittedInput())) { + return true; + } return false; }); @@ -210,35 +267,43 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { private loadConfig(): void { this.loader.start(); this.api.getConfig().subscribe({ - next: (config) => { - this.config = config; - this.enabled.set(config.enabled); - this.useAdvancedScheduling.set(config.useAdvancedScheduling); - this.cronExpression.set(config.cronExpression); - const parsed = parseCronToJobSchedule(config.cronExpression); + next: (dc) => { + this.config = dc; + this.enabled.set(dc.enabled); + this.useAdvancedScheduling.set(dc.useAdvancedScheduling); + this.cronExpression.set(dc.cronExpression); + const parsed = parseCronToJobSchedule(dc.cronExpression); if (parsed) { this.scheduleEvery.set(parsed.every); this.scheduleUnit.set(parsed.type); } - this.ignoredDownloads.set(config.ignoredDownloads ?? []); - this.clientConfigs.set((config.clients ?? []).map(c => ({ + this.ignoredDownloads.set(dc.ignoredDownloads ?? []); + + this.clientConfigs.set((dc.clients ?? []).map(c => ({ ...c, seedingRules: c.seedingRules ?? [], unlinkedConfig: c.unlinkedConfig ?? createDefaultUnlinkedConfig(), + orphanedFilesConfig: c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig(), }))); - if (config.clients?.length > 0) { - this.selectedClientId.set(config.clients[0].downloadClientId); + + if (dc.clients?.length > 0) { + this.selectedClientId.set(dc.clients[0].downloadClientId); } - // Save unlinked config snapshots per client - const snapshots: Record = {}; - for (const c of config.clients ?? []) { - snapshots[c.downloadClientId] = JSON.stringify(c.unlinkedConfig ?? createDefaultUnlinkedConfig()); + + const unlinkedSnapshots: Record = {}; + const orphanedFilesSnapshots: Record = {}; + for (const c of dc.clients ?? []) { + unlinkedSnapshots[c.downloadClientId] = JSON.stringify(c.unlinkedConfig ?? createDefaultUnlinkedConfig()); + orphanedFilesSnapshots[c.downloadClientId] = JSON.stringify(c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig()); } - this.unlinkedSnapshots.set(snapshots); + this.unlinkedSnapshots.set(unlinkedSnapshots); + this.orphanedFilesSnapshots.set(orphanedFilesSnapshots); this.loader.stop(); // Defer snapshot so constructor effects (e.g. schedule unit clamping) settle first - queueMicrotask(() => this.savedSnapshot.set(this.buildSnapshot())); + queueMicrotask(() => { + this.savedSnapshot.set(this.buildSnapshot()); + }); }, error: () => { this.toast.error('Failed to load download cleaner settings'); @@ -284,9 +349,13 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { } saveRule(): void { - if (this.ruleNameError() || this.ruleCategoriesError() || this.ruleDisabledError() || this.ruleHasUncommittedInputs()) return; + if (this.ruleNameError() || this.ruleCategoriesError() || this.ruleDisabledError() || this.ruleHasUncommittedInputs()) { + return; + } const clientId = this.selectedClientId(); - if (!clientId) return; + if (!clientId) { + return; + } const sanitize = (list: string[]) => list.map(s => s.trim()).filter(s => s.length > 0); @@ -325,9 +394,13 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { confirmLabel: 'Delete', destructive: true, }); - if (!confirmed || !rule.id) return; + if (!confirmed || !rule.id) { + return; + } const clientId = this.selectedClientId(); - if (!clientId) return; + if (!clientId) { + return; + } this.api.deleteSeedingRule(rule.id).subscribe({ next: () => { @@ -340,7 +413,9 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { onRulesReorder(event: CdkDragDrop): void { const clientId = this.selectedClientId(); - if (!clientId) return; + if (!clientId) { + return; + } const rules = [...(this.selectedClient()?.seedingRules ?? [])]; moveItemInArray(rules, event.previousIndex, event.currentIndex); @@ -375,14 +450,16 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { } async onClientChange(newClientId: unknown): Promise { - if (this.unlinkedDirty()) { + if (this.unlinkedDirty() || this.orphanedFilesDirty()) { const confirmed = await this.confirm.confirm({ title: 'Unsaved Changes', - message: 'You have unsaved unlinked config changes. Discard them?', + message: 'You have unsaved changes for this client. Discard them?', confirmLabel: 'Discard', destructive: true, }); - if (!confirmed) return; + if (!confirmed) { + return; + } } this.selectedClientId.set(newClientId as string | null); } @@ -402,7 +479,9 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { saveUnlinkedConfig(): void { const clientId = this.selectedClientId(); const client = this.selectedClient(); - if (!clientId || !client?.unlinkedConfig) return; + if (!clientId || !client?.unlinkedConfig) { + return; + } this.unlinkedSaving.set(true); this.api.updateUnlinkedConfig(clientId, client.unlinkedConfig).subscribe({ @@ -411,7 +490,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { this.unlinkedSaving.set(false); this.unlinkedSaved.set(true); setTimeout(() => this.unlinkedSaved.set(false), 1500); - // Update snapshot for this client this.unlinkedSnapshots.update(s => ({ ...s, [clientId]: JSON.stringify(client.unlinkedConfig), @@ -424,9 +502,48 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { }); } + // --- Orphaned files per-client config --- + + updateOrphanedFilesField(field: K, value: OrphanedFilesConfig[K]): void { + this.updateSelectedClient(client => ({ + ...client, + orphanedFilesConfig: { + ...(client.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig()), + [field]: value, + }, + })); + } + + saveOrphanedFilesConfig(): void { + const clientId = this.selectedClientId(); + const client = this.selectedClient(); + if (!clientId || !client?.orphanedFilesConfig) { + return; + } + this.orphanedFilesSaving.set(true); + this.api.updateOrphanedFilesConfig(clientId, client.orphanedFilesConfig).subscribe({ + next: () => { + this.toast.success('Orphaned files settings saved'); + this.orphanedFilesSaving.set(false); + this.orphanedFilesSaved.set(true); + setTimeout(() => this.orphanedFilesSaved.set(false), 1500); + this.orphanedFilesSnapshots.update(s => ({ + ...s, + [clientId]: JSON.stringify(client.orphanedFilesConfig), + })); + }, + error: (err: ApiError) => { + this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save orphaned files settings'); + this.orphanedFilesSaving.set(false); + }, + }); + } + private updateSelectedClient(updater: (client: ClientCleanerConfig) => ClientCleanerConfig): void { const id = this.selectedClientId(); - if (!id) return; + if (!id) { + return; + } this.clientConfigs.update(configs => configs.map(c => c.downloadClientId === id ? updater(c) : c) ); @@ -435,7 +552,9 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { // --- Global config save --- save(): void { - if (!this.config) return; + if (!this.config) { + return; + } const jobSchedule = { every: (this.scheduleEvery() as number) ?? 5, type: this.scheduleUnit() as ScheduleUnit }; const cronExpression = this.useAdvancedScheduling() @@ -484,6 +603,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges { }); hasPendingChanges(): boolean { - return this.dirty() || this.unlinkedDirty(); + return this.dirty() || this.unlinkedDirty() || this.orphanedFilesDirty(); } } diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html index f381e898..951d82a7 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html @@ -87,6 +87,12 @@ + +
diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts index af59d29e..76e9b46f 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts @@ -57,15 +57,21 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges { readonly modalPassword = signal(''); readonly modalUrlBase = signal(''); readonly modalExternalUrl = signal(''); + readonly modalDownloadDirectorySource = signal(''); + readonly modalDownloadDirectoryTarget = signal(''); readonly testing = signal(false); // Modal validation readonly modalNameError = computed(() => { - if (!this.modalName().trim()) return 'Name is required'; + if (!this.modalName().trim()) { + return 'Name is required'; + } return undefined; }); readonly modalHostError = computed(() => { - if (!this.modalHost().trim()) return 'Host is required'; + if (!this.modalHost().trim()) { + return 'Host is required'; + } return undefined; }); readonly hasModalErrors = computed(() => !!( @@ -146,6 +152,8 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges { this.modalPassword.set(''); this.modalUrlBase.set(''); this.modalExternalUrl.set(''); + this.modalDownloadDirectorySource.set(''); + this.modalDownloadDirectoryTarget.set(''); this.modalVisible.set(true); } @@ -159,6 +167,8 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges { this.modalPassword.set(client.password ?? ''); this.modalUrlBase.set(client.urlBase); this.modalExternalUrl.set(client.externalUrl ?? ''); + this.modalDownloadDirectorySource.set(client.downloadDirectorySource ?? ''); + this.modalDownloadDirectoryTarget.set(client.downloadDirectoryTarget ?? ''); this.modalVisible.set(true); } @@ -186,7 +196,9 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges { } saveClient(): void { - if (this.hasModalErrors()) return; + if (this.hasModalErrors()) { + return; + } const editing = this.editingClient(); this.saving.set(true); @@ -201,6 +213,8 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges { password: this.modalPassword() || undefined, urlBase: this.modalUrlBase(), externalUrl: this.modalExternalUrl() || undefined, + downloadDirectorySource: this.modalDownloadDirectorySource() || null, + downloadDirectoryTarget: this.modalDownloadDirectoryTarget() || null, }; this.api.update(editing.id, client).subscribe({ next: () => { @@ -225,6 +239,8 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges { password: this.modalPassword(), urlBase: this.modalUrlBase(), externalUrl: this.modalExternalUrl() || undefined, + downloadDirectorySource: this.modalDownloadDirectorySource() || null, + downloadDirectoryTarget: this.modalDownloadDirectoryTarget() || null, }; this.api.create(dto).subscribe({ next: () => { @@ -248,7 +264,9 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges { confirmLabel: 'Delete', destructive: true, }); - if (!confirmed) return; + if (!confirmed) { + return; + } this.api.delete(client.id).subscribe({ next: () => { diff --git a/code/frontend/src/app/shared/models/download-cleaner-config.model.ts b/code/frontend/src/app/shared/models/download-cleaner-config.model.ts index 06eba7fb..757bb5a5 100644 --- a/code/frontend/src/app/shared/models/download-cleaner-config.model.ts +++ b/code/frontend/src/app/shared/models/download-cleaner-config.model.ts @@ -21,8 +21,15 @@ export interface UnlinkedConfigModel { useTag: boolean; ignoredRootDirs: string[]; categories: string[]; - downloadDirectorySource: string | null; - downloadDirectoryTarget: string | null; +} + +export interface OrphanedFilesConfig { + enabled: boolean; + scanDirectories: string[]; + orphanedDirectory: string; + excludePatterns: string[]; + minFileAgeHours: number; + purgeAfterHours?: number; } export interface ClientCleanerConfig { @@ -32,6 +39,7 @@ export interface ClientCleanerConfig { downloadClientTypeName: string; seedingRules: SeedingRule[]; unlinkedConfig: UnlinkedConfigModel | null; + orphanedFilesConfig: OrphanedFilesConfig | null; } export interface DownloadCleanerConfig { @@ -65,7 +73,15 @@ export function createDefaultUnlinkedConfig(): UnlinkedConfigModel { useTag: false, ignoredRootDirs: [], categories: [], - downloadDirectorySource: null, - downloadDirectoryTarget: null, + }; +} + +export function createDefaultOrphanedFilesConfig(): OrphanedFilesConfig { + return { + enabled: false, + scanDirectories: [], + orphanedDirectory: '', + excludePatterns: [], + minFileAgeHours: 24, }; } diff --git a/code/frontend/src/app/shared/models/download-client-config.model.ts b/code/frontend/src/app/shared/models/download-client-config.model.ts index ed71c9e2..e031142f 100644 --- a/code/frontend/src/app/shared/models/download-client-config.model.ts +++ b/code/frontend/src/app/shared/models/download-client-config.model.ts @@ -11,6 +11,8 @@ export interface ClientConfig { password?: string; urlBase: string; externalUrl?: string; + downloadDirectorySource?: string | null; + downloadDirectoryTarget?: string | null; } export interface DownloadClientConfig { @@ -27,6 +29,8 @@ export interface CreateDownloadClientDto { password?: string; urlBase?: string; externalUrl?: string; + downloadDirectorySource?: string | null; + downloadDirectoryTarget?: string | null; } export interface TestDownloadClientRequest { diff --git a/docs/docs/2_features.mdx b/docs/docs/2_features.mdx index b328747b..948c9560 100644 --- a/docs/docs/2_features.mdx +++ b/docs/docs/2_features.mdx @@ -138,6 +138,18 @@ Advanced download management and automation features for your *arr applications + + +- Scan configured directories for **files and folders not referenced by any active torrent** across all download clients. +- Move detected orphans to a dedicated **Orphaned Directory** for later review. + + + +Orphaned Files + +

+ The Orphaned Files feature scans configured directories for files and directories no longer tracked by any active torrent and moves them to a dedicated orphaned directory. It runs as part of the Download Cleaner job and shares its schedule. Supported for all download clients: qBittorrent, Transmission, Deluge, rTorrent, and uTorrent. +

+ +

+ All Orphaned Files settings are configured per download client in the Orphaned Files accordion inside the per-client section. +

+ + + +Glob patterns for file or directory names that should never be considered orphaned, even if no active torrent claims them. Matching is case-insensitive and applied to the entry name only, not the full path. + +**Examples:** +``` +*.nfo +*.txt +.DS_Store +Thumbs.db +``` + + +Use `*` as a wildcard for any characters. For example, `*.nfo` matches any file ending in `.nfo` regardless of its location inside the scan directory. + + + + + + +Minimum age in hours a file or directory must have before it can be considered orphaned. This protects files that are actively being downloaded or have just finished — they may not yet be registered as a torrent save path. + +Defaults to `24` hours. Set to `0` to disable the age check. + +**Example:** Set to `1` to skip any entry modified less than an hour ago. + + + + + +Number of hours after which entries in the Orphaned Directory are permanently deleted. Leave empty to keep orphaned files indefinitely. + +**Example:** Set to `720` to automatically purge orphaned files older than 30 days. + + +Files deleted by this option are permanently removed from disk. Review the contents of your Orphaned Directory before enabling this setting. + + + + + + +Enable orphaned files scanning for this specific download client. The download client must also be enabled in Download Client settings. + + + + + +Absolute paths to scan for orphaned files for this download client. Each top-level entry inside these directories is checked against the save paths of all active torrents across all enabled clients. If no active torrent claims an entry, it is considered orphaned. + +**Examples:** +``` +/data/downloads/completed +/data/downloads/cross-seed +``` + + +Each path must be accessible by Cleanuparr. If running in Docker, make sure to mount the directories accordingly. + + + +Scan directories must not overlap (be equal to, a parent of, or a subdirectory of) the scan directories or orphaned directory of any other download client. Cleanuparr enforces this at save time to prevent cross-client false positives. + + + + + + +The directory where orphaned files and directories are moved for this download client. Required when the orphaned files cleanup is enabled. + +**Example:** +``` +/data/downloads/orphaned +``` + + +The orphaned directory itself is never scanned for orphans. If the destination already contains an entry with the same name, a timestamp suffix is appended automatically to avoid collisions. + + + + +
+ +
+ Unlinked Download Settings

diff --git a/e2e/.gitignore b/e2e/.gitignore index 7e0f9045..785154b4 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -2,3 +2,4 @@ node_modules/ test-results/ playwright-report/ blob-report/ +test-data/ diff --git a/e2e/Makefile b/e2e/Makefile index 49757f52..bd46ac1c 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -1,10 +1,13 @@ -.PHONY: up down test install +.PHONY: up down test install setup -up: - docker compose -f docker-compose.e2e.yml up -d --build +setup: + bash ./scripts/setup-test-data.sh + +up: setup + docker compose -f docker-compose.e2e.yml up -d --build --remove-orphans down: - docker compose -f docker-compose.e2e.yml down + docker compose -f docker-compose.e2e.yml down -v install: npm install diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 6f3c44f3..62723b4f 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -35,6 +35,8 @@ services: HTTP_PORTS: "5000" tmpfs: - /config + volumes: + - ./test-data/downloads:/e2e-downloads nginx: image: nginx:1.27-alpine @@ -43,3 +45,100 @@ services: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro depends_on: - app + + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:4.6.7 + network_mode: host + environment: + PUID: "1000" + PGID: "1000" + TZ: "UTC" + WEBUI_PORT: "8090" + TORRENTING_PORT: "6881" + volumes: + - ./test-data/qbittorrent-config:/config + - ./test-data/downloads/qbittorrent:/downloads + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:8090/api/v2/app/version > /dev/null 2>&1 || exit 1"] + interval: 5s + timeout: 3s + retries: 60 + start_period: 30s + + transmission: + image: lscr.io/linuxserver/transmission:4.0.6 + network_mode: host + environment: + PUID: "1000" + PGID: "1000" + TZ: "UTC" + USER: "transmission" + PASS: "transmission" + PEERPORT: "51413" + volumes: + - ./test-data/transmission-config:/config + - ./test-data/downloads/transmission:/downloads + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:9091/transmission/rpc > /dev/null 2>&1 ; [ $$? -eq 8 ] || [ $$? -eq 0 ]"] + interval: 5s + timeout: 3s + retries: 60 + start_period: 30s + + deluge: + image: lscr.io/linuxserver/deluge:2.1.1 + network_mode: host + environment: + PUID: "1000" + PGID: "1000" + TZ: "UTC" + DELUGE_LOGLEVEL: "info" + volumes: + - ./test-data/deluge-config:/config + - ./test-data/downloads/deluge:/downloads + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:8112 > /dev/null 2>&1 || exit 1"] + interval: 5s + timeout: 3s + retries: 60 + start_period: 30s + + utorrent: + image: ekho/utorrent:latest + platform: linux/amd64 + ports: + - "8083:8080" + environment: + UID: "1000" + GID: "1000" + dir_root: "/downloads" + dir_active: "/downloads" + dir_completed: "/downloads" + dir_download: "/downloads" + volumes: + - ./test-data/utorrent-config:/data + - ./test-data/downloads/utorrent:/downloads + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/gui/ > /dev/null 2>&1 || exit 1"] + interval: 5s + timeout: 3s + retries: 60 + start_period: 30s + + rutorrent: + image: lscr.io/linuxserver/rutorrent:latest + ports: + - "8088:80" + environment: + PUID: "1000" + PGID: "1000" + TZ: "UTC" + volumes: + - ./test-data/rutorrent-config:/config + - ./test-data/downloads/rtorrent:/downloads + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost > /dev/null 2>&1 || exit 1"] + interval: 5s + timeout: 3s + retries: 60 + start_period: 60s diff --git a/e2e/scripts/setup-test-data.sh b/e2e/scripts/setup-test-data.sh new file mode 100644 index 00000000..36ed9f19 --- /dev/null +++ b/e2e/scripts/setup-test-data.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# Prepare the e2e/test-data tree before `docker compose up`. +# +# Re-creates the qBittorrent config from scratch on every run +# +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_DATA="$HERE/test-data" + +mkdir -p \ + "$TEST_DATA/downloads/qbittorrent" \ + "$TEST_DATA/downloads/transmission" \ + "$TEST_DATA/downloads/deluge" \ + "$TEST_DATA/downloads/utorrent" \ + "$TEST_DATA/downloads/rtorrent" \ + "$TEST_DATA/qbittorrent-config/qBittorrent" \ + "$TEST_DATA/transmission-config" \ + "$TEST_DATA/deluge-config" \ + "$TEST_DATA/utorrent-config" \ + "$TEST_DATA/rutorrent-config" + +chmod -R a+rwX "$TEST_DATA" 2>/dev/null || true + +# qBittorrent credentials: admin / adminadmin +cat > "$TEST_DATA/qbittorrent-config/qBittorrent/qBittorrent.conf" <<'EOF' +[LegalNotice] +Accepted=true + +[Preferences] +WebUI\Port=8090 +WebUI\Address=* +WebUI\CSRFProtection=false +WebUI\HostHeaderValidation=false +WebUI\LocalHostAuth=false +WebUI\AuthSubnetWhitelistEnabled=true +WebUI\AuthSubnetWhitelist=127.0.0.0/8, ::1/128 +WebUI\Username=admin +WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZ366igo9pHIQ==:Bn3qWLqOY3qE6Z+sCx2NoO5q4nhgxhUL3eRD4Zw3+5p9C7+RmrI20bzAjcwHKqcWa+5z6QBQGckCB8sFCnVTGw==)" +Downloads\SavePath=/downloads +EOF + +echo "test-data ready under $TEST_DATA" diff --git a/e2e/tests/16-orphaned-files-cleanup.spec.ts b/e2e/tests/16-orphaned-files-cleanup.spec.ts new file mode 100644 index 00000000..9d902755 --- /dev/null +++ b/e2e/tests/16-orphaned-files-cleanup.spec.ts @@ -0,0 +1,221 @@ +import { test, expect } from '@playwright/test'; +import { existsSync, mkdirSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + loginAndGetToken, + createDownloadClient, + listDownloadClients, + deleteDownloadClient, + updateDownloadCleanerConfig, + getDownloadCleanerConfig, + updateOrphanedFilesConfig, + triggerJob, +} from './helpers/app-api'; +import { ALL_CLIENTS, TorrentClientFixture } from './helpers/torrent-clients'; +import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; + +async function waitForTorrents( + driver: { listTorrents(): Promise> }, + expectedHashes: string[], + timeoutMs = 15_000, +): Promise { + const want = new Set(expectedHashes.map((h) => h.toLowerCase())); + const start = Date.now(); + let last: Set = new Set(); + while (Date.now() - start < timeoutMs) { + const list = await driver.listTorrents(); + last = new Set(list.map((t) => t.hash.toLowerCase())); + if ([...want].every((h) => last.has(h))) return; + await new Promise((r) => setTimeout(r, 500)); + } + const missing = [...want].filter((h) => !last.has(h)); + throw new Error(`Torrents missing after ${timeoutMs}ms: ${missing.join(', ')} (saw [${[...last].join(', ')}])`); +} + +async function waitForOrphanMove(dir: string, expectedName: string, timeoutMs = 45_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (existsSync(dir)) { + const entries = readdirSync(dir); + const moved = entries.find((e) => e === expectedName || e.startsWith(`${expectedName}_`)); + if (moved) return moved; + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`Timed out waiting for orphan "${expectedName}" to appear under ${dir}`); +} + +/** + * Orphaned files cleanup e2e — exercises the full pipeline for every + * supported download client: + * + * 1. configure the download cleaner globally (enabled, generous schedule) + * 2. configure the orphaned files cleanup globally (no min age, no purge) + * 3. spin up the client and pre-create two torrents whose data lives in + * /e2e-downloads// + * 4. delete one of those torrents through the client's API while keeping + * data on disk → produces a real orphan + * 5. trigger the DownloadCleaner job + * 6. assert the surviving torrent's files are untouched and the orphan's + * files were moved into /e2e-downloads//orphaned/ + * + * The downloads volume is bind-mounted at the same path inside every + * container (`/e2e-downloads`) and on the host (`e2e/test-data/downloads`) + * so the spec can assert directly against host paths without any + * DownloadDirectorySource/Target remapping. + */ + +const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const CLIENT_DOWNLOADS = '/downloads'; +const APP_DOWNLOADS = '/e2e-downloads'; + +function clientDirs(slug: string) { + return { + hostScanDir: join(HOST_DOWNLOADS, slug), + hostOrphanedDir: join(HOST_DOWNLOADS, slug, 'orphaned'), + clientSavePath: CLIENT_DOWNLOADS, + appScanDir: `${APP_DOWNLOADS}/${slug}`, + appOrphanedDir: `${APP_DOWNLOADS}/${slug}/orphaned`, + }; +} + +const SLUG_BY_TYPE: Record = { + qBittorrent: 'qbittorrent', + Transmission: 'transmission', + Deluge: 'deluge', + uTorrent: 'utorrent', + rTorrent: 'rtorrent', +}; + +test.describe.serial('Orphaned files cleanup', () => { + let token: string; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + + // Reset all existing download clients so the spec starts from a clean slate. + const existing = await listDownloadClients(token); + for (const client of existing) { + await deleteDownloadClient(token, client.id); + } + + // Enable the global download cleaner + the global orphaned-files config. + // Schedule is irrelevant since we trigger the job manually. + const dcCurrent = await (await getDownloadCleanerConfig(token)).json(); + await updateDownloadCleanerConfig(token, { + enabled: true, + cronExpression: dcCurrent.cronExpression || '0 0 * * * ?', + useAdvancedScheduling: dcCurrent.useAdvancedScheduling ?? false, + ignoredDownloads: [], + }); + + mkdirSync(HOST_DOWNLOADS, { recursive: true }); + }); + + for (const fixture of ALL_CLIENTS) { + runClientScenario(fixture, () => token); + } +}); + +function runClientScenario(fixture: TorrentClientFixture, getToken: () => string) { + const { driver } = fixture; + const slug = SLUG_BY_TYPE[driver.typeName]; + const describeFn = fixture.enabled ? test.describe : test.describe.skip; + + describeFn(`${driver.typeName}`, () => { + let keep: { name: string; infoHash: string }; + let orphan: { name: string; infoHash: string }; + let clientId: string; + const dirs = clientDirs(slug); + + test('configures client and produces an orphan', async () => { + test.setTimeout(180_000); + + // Fresh per-client scan dir so a previous failed run doesn't bleed in. + resetDirectory(dirs.hostScanDir); + mkdirSync(dirs.hostOrphanedDir, { recursive: true }); + chmodIgnoringEPERM(dirs.hostOrphanedDir, 0o777); + + const keepName = `keep-${slug}`; + const orphanName = `orphan-${slug}`; + const keepFx = buildFolderTorrent(dirs.hostScanDir, keepName); + const orphanFx = buildFolderTorrent(dirs.hostScanDir, orphanName); + keep = { name: keepName, infoHash: keepFx.infoHash }; + orphan = { name: orphanName, infoHash: orphanFx.infoHash }; + + // Wait for the client's HTTP surface to come up. This is the slowest + // step on a cold compose start. + await driver.ready(); + + // Wipe any torrents left over from a prior `make test` run — the + // client's session is in a persistent config volume that survives + // `make test` and would otherwise reject re-adding the same infohash. + await driver.clearAllTorrents(); + + const createRes = await createDownloadClient(getToken(), { + enabled: true, + name: `${driver.typeName} e2e`, + typeName: driver.typeName, + type: 'Torrent', + host: driver.cleanuparrHost, + username: driver.username ?? '', + password: driver.password ?? '', + downloadDirectorySource: dirs.clientSavePath, + downloadDirectoryTarget: dirs.appScanDir, + }); + expect(createRes.status).toBeGreaterThanOrEqual(200); + expect(createRes.status).toBeLessThan(300); + const createdClient = await createRes.json(); + clientId = createdClient.id; + + const ofcRes = await updateOrphanedFilesConfig(getToken(), clientId, { + enabled: true, + scanDirectories: [dirs.appScanDir], + orphanedDirectory: dirs.appOrphanedDir, + minFileAgeHours: 0, + }); + expect(ofcRes.status).toBe(200); + + await driver.addTorrent({ + metainfo: keepFx.metainfo, + savePath: dirs.clientSavePath, + name: keepName, + infoHash: keepFx.infoHash, + }); + await driver.addTorrent({ + metainfo: orphanFx.metainfo, + savePath: dirs.clientSavePath, + name: orphanName, + infoHash: orphanFx.infoHash, + }); + + // Some clients process `add` asynchronously — poll for both torrents + // to become visible before continuing. + await waitForTorrents(driver, [keep.infoHash, orphan.infoHash]); + + // Delete the orphan torrent from the client while preserving data. + await driver.deleteTorrent(orphan.infoHash); + + // Verify orphan is gone from the client but still present on disk. + const afterList = await driver.listTorrents(); + const afterHashes = new Set(afterList.map((t) => t.hash.toLowerCase())); + expect(afterHashes.has(keep.infoHash.toLowerCase())).toBe(true); + expect(afterHashes.has(orphan.infoHash.toLowerCase())).toBe(false); + expect(existsSync(join(dirs.hostScanDir, orphanName))).toBe(true); + + // Trigger the cleaner. The job runs async on a worker thread; we poll + // the filesystem for the expected outcome rather than sleeping. + const trig = await triggerJob(getToken(), 'DownloadCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + const moved = await waitForOrphanMove(dirs.hostOrphanedDir, orphanName); + + // Assert: kept torrent's folder survives in place. + expect(existsSync(join(dirs.hostScanDir, keepName, 'data.bin'))).toBe(true); + // Assert: orphan folder no longer at top of scan dir. + expect(existsSync(join(dirs.hostScanDir, orphanName))).toBe(false); + // Assert: orphan folder is under the orphanedDirectory, with its data intact. + expect(existsSync(join(dirs.hostOrphanedDir, moved, 'data.bin'))).toBe(true); + }); + }); +} diff --git a/e2e/tests/17-orphaned-files-behaviors.spec.ts b/e2e/tests/17-orphaned-files-behaviors.spec.ts new file mode 100644 index 00000000..5660b433 --- /dev/null +++ b/e2e/tests/17-orphaned-files-behaviors.spec.ts @@ -0,0 +1,256 @@ +import { test, expect } from '@playwright/test'; +import { existsSync, mkdirSync, readdirSync, statSync, utimesSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + loginAndGetToken, + createDownloadClient, + listDownloadClients, + deleteDownloadClient, + updateDownloadCleanerConfig, + getDownloadCleanerConfig, + updateOrphanedFilesConfig, + getGeneralConfig, + updateGeneralConfig, + triggerJob, + OrphanedFilesConfigRequest, +} from './helpers/app-api'; +import { QBittorrentDriver } from './helpers/torrent-clients/qbittorrent'; +import { chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; + +/** + * Behavior-level coverage for the orphaned files cleaner that isn't + * client-specific. The per-client integration matrix lives in + * `16-orphaned-files-cleanup.spec.ts`; this file picks qBittorrent as the + * single backing client and exercises configuration knobs: + * + * - PurgeAfterHours (deletes aged, leaves recent, null = never purge) + * - MinFileAgeHours (skips fresh entries) + * - ExcludePatterns + * - Per-client config disabled = no-op + * - DryRun = read-only + * + * "Aged" is simulated by backdating mtime via `utimesSync` after the file + * exists. This is reliable for the purge path (which only consults + * `GetLastWriteTimeUtc`) but not for the move path's MinFileAgeHours check, + * which compares against `MAX(lastWrite, created)` — Linux birthtime + * cannot be portably backdated. That scenario is covered by unit tests. + */ + +const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const APP_DOWNLOADS = '/e2e-downloads'; +const SLUG = 'qbittorrent-behaviors'; +const HOST_SCAN_DIR = join(HOST_DOWNLOADS, SLUG); +const HOST_ORPHANED_DIR = join(HOST_DOWNLOADS, SLUG, 'orphaned'); +const APP_SCAN_DIR = `${APP_DOWNLOADS}/${SLUG}`; +const APP_ORPHANED_DIR = `${APP_DOWNLOADS}/${SLUG}/orphaned`; + +function backdateRecursive(path: string, hoursAgo: number): void { + const t = (Date.now() - hoursAgo * 3600_000) / 1000; + const visit = (p: string) => { + utimesSync(p, t, t); + if (statSync(p).isDirectory()) { + for (const e of readdirSync(p)) visit(join(p, e)); + } + }; + visit(path); +} + +function writeOrphanFile(dir: string, name: string, content = 'orphan'): string { + mkdirSync(dir, { recursive: true }); + chmodIgnoringEPERM(dir, 0o777); + const path = join(dir, name); + writeFileSync(path, content); + return path; +} + +async function waitForCondition( + predicate: () => boolean | Promise, + timeoutMs: number, + label: string, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await predicate()) { + return; + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for: ${label}`); +} + +async function triggerAndSettle(token: string): Promise { + const res = await triggerJob(token, 'DownloadCleaner'); + expect(res.ok, `triggerJob: ${res.status}`).toBe(true); + // The cleaner is async on a worker thread. Give it time to walk the dirs. + // No seeding downloads means no 10s arr-settle delay — a couple of seconds + // is plenty in practice, but we still poll where it matters. + await new Promise((r) => setTimeout(r, 3000)); +} + +test.describe.serial('Orphaned files cleanup — behaviors', () => { + const driver = new QBittorrentDriver(); + let token: string; + let clientId: string; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + + // Clean slate: remove any leftover clients from other specs. + const existing = await listDownloadClients(token); + for (const client of existing) { + await deleteDownloadClient(token, client.id); + } + + // Enable the global download cleaner. Schedule is irrelevant — we + // trigger the job manually. + const dcCurrent = await (await getDownloadCleanerConfig(token)).json(); + await updateDownloadCleanerConfig(token, { + enabled: true, + cronExpression: dcCurrent.cronExpression || '0 0 * * * ?', + useAdvancedScheduling: dcCurrent.useAdvancedScheduling ?? false, + ignoredDownloads: [], + }); + + mkdirSync(HOST_DOWNLOADS, { recursive: true }); + + // Bring up qBittorrent and register it with Cleanuparr. + await driver.ready(); + await driver.clearAllTorrents(); + + const createRes = await createDownloadClient(token, { + enabled: true, + name: 'qBittorrent behaviors', + typeName: driver.typeName, + type: 'Torrent', + host: driver.cleanuparrHost, + username: driver.username ?? '', + password: driver.password ?? '', + downloadDirectorySource: '/downloads', + downloadDirectoryTarget: APP_SCAN_DIR, + }); + expect(createRes.ok, `createDownloadClient: ${createRes.status}`).toBe(true); + const created = await createRes.json(); + clientId = created.id; + }); + + test.beforeEach(async () => { + // Reset filesystem state before each scenario. + resetDirectory(HOST_SCAN_DIR); + mkdirSync(HOST_ORPHANED_DIR, { recursive: true }); + chmodIgnoringEPERM(HOST_ORPHANED_DIR, 0o777); + // No torrents in the client → claimedPaths is empty → every entry in + // scan dir is treated as orphan. + await driver.clearAllTorrents(); + }); + + const configureOrphanedFiles = async ( + overrides: Partial = {}, + ): Promise => { + const config: OrphanedFilesConfigRequest = { + enabled: true, + scanDirectories: [APP_SCAN_DIR], + orphanedDirectory: APP_ORPHANED_DIR, + excludePatterns: [], + minFileAgeHours: 0, + purgeAfterHours: null, + ...overrides, + }; + const res = await updateOrphanedFilesConfig(token, clientId, config); + expect(res.ok, `updateOrphanedFilesConfig: ${res.status}`).toBe(true); + }; + + test('PurgeAfterHours deletes aged entries from the orphaned directory', async () => { + test.setTimeout(60_000); + + const aged = writeOrphanFile(HOST_ORPHANED_DIR, 'aged.bin'); + backdateRecursive(aged, 25); + await configureOrphanedFiles({ purgeAfterHours: 24 }); + + await triggerAndSettle(token); + await waitForCondition(() => !existsSync(aged), 10_000, `purge of ${aged}`); + }); + + test('PurgeAfterHours leaves entries newer than the cutoff', async () => { + test.setTimeout(60_000); + + const fresh = writeOrphanFile(HOST_ORPHANED_DIR, 'fresh.bin'); + await configureOrphanedFiles({ purgeAfterHours: 24 }); + + await triggerAndSettle(token); + expect(existsSync(fresh)).toBe(true); + }); + + test('PurgeAfterHours null never purges, even very old entries', async () => { + test.setTimeout(60_000); + + const ancient = writeOrphanFile(HOST_ORPHANED_DIR, 'ancient.bin'); + backdateRecursive(ancient, 24 * 365); + await configureOrphanedFiles({ purgeAfterHours: null }); + + await triggerAndSettle(token); + expect(existsSync(ancient)).toBe(true); + }); + + test('MinFileAgeHours skips fresh entries in the scan directory', async () => { + test.setTimeout(60_000); + + const fresh = writeOrphanFile(HOST_SCAN_DIR, 'too-fresh.bin'); + await configureOrphanedFiles({ minFileAgeHours: 1 }); + + await triggerAndSettle(token); + // Still in the scan dir, not moved to orphaned dir. + expect(existsSync(fresh)).toBe(true); + expect(existsSync(join(HOST_ORPHANED_DIR, 'too-fresh.bin'))).toBe(false); + }); + + test('ExcludePatterns prevents matching entries from being moved', async () => { + test.setTimeout(60_000); + + const excluded = writeOrphanFile(HOST_SCAN_DIR, 'metadata.nfo'); + const matched = writeOrphanFile(HOST_SCAN_DIR, 'real-orphan.bin'); + await configureOrphanedFiles({ excludePatterns: ['*.nfo'] }); + + await triggerAndSettle(token); + await waitForCondition( + () => !existsSync(matched), + 10_000, + 'real-orphan.bin to be moved', + ); + // .nfo file untouched. + expect(existsSync(excluded)).toBe(true); + }); + + test('Disabled per-client config is a no-op', async () => { + test.setTimeout(60_000); + + const orphan = writeOrphanFile(HOST_SCAN_DIR, 'leave-me.bin'); + await configureOrphanedFiles({ enabled: false }); + + await triggerAndSettle(token); + expect(existsSync(orphan)).toBe(true); + expect(existsSync(join(HOST_ORPHANED_DIR, 'leave-me.bin'))).toBe(false); + }); + + test.describe('DryRun', () => { + test.afterEach(async () => { + // Always clear dry-run so it doesn't leak into subsequent specs. + const current = await getGeneralConfig(token); + await updateGeneralConfig(token, { ...current, dryRun: false }); + }); + + test('DryRun skips filesystem mutations', async () => { + test.setTimeout(60_000); + + const orphan = writeOrphanFile(HOST_SCAN_DIR, 'pretend-only.bin'); + + const current = await getGeneralConfig(token); + await updateGeneralConfig(token, { ...current, dryRun: true }); + + await configureOrphanedFiles(); + + await triggerAndSettle(token); + expect(existsSync(orphan)).toBe(true); + expect(existsSync(join(HOST_ORPHANED_DIR, 'pretend-only.bin'))).toBe(false); + }); + }); +}); diff --git a/e2e/tests/helpers/app-api.ts b/e2e/tests/helpers/app-api.ts index 2d983718..895065cf 100644 --- a/e2e/tests/helpers/app-api.ts +++ b/e2e/tests/helpers/app-api.ts @@ -304,6 +304,55 @@ export async function deleteDownloadClient(accessToken: string, clientId: string }); } +export async function listDownloadClients(accessToken: string): Promise> { + const res = await fetch(`${API}/api/configuration/download_client`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + throw new Error(`Failed to list download clients: ${res.status}`); + } + const body = await res.json(); + return body.clients ?? []; +} + +// --- Orphaned files cleanup helpers --- + +export interface OrphanedFilesConfigRequest { + enabled: boolean; + scanDirectories: string[]; + orphanedDirectory: string; + excludePatterns?: string[]; + minFileAgeHours?: number; + purgeAfterHours?: number | null; +} + +export async function updateOrphanedFilesConfig( + accessToken: string, + downloadClientId: string, + config: OrphanedFilesConfigRequest, +): Promise { + return fetch(`${API}/api/orphaned-files-config/${downloadClientId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(config), + }); +} + +// --- Job triggering --- + +export async function triggerJob( + accessToken: string, + jobType: 'QueueCleaner' | 'MalwareBlocker' | 'DownloadCleaner' | 'BlacklistSynchronizer' | 'CustomFormatScoreSyncer', +): Promise { + return fetch(`${API}/api/jobs/${jobType}/trigger`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + // --- General config / auth-bypass helpers --- export async function getGeneralConfig(accessToken: string): Promise> { diff --git a/e2e/tests/helpers/torrent-clients/deluge.ts b/e2e/tests/helpers/torrent-clients/deluge.ts new file mode 100644 index 00000000..afae7820 --- /dev/null +++ b/e2e/tests/helpers/torrent-clients/deluge.ts @@ -0,0 +1,112 @@ +import { TorrentClientDriver, pollUntilOk } from './types'; + +/** + * Deluge driver (Web UI JSON-RPC at /json). + * + * Auth flow: + * - POST { method: 'auth.login', params: [password] } — sets session cookie + * - POST { method: 'web.connected' } — true once Web UI is connected to a daemon + * - POST { method: 'web.connect', params: [host_id] } — pick the first + * daemon if Web UI isn't connected yet + * + * Default linuxserver/deluge web password is `deluge`. + */ +export class DelugeDriver implements TorrentClientDriver { + readonly typeName = 'Deluge' as const; + readonly cleanuparrHost: string; + readonly username = ''; + readonly password: string; + private readonly directJson: string; + private cookie: string | null = null; + private requestId = 1; + + constructor(host = 'http://localhost:8112', password = 'deluge') { + this.cleanuparrHost = host; + this.password = password; + this.directJson = `${host.replace(/\/$/, '')}/json`; + } + + async ready(): Promise { + await pollUntilOk( + async () => { + const res = await fetch(this.directJson, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'auth.login', params: [this.password], id: this.requestId++ }), + }); + if (!res.ok) return false; + const setCookie = res.headers.get('set-cookie'); + if (setCookie) this.cookie = setCookie.split(';')[0]; + const body = await res.json(); + return body.result === true; + }, + { label: 'Deluge Web UI' }, + ); + + // Ensure Web UI is bound to the local daemon. On a fresh install the + // Web UI starts unconnected and `core.*` calls fail until we connect. + const connected = await this.call('web.connected', []); + if (!connected) { + const hosts = await this.call>>('web.get_hosts', []); + const firstHost = hosts?.[0]?.[0]; + if (!firstHost) { + throw new Error('Deluge Web UI has no daemon to connect to (web.get_hosts returned empty)'); + } + await this.call('web.connect', [firstHost]); + const connectedAfter = await this.call('web.connected', []); + if (!connectedAfter) { + throw new Error('Deluge Web UI is not connected to a daemon after web.connect'); + } + } + } + + private async call(method: string, params: unknown[]): Promise { + const res = await fetch(this.directJson, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.cookie ? { Cookie: this.cookie } : {}), + }, + body: JSON.stringify({ method, params, id: this.requestId++ }), + }); + if (!res.ok) { + throw new Error(`Deluge ${method} failed: ${res.status} ${await res.text()}`); + } + const body = await res.json(); + if (body.error) { + throw new Error(`Deluge ${method} error: ${JSON.stringify(body.error)}`); + } + return body.result as T; + } + + async addTorrent({ metainfo, savePath, name }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise { + const filename = `${name}.torrent`; + const b64 = metainfo.toString('base64'); + await this.call('core.add_torrent_file', [ + filename, + b64, + { + download_location: savePath, + add_paused: true, + seed_mode: true, // skip hash check — treat as already complete + }, + ]); + } + + async deleteTorrent(infoHash: string): Promise { + // remove_torrent signature: (torrent_id, remove_data: bool) + await this.call('core.remove_torrent', [infoHash, false]); + } + + async clearAllTorrents(): Promise { + const all = await this.listTorrents(); + for (const t of all) { + await this.call('core.remove_torrent', [t.hash, false]); + } + } + + async listTorrents(): Promise> { + const result = await this.call>('core.get_torrents_status', [{}, ['name']]); + return Object.entries(result ?? {}).map(([hash, info]) => ({ hash, name: info.name })); + } +} diff --git a/e2e/tests/helpers/torrent-clients/index.ts b/e2e/tests/helpers/torrent-clients/index.ts new file mode 100644 index 00000000..52724fac --- /dev/null +++ b/e2e/tests/helpers/torrent-clients/index.ts @@ -0,0 +1,25 @@ +import { QBittorrentDriver } from './qbittorrent'; +import { TransmissionDriver } from './transmission'; +import { DelugeDriver } from './deluge'; +import { RTorrentDriver } from './rtorrent'; +import { UTorrentDriver } from './utorrent'; +import { TorrentClientDriver, TorrentClientType } from './types'; + +export { TorrentClientDriver, TorrentClientType }; +export { ClientNotImplementedError } from './types'; + +export interface TorrentClientFixture { + driver: TorrentClientDriver; + /** Whether the spec should actually run against this driver. */ + enabled: boolean; + /** Reason this client is disabled (shown in test.skip). */ + skipReason?: string; +} + +export const ALL_CLIENTS: TorrentClientFixture[] = [ + { driver: new QBittorrentDriver(), enabled: true }, + { driver: new TransmissionDriver(), enabled: true }, + { driver: new DelugeDriver(), enabled: true }, + { driver: new UTorrentDriver(), enabled: true }, + { driver: new RTorrentDriver(), enabled: true }, +]; diff --git a/e2e/tests/helpers/torrent-clients/qbittorrent.ts b/e2e/tests/helpers/torrent-clients/qbittorrent.ts new file mode 100644 index 00000000..c7ad7393 --- /dev/null +++ b/e2e/tests/helpers/torrent-clients/qbittorrent.ts @@ -0,0 +1,124 @@ +import { TorrentClientDriver, pollUntilOk } from './types'; + +/** + * qBittorrent driver (WebUI v2). + * + * Auth note: relies on the linuxserver/qbittorrent default of bypassing auth + * for localhost. Combined with `network_mode: host`, requests from the test + * runner originate from 127.0.0.1, so login is skipped. If running against + * a qBittorrent without localhost-bypass, set `username` and `password` and + * the driver will POST /api/v2/auth/login. + */ +export class QBittorrentDriver implements TorrentClientDriver { + readonly typeName = 'qBittorrent' as const; + readonly cleanuparrHost: string; + readonly username?: string; + readonly password?: string; + private readonly directHost: string; + private cookie: string | null = null; + + constructor(host = 'http://localhost:8090', username = 'admin', password = 'adminadmin') { + this.cleanuparrHost = host; + this.directHost = host; + this.username = username; + this.password = password; + } + + async ready(): Promise { + await pollUntilOk( + async () => { + const res = await fetch(`${this.directHost}/api/v2/app/version`, { + headers: this.cookie ? { Cookie: this.cookie } : undefined, + }); + return res.ok || res.status === 403; + }, + { label: 'qBittorrent WebUI' }, + ); + if (this.username && this.password) { + await this.login(); + } + } + + private async login(): Promise { + const body = new URLSearchParams({ username: this.username!, password: this.password! }); + const res = await fetch(`${this.directHost}/api/v2/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + // qBittorrent returns HTTP 200 with body "Ok." on success and "Fails." on + // bad credentials, so we cannot rely on res.ok alone. + const responseBody = (await res.text()).trim(); + if (!res.ok || responseBody !== 'Ok.') { + throw new Error(`qBittorrent login failed: ${res.status} ${responseBody}`); + } + const cookie = res.headers.get('set-cookie'); + if (cookie) { + // Strip flags — Node's fetch returns the full header + this.cookie = cookie.split(';')[0]; + } + } + + async addTorrent({ metainfo, savePath }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise { + const form = new FormData(); + form.append('torrents', new Blob([new Uint8Array(metainfo)]), 'torrent.torrent'); + form.append('savepath', savePath); + form.append('paused', 'true'); + form.append('skip_checking', 'true'); + form.append('autoTMM', 'false'); + const res = await fetch(`${this.directHost}/api/v2/torrents/add`, { + method: 'POST', + headers: this.cookie ? { Cookie: this.cookie } : undefined, + body: form, + }); + if (!res.ok) { + throw new Error(`qBittorrent add failed: ${res.status} ${await res.text()}`); + } + } + + async deleteTorrent(infoHash: string): Promise { + const body = new URLSearchParams({ hashes: infoHash, deleteFiles: 'false' }); + const res = await fetch(`${this.directHost}/api/v2/torrents/delete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(this.cookie ? { Cookie: this.cookie } : {}), + }, + body: body.toString(), + }); + if (!res.ok) { + throw new Error(`qBittorrent delete failed: ${res.status} ${await res.text()}`); + } + } + + async clearAllTorrents(): Promise { + const all = await this.listTorrents(); + if (all.length === 0) return; + const body = new URLSearchParams({ + hashes: all.map((t) => t.hash).join('|'), + deleteFiles: 'false', + }); + const res = await fetch(`${this.directHost}/api/v2/torrents/delete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(this.cookie ? { Cookie: this.cookie } : {}), + }, + body: body.toString(), + }); + if (!res.ok) { + throw new Error(`qBittorrent clear failed: ${res.status}`); + } + } + + async listTorrents(): Promise> { + const res = await fetch(`${this.directHost}/api/v2/torrents/info`, { + headers: this.cookie ? { Cookie: this.cookie } : undefined, + }); + if (!res.ok) { + throw new Error(`qBittorrent list failed: ${res.status}`); + } + const items: Array<{ hash: string; name: string }> = await res.json(); + return items.map((t) => ({ hash: t.hash, name: t.name })); + } +} diff --git a/e2e/tests/helpers/torrent-clients/rtorrent.ts b/e2e/tests/helpers/torrent-clients/rtorrent.ts new file mode 100644 index 00000000..3a49fd34 --- /dev/null +++ b/e2e/tests/helpers/torrent-clients/rtorrent.ts @@ -0,0 +1,190 @@ +import { TorrentClientDriver, pollUntilOk } from './types'; + +/** + * rTorrent driver via XML-RPC over HTTP. linuxserver/rutorrent exposes + * SCGI-backed XML-RPC at `/RPC2` on the rutorrent web port (8088 by default). + * + * rTorrent is the most awkward of the supported clients to drive from a + * test runner because: + * - It uses XML-RPC (not JSON), so we hand-build the request/response + * - There is no native "skip hash check" — we use `load.raw` (load only, + * no auto-start) so rTorrent never tries to peer or verify pieces. + * + * If the spec for this client fails because of XML escaping or because the + * rutorrent nginx isn't routing /RPC2, this file is the most likely place + * to need adjustment. + */ +export class RTorrentDriver implements TorrentClientDriver { + readonly typeName = 'rTorrent' as const; + readonly cleanuparrHost: string; + readonly username?: string; + readonly password?: string; + private readonly directRpc: string; + + constructor(host = 'http://localhost:8088/RPC2') { + this.cleanuparrHost = host; + this.directRpc = host; + } + + async ready(): Promise { + await pollUntilOk( + async () => { + try { + await this.call('system.client_version', []); + return true; + } catch { + return false; + } + }, + { label: 'rTorrent XML-RPC' }, + ); + } + + private async call(method: string, params: Array): Promise { + const xml = buildXmlRpcRequest(method, params); + const res = await fetch(this.directRpc, { + method: 'POST', + headers: { 'Content-Type': 'text/xml' }, + body: xml, + }); + if (!res.ok) { + throw new Error(`rTorrent ${method} failed: ${res.status} ${await res.text()}`); + } + const text = await res.text(); + return parseXmlRpcResponse(text); + } + + async addTorrent({ metainfo, savePath, name, infoHash }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise { + // load.raw_start_verbose loads AND starts the torrent. Starting triggers + // an immediate hash check, which (for our tiny 32KB files matching the + // metainfo piece hashes) populates `d.base_path` — the field Cleanuparr + // reads as the torrent's save path. Without starting, `d.base_path` + // stays empty and Cleanuparr can't build a claimed-paths set. + await this.call('load.raw_start_verbose', [ + '', + { type: 'base64', value: metainfo.toString('base64') }, + `d.directory.set="${savePath}"`, + `d.custom1.set="${name}"`, + ]); + void infoHash; + } + + async deleteTorrent(infoHash: string): Promise { + // d.erase removes the torrent from rTorrent's session without touching + // the data on disk. + await this.call('d.erase', [infoHash.toUpperCase()]); + } + + async clearAllTorrents(): Promise { + const all = await this.listTorrents(); + for (const t of all) { + try { + await this.call('d.erase', [t.hash.toUpperCase()]); + } catch { + // best-effort: continue clearing + } + } + } + + async listTorrents(): Promise> { + const result = await this.call('d.multicall2', ['', 'main', 'd.hash=', 'd.name=']); + if (!Array.isArray(result)) return []; + return result.map((row: unknown) => { + const arr = row as unknown[]; + return { hash: String(arr[0]).toLowerCase(), name: String(arr[1]) }; + }); + } +} + +type XmlRpcValue = string | number | boolean | { type: 'base64'; value: string }; + +function buildXmlRpcRequest(method: string, params: XmlRpcValue[]): string { + const paramsXml = params.map((p) => `${encodeValue(p)}`).join(''); + return `${escapeXml(method)}${paramsXml}`; +} + +function encodeValue(v: XmlRpcValue): string { + if (typeof v === 'number') { + return Number.isInteger(v) ? `${v}` : `${v}`; + } + if (typeof v === 'boolean') { + return `${v ? 1 : 0}`; + } + if (typeof v === 'string') { + return `${escapeXml(v)}`; + } + if (v && typeof v === 'object' && v.type === 'base64') { + return `${v.value}`; + } + throw new Error(`xml-rpc: unsupported value ${typeof v}`); +} + +function escapeXml(s: string): string { + return s.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]!)); +} + +function parseXmlRpcResponse(xml: string): unknown { + if (xml.includes('')) { + const msg = xml.match(/faultString<\/name>\s*([^<]+)<\/string>/)?.[1] ?? xml; + throw new Error(`xml-rpc fault: ${msg}`); + } + const paramsMatch = xml.match(/([\s\S]*?)<\/params>/); + if (!paramsMatch) return null; + return parseValue(paramsMatch[1]); +} + +function parseValue(xml: string): unknown { + // Very small subset of XML-RPC parsing: handles int/string/boolean/array/struct/base64. + const tag = xml.match(/\s*<([a-zA-Z0-9]+)>/); + if (!tag) { + // Bare text is treated as string per spec. + const bare = xml.match(/([\s\S]*?)<\/value>/); + return bare ? bare[1].trim() : null; + } + const type = tag[1]; + if (type === 'array') { + // Greedy — for nested arrays, we want the OUTER not the first inner one. + const inner = xml.match(/\s*([\s\S]*)<\/data>\s*<\/array>/)?.[1] ?? ''; + return splitValues(inner).map(parseValue); + } + if (type === 'struct') { + const inner = xml.match(/([\s\S]*)<\/struct>/)?.[1] ?? ''; + const out: Record = {}; + const memberRe = /\s*([^<]+)<\/name>\s*([\s\S]*?)<\/member>/g; + let m: RegExpExecArray | null; + while ((m = memberRe.exec(inner)) !== null) { + out[m[1]] = parseValue(m[2]); + } + return out; + } + const scalar = xml.match(new RegExp(`<${type}>([\\s\\S]*?)<\\/${type}>`))?.[1] ?? ''; + if (type === 'int' || type === 'i4') return Number(scalar); + if (type === 'boolean') return scalar === '1'; + if (type === 'double') return Number(scalar); + return decodeXml(scalar); +} + +function splitValues(xml: string): string[] { + const out: string[] = []; + let depth = 0; + let start = -1; + for (let i = 0; i < xml.length; i++) { + if (xml.startsWith('', i)) { + if (depth === 0) start = i; + depth++; + i += ''.length - 1; + } else if (xml.startsWith('', i)) { + depth--; + if (depth === 0 && start !== -1) { + out.push(xml.slice(start, i + ''.length)); + start = -1; + } + i += ''.length - 1; + } + } + return out; +} + +function decodeXml(s: string): string { + return s.replace(/&(lt|gt|amp|apos|quot);/g, (_, e) => ({ lt: '<', gt: '>', amp: '&', apos: "'", quot: '"' }[e as 'lt']!)); +} diff --git a/e2e/tests/helpers/torrent-clients/transmission.ts b/e2e/tests/helpers/torrent-clients/transmission.ts new file mode 100644 index 00000000..58af28ed --- /dev/null +++ b/e2e/tests/helpers/torrent-clients/transmission.ts @@ -0,0 +1,107 @@ +import { TorrentClientDriver, pollUntilOk } from './types'; + +/** + * Transmission driver (transmission-rpc protocol). + * + * Transmission requires a CSRF-style session id obtained by issuing any RPC + * call and reading the `X-Transmission-Session-Id` header from the 409 + * response, then replaying with that header. We refresh the id transparently + * on each request. + * + * Compose wires linuxserver/transmission with USER=transmission / + * PASS=transmission, which gates the RPC endpoint behind basic auth. + */ +export class TransmissionDriver implements TorrentClientDriver { + readonly typeName = 'Transmission' as const; + readonly cleanuparrHost: string; + readonly username: string; + readonly password: string; + private readonly directRpc: string; + private sessionId = ''; + + constructor(host = 'http://localhost:9091/transmission', username = 'transmission', password = 'transmission') { + this.cleanuparrHost = host; + this.username = username; + this.password = password; + this.directRpc = `${host.replace(/\/$/, '')}/rpc`; + } + + async ready(): Promise { + await pollUntilOk( + async () => { + try { + await this.call('session-get', {}); + return true; + } catch { + return false; + } + }, + { label: 'Transmission RPC' }, + ); + } + + private authHeader(): string { + return 'Basic ' + Buffer.from(`${this.username}:${this.password}`).toString('base64'); + } + + private async call(method: string, args: Record): Promise { + const send = async () => { + return fetch(this.directRpc, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': this.authHeader(), + 'X-Transmission-Session-Id': this.sessionId, + }, + body: JSON.stringify({ method, arguments: args }), + }); + }; + let res = await send(); + if (res.status === 409) { + this.sessionId = res.headers.get('x-transmission-session-id') ?? ''; + res = await send(); + } + if (!res.ok) { + throw new Error(`Transmission ${method} failed: ${res.status} ${await res.text()}`); + } + const body = await res.json(); + if (body.result !== 'success') { + throw new Error(`Transmission ${method} non-success: ${body.result}`); + } + return body.arguments; + } + + async addTorrent({ metainfo, savePath, infoHash }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise { + await this.call('torrent-add', { + metainfo: metainfo.toString('base64'), + 'download-dir': savePath, + paused: true, + }); + // The torrent is added in a paused, unverified state. Transmission will + // try to verify on resume — we never resume, so it stays in stopped/ + // queued state with savePath populated, which is enough for the cleaner + // to pick it up via GetAllTorrentsLite. + void infoHash; + } + + async deleteTorrent(infoHash: string): Promise { + await this.call('torrent-remove', { + ids: [infoHash], + 'delete-local-data': false, + }); + } + + async clearAllTorrents(): Promise { + const all = await this.listTorrents(); + if (all.length === 0) return; + await this.call('torrent-remove', { + ids: all.map((t) => t.hash), + 'delete-local-data': false, + }); + } + + async listTorrents(): Promise> { + const args = await this.call('torrent-get', { fields: ['hashString', 'name'] }); + return (args.torrents ?? []).map((t: { hashString: string; name: string }) => ({ hash: t.hashString, name: t.name })); + } +} diff --git a/e2e/tests/helpers/torrent-clients/types.ts b/e2e/tests/helpers/torrent-clients/types.ts new file mode 100644 index 00000000..243399b5 --- /dev/null +++ b/e2e/tests/helpers/torrent-clients/types.ts @@ -0,0 +1,61 @@ +export type TorrentClientType = 'qBittorrent' | 'Transmission' | 'Deluge' | 'uTorrent' | 'rTorrent'; + +/** + * Minimal driver surface used by the orphaned-files spec. Each implementation + * wraps a specific torrent client's HTTP API and exposes: + * + * - `ready()` — block until the client is accepting requests + * - `addTorrent({ metainfo, savePath, name })` — register a torrent whose + * data already exists on disk (no actual downloading) + * - `deleteTorrent(hash, { deleteFiles })` — remove a torrent from the + * client; the spec always passes deleteFiles=false to leave the orphan + * on disk so the cleaner has something to detect + * - `listTorrents()` — used to assert state after operations + * + * `host` is the URL the *Cleanuparr backend* should be configured with — not + * necessarily the URL the test helper itself talks to (some clients require + * a different sub-path for their RPC endpoint). + */ +export interface TorrentClientDriver { + readonly typeName: TorrentClientType; + /** Hostname+path the Cleanuparr backend uses to reach this client. */ + readonly cleanuparrHost: string; + readonly username?: string; + readonly password?: string; + + ready(): Promise; + addTorrent(input: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise; + deleteTorrent(infoHash: string): Promise; + listTorrents(): Promise>; + /** + * Remove every torrent currently registered with the client without deleting + * data on disk. Called at the start of each test to make the spec + * idempotent across re-runs (the torrent client's state persists in its + * config volume between `make test` invocations). + */ + clearAllTorrents(): Promise; +} + +export class ClientNotImplementedError extends Error { + constructor(client: TorrentClientType, detail: string) { + super(`${client}: ${detail}`); + this.name = 'ClientNotImplementedError'; + } +} + +export async function pollUntilOk( + fn: () => Promise, + { timeoutMs = 90_000, intervalMs = 1500, label = 'condition' }: { timeoutMs?: number; intervalMs?: number; label?: string } = {}, +): Promise { + const start = Date.now(); + let lastError: unknown; + while (Date.now() - start < timeoutMs) { + try { + if (await fn()) return; + } catch (err) { + lastError = err; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error(`Timed out waiting for ${label} after ${timeoutMs}ms (last error: ${String(lastError)})`); +} diff --git a/e2e/tests/helpers/torrent-clients/utorrent.ts b/e2e/tests/helpers/torrent-clients/utorrent.ts new file mode 100644 index 00000000..920732ba --- /dev/null +++ b/e2e/tests/helpers/torrent-clients/utorrent.ts @@ -0,0 +1,116 @@ +import { TorrentClientDriver, pollUntilOk } from './types'; + +/** + * µTorrent driver (WebUI HTTP API). + * + * The legacy uTorrent Server for Linux is reanimated by the `ekho/utorrent` + * Docker image. Auth is HTTP Basic; the WebUI also requires a CSRF token + * fetched from /gui/token.html plus a `GUID` cookie set by that same call. + * + * The list endpoint returns a JSON object whose `torrents` field is an array + * of arrays — each row is `[hash, status, name, size, ...]`. + */ +export class UTorrentDriver implements TorrentClientDriver { + readonly typeName = 'uTorrent' as const; + readonly cleanuparrHost: string; + readonly username: string; + readonly password: string; + private readonly directHost: string; + private token = ''; + private cookie = ''; + + constructor(host = 'http://localhost:8083', username = 'admin', password = '') { + this.cleanuparrHost = host; + this.directHost = host; + this.username = username; + this.password = password; + } + + private authHeader(): string { + return 'Basic ' + Buffer.from(`${this.username}:${this.password}`).toString('base64'); + } + + private requestHeaders(): Record { + const h: Record = { Authorization: this.authHeader() }; + if (this.cookie) h.Cookie = this.cookie; + return h; + } + + async ready(): Promise { + await pollUntilOk( + async () => { + try { + await this.refreshToken(); + return this.token !== ''; + } catch { + return false; + } + }, + { label: 'uTorrent WebUI' }, + ); + } + + private async refreshToken(): Promise { + const res = await fetch(`${this.directHost}/gui/token.html`, { + headers: { Authorization: this.authHeader() }, + }); + if (!res.ok) { + throw new Error(`uTorrent token: ${res.status}`); + } + const text = await res.text(); + const match = text.match(/]*id=['"]token['"][^>]*>([^<]+)<\/div>/); + if (!match) { + throw new Error(`uTorrent token not found in response body: ${text.slice(0, 200)}`); + } + this.token = match[1]; + const setCookie = res.headers.get('set-cookie'); + if (setCookie) { + this.cookie = setCookie.split(';')[0]; + } + } + + async addTorrent({ metainfo, name }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise { + const form = new FormData(); + form.append('torrent_file', new Blob([new Uint8Array(metainfo)]), `${name}.torrent`); + const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=add-file`; + const res = await fetch(url, { + method: 'POST', + headers: this.requestHeaders(), + body: form, + }); + if (!res.ok) { + throw new Error(`uTorrent add: ${res.status} ${await res.text()}`); + } + } + + async deleteTorrent(infoHash: string): Promise { + // `remove` removes the torrent from the client without touching files; + // `removedata` / `removedatatorrent` delete data and torrent file. + const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=remove&hash=${infoHash.toUpperCase()}`; + const res = await fetch(url, { headers: this.requestHeaders() }); + if (!res.ok) { + throw new Error(`uTorrent remove: ${res.status}`); + } + } + + async listTorrents(): Promise> { + const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&list=1`; + const res = await fetch(url, { headers: this.requestHeaders() }); + if (!res.ok) { + throw new Error(`uTorrent list: ${res.status}`); + } + const body: { torrents?: unknown[][] } = await res.json(); + return (body.torrents ?? []).map((row) => ({ + hash: String(row[0]).toLowerCase(), + name: String(row[2]), + })); + } + + async clearAllTorrents(): Promise { + const all = await this.listTorrents(); + for (const t of all) { + const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=remove&hash=${t.hash.toUpperCase()}`; + await fetch(url, { headers: this.requestHeaders() }); + } + } +} diff --git a/e2e/tests/helpers/torrent-fixtures.ts b/e2e/tests/helpers/torrent-fixtures.ts new file mode 100644 index 00000000..052eb370 --- /dev/null +++ b/e2e/tests/helpers/torrent-fixtures.ts @@ -0,0 +1,148 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { chmodSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Bencode an arbitrary value. Supports integers, Buffers, strings (utf-8), + * arrays, and plain objects (whose keys are sorted lexicographically as + * required by BEP-3). + */ +function bencode(value: unknown): Buffer { + if (typeof value === 'number') { + if (!Number.isInteger(value)) { + throw new Error(`bencode: non-integer number ${value}`); + } + return Buffer.from(`i${value}e`); + } + if (Buffer.isBuffer(value)) { + return Buffer.concat([Buffer.from(`${value.length}:`), value]); + } + if (typeof value === 'string') { + const buf = Buffer.from(value, 'utf8'); + return Buffer.concat([Buffer.from(`${buf.length}:`), buf]); + } + if (Array.isArray(value)) { + return Buffer.concat([Buffer.from('l'), ...value.map(bencode), Buffer.from('e')]); + } + if (value !== null && typeof value === 'object') { + const obj = value as Record; + const keys = Object.keys(obj).sort(); + const parts: Buffer[] = [Buffer.from('d')]; + for (const k of keys) { + parts.push(bencode(k)); + parts.push(bencode(obj[k])); + } + parts.push(Buffer.from('e')); + return Buffer.concat(parts); + } + throw new Error(`bencode: unsupported value ${typeof value}`); +} + +export interface GeneratedTorrent { + /** Bencoded .torrent metainfo buffer */ + metainfo: Buffer; + /** Lowercase hex SHA-1 of the bencoded info dict — the torrent's infohash */ + infoHash: string; + /** Name of the top-level directory inside the torrent */ + name: string; + /** Absolute on-disk path to the directory containing the torrent's data */ + contentPath: string; +} + +/** + * Build a single-file multi-piece torrent on disk and return its metainfo. + * + * The data file is written to `//data.bin` and contains + * deterministic random bytes seeded from `name` so re-runs produce the same + * content (and thus the same infohash) for a given name. + * + * @param savePath - directory where the torrent's top-level folder will be created + * @param name - top-level folder name (also the torrent's `info.name`) + * @param sizeBytes - total size of the inner data file + */ +export function buildFolderTorrent(savePath: string, name: string, sizeBytes = 32_768): GeneratedTorrent { + const contentPath = join(savePath, name); + mkdirSync(contentPath, { recursive: true }); + chmodIgnoringEPERM(contentPath, 0o777); + + // Deterministic content: HMAC-like expansion from the name so two runs + // produce identical bytes (and thus identical pieces / infohash). + const seed = createHash('sha256').update(`cleanuparr-e2e:${name}`).digest(); + const data = Buffer.alloc(sizeBytes); + let offset = 0; + let counter = 0; + while (offset < sizeBytes) { + const block = createHash('sha256').update(seed).update(Buffer.from([counter & 0xff, (counter >> 8) & 0xff])).digest(); + block.copy(data, offset, 0, Math.min(block.length, sizeBytes - offset)); + offset += block.length; + counter++; + } + writeFileSync(join(contentPath, 'data.bin'), data); + + const pieceLength = 16384; + const pieces: Buffer[] = []; + for (let i = 0; i < data.length; i += pieceLength) { + const piece = data.subarray(i, Math.min(i + pieceLength, data.length)); + pieces.push(createHash('sha1').update(piece).digest()); + } + const piecesConcat = Buffer.concat(pieces); + + const info = { + name, + 'piece length': pieceLength, + pieces: piecesConcat, + files: [ + { length: data.length, path: ['data.bin'] }, + ], + // Mark as private to short-circuit DHT/PEX work in clients. + private: 1, + }; + const metainfo = bencode({ + announce: 'http://tracker.invalid/announce', + 'created by': 'cleanuparr-e2e', + 'creation date': 0, + info, + }); + const infoHash = createHash('sha1').update(bencode(info)).digest('hex'); + + return { metainfo, infoHash, name, contentPath }; +} + +/** + * `chmodSync` that tolerates EPERM. The torrent-client bind mounts + * (`test-data/downloads/`) are chowned to PUID=1000 by + * linuxserver.io entrypoints, while CI's Playwright runner is uid 1001 + * and cannot chmod paths it doesn't own. Mode bits are already 0o777 + * from setup-test-data.sh's `chmod -R a+rwX`, so the chmod is best-effort. + */ +export function chmodIgnoringEPERM(path: string, mode: number): void { + try { + chmodSync(path, mode); + } catch (err) { + if ((err as { code?: string }).code !== 'EPERM') { + throw err; + } + } +} + +/** + * Wipe and recreate a directory. Used at test setup to reset client data. + */ +export function resetDirectory(path: string): void { + mkdirSync(path, { recursive: true }); + for (const entry of readdirSync(path)) { + rmSync(join(path, entry), { recursive: true, force: true }); + } + chmodIgnoringEPERM(path, 0o777); +} + +/** + * Write a random extra file directly under a directory. Useful to seed an + * unrelated file that the cleaner should classify as orphaned. + */ +export function writeRandomFile(dir: string, name: string, sizeBytes = 1024): string { + mkdirSync(dir, { recursive: true }); + const path = join(dir, name); + writeFileSync(path, randomBytes(sizeBytes)); + return path; +}