mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
Remove some old and unreachable code (#804)
This commit is contained in:
1 parent
7f8ddcfb4e
commit
124fcbe747
106 files changed
+28
-2310
No files matched your search
@@ -98,16 +98,6 @@ public class EventsResponseContractTests : IDisposable
|
||||
result.Result.ShouldBeOfType<NotFoundResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEventsByTracking_ReturnsTheDocumentedListItemKeys()
|
||||
{
|
||||
await SeedEventAsync();
|
||||
|
||||
ActionResult<List<EventListItem>> result = await _controller.GetEventsByTracking(_trackingId);
|
||||
|
||||
ResponseContract.FirstItemKeys(result.Result!).ShouldBe(ListItemKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleEventShape_MatchesTheListShape()
|
||||
{
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
[Authorize]
|
||||
public class ApiDocumentationController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult RedirectToSwagger()
|
||||
{
|
||||
return Redirect("/api/swagger");
|
||||
}
|
||||
}
|
||||
@@ -153,21 +153,6 @@ public class EventsController : ControllerBase
|
||||
return Ok(eventItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets events by tracking ID
|
||||
/// </summary>
|
||||
[HttpGet("tracking/{trackingId}")]
|
||||
public async Task<ActionResult<List<EventListItem>>> GetEventsByTracking(Guid trackingId)
|
||||
{
|
||||
List<EventListItem> events = await _context.Events
|
||||
.Where(e => e.TrackingId == trackingId)
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.Select(EventListItem.FromEvent)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unique event types
|
||||
/// </summary>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
// Queue rules endpoints have moved to Cleanuparr.Api.Features.QueueCleaner.Controllers
|
||||
@@ -98,6 +98,13 @@ public static class ApiDI
|
||||
// Custom SPA fallback to inject base path
|
||||
app.MapFallback(async context =>
|
||||
{
|
||||
// Serving the SPA here would mask unknown API routes as 200 + HTML
|
||||
if (context.Request.Path.StartsWithSegments("/api"))
|
||||
{
|
||||
context.Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
var basePath = app.Configuration.GetValue<string>(ConfigurationKeys.BasePath) ?? "/";
|
||||
|
||||
// Normalize the base path (remove trailing slash if not root)
|
||||
|
||||
-27
@@ -25,33 +25,6 @@ public sealed class OrphanedFilesConfigController : ControllerBase
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
[HttpGet("{downloadClientId}")]
|
||||
public async Task<IActionResult> GetClientConfig(Guid downloadClientId)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var client = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
|
||||
}
|
||||
|
||||
var config = await _dataContext.OrphanedFilesConfigs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId);
|
||||
|
||||
return Ok(config is null ? null : OrphanedFilesConfigResponse.From(config));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{downloadClientId}")]
|
||||
public async Task<IActionResult> UpdateClientConfig(Guid downloadClientId, [FromBody] OrphanedFilesConfigRequest dto)
|
||||
{
|
||||
|
||||
@@ -71,18 +71,6 @@ internal static class SeedingRuleHelper
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the client by ID then queries its seeding rules.
|
||||
/// </summary>
|
||||
public static async Task<List<ISeedingRule>> GetForClientIdAsync(DataContext ctx, Guid clientId)
|
||||
{
|
||||
var client = await ctx.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == clientId);
|
||||
|
||||
return client is null ? [] : await GetForClientAsync(ctx, client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters seeding rules for a client from pre-loaded in-memory lists.
|
||||
/// Use this in bulk-load scenarios to avoid N+1 queries.
|
||||
|
||||
@@ -58,11 +58,6 @@ public class SetupGuardMiddleware
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
public void ResetSetupState()
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private async Task<bool> IsSetupCompleted()
|
||||
{
|
||||
if (_setupCompleted)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using CreateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest instead.")]
|
||||
public sealed record CreateAppriseProviderDto : CreateAppriseProviderRequest;
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using CreateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest instead.")]
|
||||
public sealed record CreateNotifiarrProviderDto : CreateNotifiarrProviderRequest;
|
||||
-1
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using CreateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest instead.")]
|
||||
public sealed record CreateNtfyProviderDto : CreateNtfyProviderRequest;
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using TestAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest instead.")]
|
||||
public sealed record TestAppriseProviderDto : TestAppriseProviderRequest;
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using TestNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest instead.")]
|
||||
public sealed record TestNotifiarrProviderDto : TestNotifiarrProviderRequest;
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using TestNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest instead.")]
|
||||
public sealed record TestNtfyProviderDto : TestNtfyProviderRequest;
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using UpdateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest instead.")]
|
||||
public sealed record UpdateAppriseProviderDto : UpdateAppriseProviderRequest;
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using UpdateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest instead.")]
|
||||
public sealed record UpdateNotifiarrProviderDto : UpdateNotifiarrProviderRequest;
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using UpdateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest instead.")]
|
||||
public sealed record UpdateNtfyProviderDto : UpdateNtfyProviderRequest;
|
||||
@@ -1 +0,0 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -1 +0,0 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -1 +0,0 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -1,162 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Application.Features.BlacklistSync;
|
||||
|
||||
public sealed class BlacklistSynchronizer : IHandler
|
||||
{
|
||||
private readonly ILogger<BlacklistSynchronizer> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
|
||||
public BlacklistSynchronizer(
|
||||
ILogger<BlacklistSynchronizer> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
FileReader fileReader,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_fileReader = fileReader;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Blacklist sync is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BlacklistPath))
|
||||
{
|
||||
_logger.LogWarning("Blacklist sync path is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] patterns = await _fileReader.ReadContentAsync(config.BlacklistPath);
|
||||
string excludedFileNames = string.Join('\n', patterns.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
|
||||
string currentHash = ComputeHash(excludedFileNames);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(() => SyncBlacklist(currentHash, excludedFileNames));
|
||||
await _dryRunInterceptor.InterceptAsync(() => RemoveOldSyncDataAsync(currentHash));
|
||||
|
||||
_logger.LogDebug("Blacklist synchronization completed");
|
||||
}
|
||||
|
||||
private async Task SyncBlacklist(string currentHash, string excludedFileNames)
|
||||
{
|
||||
List<DownloadClientConfig> qBittorrentClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
|
||||
.ToListAsync();
|
||||
|
||||
if (qBittorrentClients.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting blacklist synchronization for {Count} qBittorrent clients", qBittorrentClients.Count);
|
||||
|
||||
// Pull existing sync history for this hash
|
||||
var alreadySynced = await _dataContext.BlacklistSyncHistory
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Hash == currentHash)
|
||||
.Select(x => x.DownloadClientId)
|
||||
.ToListAsync();
|
||||
|
||||
// Only update clients not present in history for current hash
|
||||
foreach (var clientConfig in qBittorrentClients)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (alreadySynced.Contains(clientConfig.Id))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientName} already synced for current blacklist, skipping", clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig);
|
||||
if (downloadService is not QBitService qBitService)
|
||||
{
|
||||
_logger.LogError("Expected QBitService but got {ServiceType} for client {ClientName}", downloadService.GetType().Name, clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await qBitService.LoginAsync();
|
||||
await qBitService.UpdateBlacklistAsync(excludedFileNames);
|
||||
|
||||
_logger.LogDebug("Successfully updated blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
|
||||
// Insert history row marking this client as synced for current hash
|
||||
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
|
||||
{
|
||||
Hash = currentHash,
|
||||
DownloadClientId = clientConfig.Id
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
qBitService.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create download service for client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string excludedFileNames)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(excludedFileNames);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task RemoveOldSyncDataAsync(string currentHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dataContext.BlacklistSyncHistory
|
||||
.Where(s => s.Hash != currentHash)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup old blacklist sync history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Cleanuparr.Domain.Entities.Cache;
|
||||
|
||||
public sealed record StalledCacheItem
|
||||
{
|
||||
/// <summary>
|
||||
/// The amount of bytes that have been downloaded.
|
||||
/// </summary>
|
||||
public long Downloaded { get; set; }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
public record DelugeTorrent
|
||||
{
|
||||
[JsonPropertyName("comment")]
|
||||
public string Comment { get; set; }
|
||||
|
||||
[JsonPropertyName("is_seed")]
|
||||
public bool IsSeed { get; set; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; set; }
|
||||
|
||||
[JsonPropertyName("paused")]
|
||||
public bool Paused { get; set; }
|
||||
|
||||
[JsonPropertyName("ratio")]
|
||||
public double Ratio { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public string Label { get; set; }
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
public sealed record DelugeTorrentExtended : DelugeTorrent
|
||||
{
|
||||
[JsonPropertyName("total_done")]
|
||||
public long TotalDone { get; set; }
|
||||
|
||||
[JsonPropertyName("total_payload_download")]
|
||||
public long TotalPayloadDownload { get; set; }
|
||||
|
||||
[JsonPropertyName("total_uploaded")]
|
||||
public long TotalUploaded { get; set; }
|
||||
|
||||
[JsonPropertyName("next_announce")]
|
||||
public int NextAnnounce { get; set; }
|
||||
|
||||
[JsonPropertyName("tracker_status")]
|
||||
public string TrackerStatus { get; set; }
|
||||
|
||||
[JsonPropertyName("num_pieces")]
|
||||
public int NumPieces { get; set; }
|
||||
|
||||
[JsonPropertyName("piece_length")]
|
||||
public long PieceLength { get; set; }
|
||||
|
||||
[JsonPropertyName("is_auto_managed")]
|
||||
public bool IsAutoManaged { get; set; }
|
||||
|
||||
[JsonPropertyName("active_time")]
|
||||
public long ActiveTime { get; set; }
|
||||
|
||||
[JsonPropertyName("seeding_time")]
|
||||
public long SeedingTime { get; set; }
|
||||
|
||||
[JsonPropertyName("time_since_transfer")]
|
||||
public long TimeSinceTransfer { get; set; }
|
||||
|
||||
[JsonPropertyName("seed_rank")]
|
||||
public int SeedRank { get; set; }
|
||||
|
||||
[JsonPropertyName("last_seen_complete")]
|
||||
public long LastSeenComplete { get; set; }
|
||||
|
||||
[JsonPropertyName("completed_time")]
|
||||
public long CompletedTime { get; set; }
|
||||
|
||||
[JsonPropertyName("owner")] public string Owner { get; set; }
|
||||
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; set; }
|
||||
|
||||
[JsonPropertyName("shared")]
|
||||
public bool Shared { get; set; }
|
||||
|
||||
[JsonPropertyName("queue")] public int Queue { get; set; }
|
||||
|
||||
[JsonPropertyName("total_wanted")]
|
||||
public long TotalWanted { get; set; }
|
||||
|
||||
[JsonPropertyName("state")] public string State { get; set; }
|
||||
|
||||
[JsonPropertyName("progress")]
|
||||
public float Progress { get; set; }
|
||||
|
||||
[JsonPropertyName("num_seeds")]
|
||||
public int NumSeeds { get; set; }
|
||||
|
||||
[JsonPropertyName("total_seeds")]
|
||||
public int TotalSeeds { get; set; }
|
||||
|
||||
[JsonPropertyName("num_peers")]
|
||||
public int NumPeers { get; set; }
|
||||
|
||||
[JsonPropertyName("total_peers")]
|
||||
public int TotalPeers { get; set; }
|
||||
|
||||
[JsonPropertyName("download_payload_rate")]
|
||||
public long DownloadPayloadRate { get; set; }
|
||||
|
||||
[JsonPropertyName("upload_payload_rate")]
|
||||
public long UploadPayloadRate { get; set; }
|
||||
|
||||
[JsonPropertyName("eta")] public long Eta { get; set; }
|
||||
|
||||
[JsonPropertyName("distributed_copies")]
|
||||
public float DistributedCopies { get; set; }
|
||||
|
||||
[JsonPropertyName("time_added")]
|
||||
public int TimeAdded { get; set; }
|
||||
|
||||
[JsonPropertyName("tracker_host")]
|
||||
public string TrackerHost { get; set; }
|
||||
|
||||
[JsonPropertyName("download_location")]
|
||||
public string DownloadLocation { get; set; }
|
||||
|
||||
[JsonPropertyName("total_remaining")]
|
||||
public long TotalRemaining { get; set; }
|
||||
|
||||
[JsonPropertyName("max_download_speed")]
|
||||
public long MaxDownloadSpeed { get; set; }
|
||||
|
||||
[JsonPropertyName("max_upload_speed")]
|
||||
public long MaxUploadSpeed { get; set; }
|
||||
|
||||
[JsonPropertyName("seeds_peers_ratio")]
|
||||
public float SeedsPeersRatio { get; set; }
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public static class PushoverSounds
|
||||
{
|
||||
public const string Pushover = "pushover";
|
||||
public const string Bike = "bike";
|
||||
public const string Bugle = "bugle";
|
||||
public const string Cashregister = "cashregister";
|
||||
public const string Classical = "classical";
|
||||
public const string Cosmic = "cosmic";
|
||||
public const string Falling = "falling";
|
||||
public const string Gamelan = "gamelan";
|
||||
public const string Incoming = "incoming";
|
||||
public const string Intermission = "intermission";
|
||||
public const string Magic = "magic";
|
||||
public const string Mechanical = "mechanical";
|
||||
public const string Pianobar = "pianobar";
|
||||
public const string Siren = "siren";
|
||||
public const string Spacealarm = "spacealarm";
|
||||
public const string Tugboat = "tugboat";
|
||||
public const string Alien = "alien";
|
||||
public const string Climb = "climb";
|
||||
public const string Persistent = "persistent";
|
||||
public const string Echo = "echo";
|
||||
public const string Updown = "updown";
|
||||
public const string Vibrate = "vibrate";
|
||||
public const string None = "none";
|
||||
|
||||
public static readonly string[] All =
|
||||
[
|
||||
Pushover, Bike, Bugle, Cashregister, Classical, Cosmic, Falling,
|
||||
Gamelan, Incoming, Intermission, Magic, Mechanical, Pianobar,
|
||||
Siren, Spacealarm, Tugboat, Alien, Climb, Persistent, Echo,
|
||||
Updown, Vibrate, None
|
||||
];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public sealed class DelugeLoginException : DelugeClientException
|
||||
{
|
||||
public DelugeLoginException() : base("login failed")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public sealed class DelugeLogoutException : DelugeClientException
|
||||
{
|
||||
public DelugeLogoutException() : base("logout failed")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -143,59 +143,7 @@ public class RuleIntervalValidatorTests
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_ReturnsFullGapWhenNoRules()
|
||||
{
|
||||
var gaps = _validator.FindGapsInCoverage(new List<StallRule>());
|
||||
|
||||
gaps.ShouldNotBeEmpty();
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(1);
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Private).ShouldBe(1);
|
||||
|
||||
gaps.First(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldSatisfyAllConditions(
|
||||
gap => gap.Start.ShouldBe(0),
|
||||
gap => gap.End.ShouldBe(100)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_UsesMinimumBoundaries()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Partial",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 40
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Upper",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 60,
|
||||
MaxCompletionPercentage = 90
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
var publicGap = gaps.FirstOrDefault(g => g.PrivacyType == TorrentPrivacyType.Public && g.Start >= 40 && g.End <= 60);
|
||||
publicGap.ShouldNotBeNull();
|
||||
publicGap!.Start.ShouldBe(40);
|
||||
publicGap.End.ShouldBe(60);
|
||||
|
||||
var privateGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Private);
|
||||
privateGap.Start.ShouldBe(0);
|
||||
privateGap.End.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSlowRuleIntervals_AllowsTouchingRanges()
|
||||
@@ -301,86 +249,8 @@ public class RuleIntervalValidatorTests
|
||||
result.Details.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_NoGapsWhenFullyCovered()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Lower",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 50
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Upper",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 50,
|
||||
MaxCompletionPercentage = 100
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
// No public gaps expected
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_NoGapsWhenBothRuleCoversAll()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "BothCoverage",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(0);
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Private).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_ClampsBounds()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "OutOfRange",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 150
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
var publicGap = gaps.FirstOrDefault(g => g.PrivacyType == TorrentPrivacyType.Public);
|
||||
publicGap.ShouldNotBeNull();
|
||||
publicGap!.Start.ShouldBe(0);
|
||||
publicGap.End.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_IgnoresDisabledRules()
|
||||
@@ -583,61 +453,7 @@ public class RuleIntervalValidatorTests
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_IgnoresInvalidIntervalsWhereMaxLessThanMin()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Invalid",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 80,
|
||||
MaxCompletionPercentage = 20
|
||||
}
|
||||
};
|
||||
|
||||
// Invalid interval should be ignored, resulting in full gap
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
var publicGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Public);
|
||||
publicGap.Start.ShouldBe(0);
|
||||
publicGap.End.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_IgnoresDisabledRulesWhenCalculatingCoverage()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Disabled",
|
||||
Enabled = false,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(1);
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Private).ShouldBe(1);
|
||||
|
||||
var publicGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Public);
|
||||
publicGap.Start.ShouldBe(0);
|
||||
publicGap.End.ShouldBe(100);
|
||||
|
||||
var privateGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Private);
|
||||
privateGap.Start.ShouldBe(0);
|
||||
privateGap.End.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DoesNotReportSelfOverlapWhenEnablingDisabledRule()
|
||||
|
||||
@@ -89,37 +89,6 @@ public sealed class DelugeClient
|
||||
|
||||
return id is { ValueKind: JsonValueKind.String } hostId ? hostId.GetString() : null;
|
||||
}
|
||||
|
||||
public async Task<List<DelugeTorrent>> ListTorrents(Dictionary<string, string>? filters = null)
|
||||
{
|
||||
filters ??= new Dictionary<string, string>();
|
||||
var keys = typeof(DelugeTorrent).GetAllJsonPropertyFromType();
|
||||
Dictionary<string, DelugeTorrent>? result =
|
||||
await SendRequest<Dictionary<string, DelugeTorrent>>("core.get_torrents_status", filters, keys);
|
||||
return result?.Values.ToList() ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<DelugeTorrentExtended>> ListTorrentsExtended(Dictionary<string, string>? filters = null)
|
||||
{
|
||||
filters ??= new Dictionary<string, string>();
|
||||
var keys = typeof(DelugeTorrentExtended).GetAllJsonPropertyFromType();
|
||||
Dictionary<string, DelugeTorrentExtended>? result =
|
||||
await SendRequest<Dictionary<string, DelugeTorrentExtended>>("core.get_torrents_status", filters, keys);
|
||||
return result?.Values.ToList() ?? [];
|
||||
}
|
||||
|
||||
public async Task<DelugeTorrent?> GetTorrent(string hash)
|
||||
{
|
||||
List<DelugeTorrent> torrents = await ListTorrents(new Dictionary<string, string>() { { "hash", hash } });
|
||||
return torrents.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<DelugeTorrentExtended?> GetTorrentExtended(string hash)
|
||||
{
|
||||
List<DelugeTorrentExtended> torrents =
|
||||
await ListTorrentsExtended(new Dictionary<string, string> { { "hash", hash } });
|
||||
return torrents.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<DownloadStatus?> GetTorrentStatus(string hash)
|
||||
{
|
||||
|
||||
-6
@@ -26,12 +26,6 @@ public sealed class DelugeClientWrapper : IDelugeClientWrapper
|
||||
public Task<DelugeContents?> GetTorrentFiles(string hash)
|
||||
=> _client.GetTorrentFiles(hash);
|
||||
|
||||
public Task<DelugeTorrent?> GetTorrent(string hash)
|
||||
=> _client.GetTorrent(hash);
|
||||
|
||||
public Task<DelugeTorrentExtended?> GetTorrentExtended(string hash)
|
||||
=> _client.GetTorrentExtended(hash);
|
||||
|
||||
public Task<List<DownloadStatus>?> GetStatusForAllTorrents()
|
||||
=> _client.GetStatusForAllTorrents();
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
|
||||
public partial class DelugeService : DownloadService, IDelugeService
|
||||
public partial class DelugeService : DownloadService
|
||||
{
|
||||
private readonly IDelugeClientWrapper _client;
|
||||
|
||||
|
||||
-2
@@ -9,8 +9,6 @@ public interface IDelugeClientWrapper
|
||||
Task<bool> Connect();
|
||||
Task<DownloadStatus?> GetTorrentStatus(string hash);
|
||||
Task<DelugeContents?> GetTorrentFiles(string hash);
|
||||
Task<DelugeTorrent?> GetTorrent(string hash);
|
||||
Task<DelugeTorrentExtended?> GetTorrentExtended(string hash);
|
||||
Task<List<DownloadStatus>?> GetStatusForAllTorrents();
|
||||
Task DeleteTorrents(List<string> hashes, bool removeData);
|
||||
Task PauseTorrents(List<string> hashes);
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
|
||||
public interface IDelugeService : IDownloadService
|
||||
{
|
||||
}
|
||||
@@ -333,26 +333,6 @@ public abstract class DownloadService : IDownloadService
|
||||
return new();
|
||||
}
|
||||
|
||||
protected string? GetRootWithFirstDirectory(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? root = Path.GetPathRoot(path);
|
||||
|
||||
if (root is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string relativePath = path[root.Length..].TrimStart(Path.DirectorySeparatorChar);
|
||||
string[] parts = relativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
return parts.Length > 0 ? Path.Combine(root, parts[0]) : root;
|
||||
}
|
||||
|
||||
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, ISeedingRule seedingRule)
|
||||
{
|
||||
if (seedingRule.MaxRatio < 0)
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
|
||||
public interface IQBitService : IDownloadService, IDisposable
|
||||
{
|
||||
Task UpdateBlacklistAsync(string blacklistPath);
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ using QBittorrent.Client;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
|
||||
public partial class QBitService : DownloadService, IQBitService
|
||||
public partial class QBitService : DownloadService, IDisposable
|
||||
{
|
||||
protected readonly IQBittorrentClientWrapper _client;
|
||||
private bool? _altSpeedLimitActive;
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
public interface IRTorrentService : IDownloadService
|
||||
{
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
public partial class RTorrentService : DownloadService, IRTorrentService
|
||||
public partial class RTorrentService : DownloadService
|
||||
{
|
||||
private readonly IRTorrentClientWrapper _client;
|
||||
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
|
||||
public interface ITransmissionService : IDownloadService
|
||||
{
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
|
||||
public partial class TransmissionService : DownloadService, ITransmissionService
|
||||
public partial class TransmissionService : DownloadService
|
||||
{
|
||||
private readonly ITransmissionClientWrapper _client;
|
||||
private bool? _altSpeedLimitActive;
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for µTorrent download service
|
||||
/// </summary>
|
||||
public interface IUTorrentService : IDownloadService
|
||||
{
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
/// µTorrent download service implementation
|
||||
/// Provides business logic layer on top of UTorrentClient
|
||||
/// </summary>
|
||||
public partial class UTorrentService : DownloadService, IUTorrentService
|
||||
public partial class UTorrentService : DownloadService
|
||||
{
|
||||
private readonly IUTorrentClientWrapper _client;
|
||||
|
||||
|
||||
@@ -23,6 +23,5 @@ public static class CacheKeys
|
||||
public static class UTorrent
|
||||
{
|
||||
public static string GetAuthTokenKey(string clientId) => $"utorrent:auth:token:{clientId}";
|
||||
public static string GetGuidCookieKey(string clientId) => $"utorrent:auth:cookie:{clientId}";
|
||||
}
|
||||
}
|
||||
-12
@@ -8,7 +8,6 @@ namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
public class HttpClientConfigStore : IHttpClientConfigStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, HttpClientConfig> _configurations = new();
|
||||
private readonly ConcurrentDictionary<string, RetryConfig> _retryConfigurations = new();
|
||||
|
||||
public bool TryGetConfiguration(string clientName, out HttpClientConfig config)
|
||||
{
|
||||
@@ -23,17 +22,6 @@ public class HttpClientConfigStore : IHttpClientConfigStore
|
||||
public void RemoveConfiguration(string clientName)
|
||||
{
|
||||
_configurations.TryRemove(clientName, out _);
|
||||
_retryConfigurations.TryRemove(clientName, out _);
|
||||
}
|
||||
|
||||
public void AddRetryConfiguration(string clientName, RetryConfig retryConfig)
|
||||
{
|
||||
_retryConfigurations.AddOrUpdate(clientName, retryConfig, (key, oldValue) => retryConfig);
|
||||
}
|
||||
|
||||
public bool TryGetRetryConfiguration(string clientName, out RetryConfig retryConfig)
|
||||
{
|
||||
return _retryConfigurations.TryGetValue(clientName, out retryConfig!);
|
||||
}
|
||||
|
||||
public IEnumerable<KeyValuePair<string, HttpClientConfig>> GetAllConfigurations()
|
||||
|
||||
-5
@@ -20,11 +20,6 @@ public interface IHttpClientConfigStore
|
||||
/// </summary>
|
||||
void RemoveConfiguration(string clientName);
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a retry configuration for the specified client name
|
||||
/// </summary>
|
||||
void AddRetryConfiguration(string clientName, RetryConfig retryConfig);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all currently registered configurations
|
||||
/// </summary>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Maintains a buffer of recent log entries for newly connected clients
|
||||
/// </summary>
|
||||
public class LogBuffer
|
||||
{
|
||||
private readonly ConcurrentQueue<object> _recentLogs;
|
||||
private readonly int _bufferSize;
|
||||
|
||||
public LogBuffer(int bufferSize)
|
||||
{
|
||||
_bufferSize = Math.Max(10, bufferSize);
|
||||
_recentLogs = new ConcurrentQueue<object>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a log entry to the buffer
|
||||
/// </summary>
|
||||
/// <param name="logEvent">The log event to buffer</param>
|
||||
public void AddLog(object logEvent)
|
||||
{
|
||||
_recentLogs.Enqueue(logEvent);
|
||||
|
||||
// Trim buffer if it exceeds size
|
||||
while (_recentLogs.Count > _bufferSize && _recentLogs.TryDequeue(out _)) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all buffered log entries
|
||||
/// </summary>
|
||||
/// <returns>Collection of recent log events</returns>
|
||||
public IEnumerable<object> GetRecentLogs() => _recentLogs.ToArray();
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Models;
|
||||
|
||||
public class IntervalGap
|
||||
{
|
||||
public TorrentPrivacyType PrivacyType { get; set; }
|
||||
public double Start { get; set; }
|
||||
public double End { get; set; }
|
||||
|
||||
public string GetPrivacyTypeDisplayName()
|
||||
{
|
||||
return PrivacyType switch
|
||||
{
|
||||
TorrentPrivacyType.Public => "Public",
|
||||
TorrentPrivacyType.Private => "Private",
|
||||
TorrentPrivacyType.Both => "Both",
|
||||
_ => "Unknown"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,4 @@ public interface IRuleIntervalValidator
|
||||
/// <param name="existingRules">Existing rules (excluding the one being updated if applicable)</param>
|
||||
/// <returns>Validation result with error details if invalid</returns>
|
||||
ValidationResult ValidateSlowRuleIntervals(SlowRule newRule, List<SlowRule> existingRules);
|
||||
|
||||
/// <summary>
|
||||
/// Finds gaps in coverage for a given set of rules
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Rule type (StallRule or SlowRule)</typeparam>
|
||||
/// <param name="rules">List of rules to analyze</param>
|
||||
/// <returns>List of gaps in coverage</returns>
|
||||
List<IntervalGap> FindGapsInCoverage<T>(List<T> rules) where T : QueueRule;
|
||||
}
|
||||
@@ -35,22 +35,6 @@ public class RuleIntervalValidator : IRuleIntervalValidator
|
||||
return ValidateRuleIntervals(allRules, newRule.Name);
|
||||
}
|
||||
|
||||
public List<IntervalGap> FindGapsInCoverage<T>(List<T> rules) where T : QueueRule
|
||||
{
|
||||
_logger.LogDebug("Finding gaps in coverage for {rule} rules", rules.Count);
|
||||
|
||||
var gaps = new List<IntervalGap>();
|
||||
var enabledRules = rules.Where(r => r.Enabled).ToList();
|
||||
|
||||
// Find gaps for each privacy type
|
||||
gaps.AddRange(FindGapsForPrivacyType(enabledRules, TorrentPrivacyType.Public));
|
||||
gaps.AddRange(FindGapsForPrivacyType(enabledRules, TorrentPrivacyType.Private));
|
||||
|
||||
_logger.LogDebug("Found {GapCount} gaps in coverage", gaps.Count);
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the provided rules do not create overlapping intervals.
|
||||
/// </summary>
|
||||
@@ -204,75 +188,6 @@ public class RuleIntervalValidator : IRuleIntervalValidator
|
||||
return overlaps;
|
||||
}
|
||||
|
||||
private static List<IntervalGap> FindGapsForPrivacyType<T>(List<T> rules, TorrentPrivacyType privacyType) where T : QueueRule
|
||||
{
|
||||
var gaps = new List<IntervalGap>();
|
||||
|
||||
// Get relevant intervals for this privacy type
|
||||
var relevantRules = rules.Where(r =>
|
||||
r.PrivacyType == privacyType ||
|
||||
r.PrivacyType == TorrentPrivacyType.Both).ToList();
|
||||
|
||||
if (!relevantRules.Any())
|
||||
{
|
||||
gaps.Add(new IntervalGap
|
||||
{
|
||||
PrivacyType = privacyType,
|
||||
Start = 0,
|
||||
End = 100
|
||||
});
|
||||
return gaps;
|
||||
}
|
||||
|
||||
var intervals = relevantRules
|
||||
.Select(r => new
|
||||
{
|
||||
Start = Math.Max(0, Math.Min(100, (int)r.MinCompletionPercentage)),
|
||||
End = Math.Max(0, Math.Min(100, (int)r.MaxCompletionPercentage))
|
||||
})
|
||||
.Where(i => i.End >= i.Start)
|
||||
.OrderBy(i => i.Start)
|
||||
.ThenBy(i => i.End)
|
||||
.ToList();
|
||||
|
||||
double currentCoverageEnd = 0;
|
||||
|
||||
foreach (var interval in intervals)
|
||||
{
|
||||
if (interval.Start > currentCoverageEnd)
|
||||
{
|
||||
gaps.Add(new IntervalGap
|
||||
{
|
||||
PrivacyType = privacyType,
|
||||
Start = currentCoverageEnd,
|
||||
End = interval.Start
|
||||
});
|
||||
}
|
||||
|
||||
if (interval.End > currentCoverageEnd)
|
||||
{
|
||||
currentCoverageEnd = interval.End;
|
||||
}
|
||||
|
||||
if (currentCoverageEnd >= 100)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCoverageEnd < 100)
|
||||
{
|
||||
gaps.Add(new IntervalGap
|
||||
{
|
||||
PrivacyType = privacyType,
|
||||
Start = currentCoverageEnd,
|
||||
End = 100
|
||||
});
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
private class OverlapResult
|
||||
{
|
||||
public string ConflictingRuleName { get; set; } = string.Empty;
|
||||
|
||||
-275
@@ -1,275 +0,0 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Tests.Models.Configuration.QueueCleaner;
|
||||
|
||||
public sealed class SlowConfigTests
|
||||
{
|
||||
#region Validate - Valid Configurations
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithDisabledConfig_DoesNotThrow()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 0
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithValidMinSpeed_DoesNotThrow()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = "100KB",
|
||||
MaxTime = 0
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithValidMaxTime_DoesNotThrow()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = "",
|
||||
MaxTime = 24
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithBothMinSpeedAndMaxTime_DoesNotThrow()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = "1MB",
|
||||
MaxTime = 48
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - MaxStrikes Validation
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)1)]
|
||||
[InlineData((ushort)2)]
|
||||
public void Validate_WithMaxStrikesBetween1And2_ThrowsValidationException(ushort maxStrikes)
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = maxStrikes,
|
||||
MinSpeed = "100KB",
|
||||
MaxTime = 0
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("the minimum value for slow max strikes must be 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMinimumValidMaxStrikes_DoesNotThrow()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = "100KB",
|
||||
MaxTime = 0
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - MinSpeed Validation
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("100")]
|
||||
[InlineData("KB")]
|
||||
public void Validate_WithInvalidMinSpeedFormat_ThrowsValidationException(string minSpeed)
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = minSpeed,
|
||||
MaxTime = 0
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("invalid value for slow min speed");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1KB")]
|
||||
[InlineData("100KB")]
|
||||
[InlineData("1MB")]
|
||||
[InlineData("1GB")]
|
||||
public void Validate_WithValidMinSpeedFormats_DoesNotThrow(string minSpeed)
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = minSpeed,
|
||||
MaxTime = 0
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - MaxTime Validation
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithNegativeMaxTime_ThrowsValidationException()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = "100KB",
|
||||
MaxTime = -1
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("invalid value for slow max time");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - MinSpeed and MaxTime Required
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithNoMinSpeedAndNoMaxTime_ThrowsValidationException()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = "",
|
||||
MaxTime = 0
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("either slow min speed or slow max time must be set");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - IgnoreAboveSize Validation
|
||||
|
||||
[Theory]
|
||||
[InlineData("100MB")]
|
||||
[InlineData("1GB")]
|
||||
[InlineData("10GB")]
|
||||
public void Validate_WithValidIgnoreAboveSize_DoesNotThrow(string ignoreAboveSize)
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = "100KB",
|
||||
MaxTime = 0,
|
||||
IgnoreAboveSize = ignoreAboveSize
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyIgnoreAboveSize_DoesNotThrow()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = "100KB",
|
||||
MaxTime = 0,
|
||||
IgnoreAboveSize = ""
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("100")]
|
||||
public void Validate_WithInvalidIgnoreAboveSizeFormat_ThrowsValidationException(string ignoreAboveSize)
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MaxStrikes = 3,
|
||||
MinSpeed = "100KB",
|
||||
MaxTime = 0,
|
||||
IgnoreAboveSize = ignoreAboveSize
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldContain("invalid value for slow ignore above size");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ByteSize Property Tests
|
||||
|
||||
[Fact]
|
||||
public void MinSpeedByteSize_WithValidSpeed_ParsesCorrectly()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MinSpeed = "1MB"
|
||||
};
|
||||
|
||||
config.MinSpeedByteSize.Bytes.ShouldBe(1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinSpeedByteSize_WithEmptySpeed_ReturnsZero()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MinSpeed = ""
|
||||
};
|
||||
|
||||
config.MinSpeedByteSize.Bytes.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoreAboveSizeByteSize_WithValidSize_ParsesCorrectly()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MinSpeed = "100KB",
|
||||
IgnoreAboveSize = "1GB"
|
||||
};
|
||||
|
||||
config.IgnoreAboveSizeByteSize.ShouldNotBeNull();
|
||||
config.IgnoreAboveSizeByteSize!.Value.Bytes.ShouldBe(1024L * 1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoreAboveSizeByteSize_WithEmptySize_ReturnsNull()
|
||||
{
|
||||
var config = new SlowConfig
|
||||
{
|
||||
MinSpeed = "100KB",
|
||||
IgnoreAboveSize = ""
|
||||
};
|
||||
|
||||
config.IgnoreAboveSizeByteSize.ShouldBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
|
||||
public interface IQueueRule
|
||||
{
|
||||
Guid Id { get; }
|
||||
|
||||
string Name { get; }
|
||||
|
||||
bool Enabled { get; }
|
||||
|
||||
int MaxStrikes { get; }
|
||||
|
||||
TorrentPrivacyType PrivacyType { get; }
|
||||
|
||||
ushort MinCompletionPercentage { get; }
|
||||
|
||||
ushort MaxCompletionPercentage { get; }
|
||||
|
||||
bool MatchesTorrent(ITorrentItemWrapper torrent);
|
||||
|
||||
void Validate();
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
|
||||
public abstract record QueueRule : IConfig, IQueueRule
|
||||
public abstract record QueueRule : IConfig
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
|
||||
[ComplexType]
|
||||
public sealed record SlowConfig
|
||||
{
|
||||
public ushort MaxStrikes { get; init; }
|
||||
|
||||
public bool ResetStrikesOnProgress { get; init; }
|
||||
|
||||
public bool IgnorePrivate { get; init; }
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public string MinSpeed { get; init; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public ByteSize MinSpeedByteSize => string.IsNullOrEmpty(MinSpeed) ? new ByteSize(0) : ByteSize.Parse(MinSpeed);
|
||||
|
||||
public double MaxTime { get; init; }
|
||||
|
||||
public string IgnoreAboveSize { get; init; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public ByteSize? IgnoreAboveSizeByteSize => string.IsNullOrEmpty(IgnoreAboveSize) ? null : ByteSize.Parse(IgnoreAboveSize);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException("the minimum value for slow max strikes must be 3");
|
||||
}
|
||||
|
||||
if (MaxStrikes > 0)
|
||||
{
|
||||
bool isSpeedSet = !string.IsNullOrEmpty(MinSpeed);
|
||||
|
||||
if (isSpeedSet && ByteSize.TryParse(MinSpeed, out _) is false)
|
||||
{
|
||||
throw new ValidationException("invalid value for slow min speed");
|
||||
}
|
||||
|
||||
if (MaxTime < 0)
|
||||
{
|
||||
throw new ValidationException("invalid value for slow max time");
|
||||
}
|
||||
|
||||
if (!isSpeedSet && MaxTime is 0)
|
||||
{
|
||||
throw new ValidationException("either slow min speed or slow max time must be set");
|
||||
}
|
||||
|
||||
bool isIgnoreAboveSizeSet = !string.IsNullOrEmpty(IgnoreAboveSize);
|
||||
|
||||
if (isIgnoreAboveSizeSet && ByteSize.TryParse(IgnoreAboveSize, out _) is false)
|
||||
{
|
||||
throw new ValidationException($"invalid value for slow ignore above size: {IgnoreAboveSize}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace Cleanuparr.Persistence.Models.Events;
|
||||
[Index(nameof(ArrInstanceId))]
|
||||
[Index(nameof(CycleId))]
|
||||
[Index(nameof(DeleteReason))]
|
||||
public class AppEvent : IEvent
|
||||
public class AppEvent
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.CreateVersion7();
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Cleanuparr.Persistence.Models.Events;
|
||||
|
||||
public interface IEvent
|
||||
{
|
||||
Guid Id { get; set; }
|
||||
|
||||
DateTimeOffset Timestamp { get; set; }
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill:#e5e5e5"/><path d="M460.9 256.8c0-113.5-92-205.5-205.5-205.5V35.7l-9.2 15.7C137 56.3 49.9 146.3 49.9 256.8c0 113.5 92 205.5 205.5 205.5v15.5l9.2-15.7c109.2-4.9 196.3-94.9 196.3-205.3m-96.4-109.2c13.8 13.8 24.6 29.7 32.2 47H255.4v-92.2c41.2 0 80 16 109.1 45.2m-218.3 0c18.9-18.9 41.8-32.3 66.8-39.4L105.2 292.8q-4.2-17.55-4.2-36c0-41.3 16.1-80 45.2-109.2m0 218.3c-13.8-13.8-24.6-29.7-32.2-47h141.4v92.2c-41.3 0-80-16-109.2-45.2m151.5 39.4 107.8-184.6q4.2 17.55 4.2 36c0 41.2-16.1 80-45.2 109.2-18.8 18.9-41.8 32.3-66.8 39.4"/><path d="M447.5 175.6c-10.5-24.8-25.5-47.1-44.7-66.3-19.1-19.1-41.4-34.2-66.3-44.7-24.5-10.4-50.4-15.8-77.2-16.3V20.9l-16.2 27.7c-23.9 1.4-47 6.7-69 16-24.8 10.5-47.1 25.5-66.3 44.7-19.1 19.1-34.2 41.4-44.7 66.3-10.9 25.7-16.4 53-16.4 81.2s5.5 55.5 16.4 81.2c10.5 24.8 25.5 47.1 44.7 66.3s41.4 34.2 66.3 44.7c24.5 10.4 50.4 15.8 77.2 16.3v27.4l16.2-27.7c23.9-1.4 47-6.7 69-16 24.8-10.5 47.1-25.5 66.3-44.7s34.2-41.4 44.7-66.3c10.9-25.7 16.4-53 16.4-81.2s-5.5-55.5-16.4-81.2m-144.5 23h110.8L261.6 459.2c-.7 0-1.5 0-2.2.1V331.8c39.7-2.1 71.2-34.9 71.2-75.1 0-23.4-10.8-44.3-27.6-58.1m-95.3 116.3H97L249.1 54.4c.7 0 1.5 0 2.2-.1v127.4c-39.7 2.1-71.2 34.9-71.2 75.1.1 23.4 10.8 44.3 27.6 58.1M52.9 256.8c0-106.3 82.4-193.8 186.7-201.9L83 322.9h136.7c9.5 5.2 20.3 8.3 31.7 8.9v127.4c-109.8-2.1-198.5-92.1-198.5-202.4m218.3 201.8 156.6-268H291.1c-9.5-5.2-20.3-8.3-31.7-8.9V54.3c109.8 2.1 198.5 92.1 198.5 202.5 0 106.3-82.4 193.7-186.7 201.8" style="fill:#009252"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m80.3 80.8 3.9 372.4c-31.4 3.9-54.9-11.8-54.9-43.1l-3.9-309.7c0-98 90.2-121.5 145.1-82.3l278.3 160.7c39.2 27.4 47 78.4 27.4 113.7-3.9-27.4-15.7-43.1-39.2-58.8L123.4 57.2C99.9 41.6 80.3 45.5 80.3 80.8m-23.5 392c23.5 7.8 47 3.9 66.6-7.8l321.5-188.2c19.6 27.4 15.7 54.9-7.8 70.6L166.5 504.2c-39.2 19.6-90.1 0-109.7-31.4" style="fill:#24292e"/><path d="M150.9 363 343 253.3 154.8 147.4z" style="fill:#ffc230"/></svg>
|
||||
|
Before Width: | Height: | Size: 504 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="readarr_svg__Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.readarr_svg__st0{fill:#eee}</style><circle id="readarr_svg__svg_2_00000098218664590018320060000008938703571635696276_" cx="256" cy="256" r="255.6" class="readarr_svg__st0"/><path d="M256 512c-34.6 0-68.1-6.8-99.6-20.1C125.9 479 98.5 460.5 75 437s-42-50.9-54.9-81.4C6.8 324.1 0 290.6 0 256s6.8-68.1 20.1-99.6C33 125.9 51.5 98.5 75 75s50.9-42 81.4-54.9C187.9 6.8 221.4 0 256 0s68.1 6.8 99.6 20.1C386.1 33 413.5 51.5 437 75s42 50.9 54.9 81.4C505.2 188 512 221.5 512 256s-6.8 68.1-20.1 99.6C479 386.1 460.5 413.5 437 437s-50.9 42-81.4 54.9c-31.5 13.3-65 20.1-99.6 20.1M256 .8C115.3.8.8 115.3.8 256S115.3 511.2 256 511.2 511.2 396.7 511.2 256 396.7.8 256 .8M459.6 170c-11.1-26.3-27.1-49.9-47.3-70.2s-44-36.2-70.3-47.4C314.8 40.9 285.8 35 256 35s-58.8 5.8-86 17.4c-26.3 11.1-49.9 27.1-70.2 47.3s-36.2 44-47.4 70.3C40.9 197.2 35 226.2 35 256s5.8 58.8 17.4 86c11.1 26.3 27.1 49.9 47.3 70.2s43.9 36.2 70.2 47.3c27.2 11.5 56.2 17.4 86 17.4s58.8-5.8 86-17.4c26.3-11.1 49.9-27.1 70.2-47.3s36.2-43.9 47.3-70.2c11.5-27.2 17.4-56.2 17.4-86 .1-29.8-5.7-58.8-17.2-86" style="fill:#443c3c"/><path d="M425.9 172.1c.1-6.3.2-9.9.3-10-.3-26.7-8.8-24-15.3-24.8-2.4.1-4.7.3-6.9.4-34.8-43.4-88.1-71.2-148-71.2s-113.4 27.8-148.1 71.3c-2.6-.2-5.3-.4-8.1-.5-6.5.8-14.9-1.9-15.3 24.8.1.1.2 4.7.3 12.5C73 199.3 66.4 226.9 66.4 256c0 31.5 7.7 61.2 21.3 87.3.3 1.2.9 2.6 1.9 3.6 32.2 58.7 94.6 98.6 166.3 98.6 104.6 0 189.5-84.8 189.5-189.5.1-30.1-7-58.6-19.5-83.9M255.3 389.9c-1.8 0-3.6 0-5.4-.1-.2-42.5-1.6-199.7-1.9-200 .3.3-3.5-13.1-10.2-15.6-1.5-.8-18.6-14.9-60.3-25.6 22.5-16.3 49.5-25.1 77.8-25.1s55.3 8.8 77.8 25c-41.6 10.7-58.7 24.9-60.2 25.6l-2.8 2c-5.9 3.5-6.6 8.1-6.2 7.8-.3.3-2.6 161.7-3 205.8-1.9.1-3.7.2-5.6.2" class="readarr_svg__st0"/><path d="M450 256c0-32-7.7-62.2-21.5-88.8 0-1.9.1-3.3.1-4.1l.1-.1v-1c-.3-24.2-7.2-26.6-15.5-27.1-.7 0-1.4-.1-2-.2h-.4c-1.1.1-2.2.1-3.2.2C372 90.5 317.3 62 256 62c-61.5 0-116.4 28.7-151.9 73.4-1.3-.1-2.7-.2-4-.2h-.4c-.6.1-1.3.1-2 .2-8.3.5-15.2 2.9-15.5 27.1v1l.1.1c0 1.1.1 3.1.1 6C69.4 195.5 62 224.9 62 256c0 32.8 8.2 63.7 22.5 90.8.1 8.1.2 15.8.3 22.7 0 2.9 1.5 8.5 7.2 8.9.3.1.8.2 1.6.3 4 .7 8.9 1.7 14.5 2.9C143.8 423.5 196.8 450 256 450s112.2-26.5 147.8-68.3c5.1-1.1 9.6-2 13.3-2.6.8-.1 1.3-.2 1.6-.3 5.7-.4 7.2-6 7.2-8.9.1-6.3.2-13.3.3-20.6 15.1-27.7 23.8-59.5 23.8-93.3m-383.1 0c0-26.8 5.6-52.2 15.7-75.3v.5q-.45 4.2-.6 9.6v.5c.3 6.4 1.5 84 2.4 144-11.2-24.1-17.5-51-17.5-79.3m185.6 172.9v-9c0-3.6 0-8.2-.1-13.7.3-1.5.4-3.4.1-5.6v-9c0-5.6-.1-13.6-.1-23.9-.1-19.1-.3-44.7-.6-72.3-.2-27.5-.5-53.2-.7-72.3-.1-10.3-.2-18.3-.3-23.9 0-2.9-.1-5.2-.1-6.7 0-.8 0-1.4-.1-1.9v-.5c0-.1 0-.2-.1-.4 0-.2-.1-.4-.1-.5-.8-3.3-4.5-14.1-11.4-16.9-.2-.1-.5-.3-.8-.5-16.2-11.3-54.1-30.4-128.2-35.9C144.7 93.8 197.2 67 255.9 67c58.4 0 110.7 26.6 145.4 68.4-74.6 5.4-112.6 24.7-128.9 36.1-.3.2-.6.4-.8.5-6.9 2.8-10.6 13.6-11.4 16.9-.1.1-.1.3-.1.5s-.1.3-.1.4v.4c0 .5 0 1.1-.1 2 0 1.6-.1 3.8-.1 6.7-.1 5.6-.2 13.6-.3 23.9-.2 19.1-.5 44.9-.7 72.5s-.5 53.3-.6 72.4c-.1 10.3-.1 18.3-.1 23.9v9c-.3 2-.2 3.7 0 5.2 0 5.8-.1 10.7-.1 14.4v9c-1 6.9 1.6 9.8 4 10.9 1 .5 2.3.8 3.4.8.8 0 1.4-.1 1.8-.4 42-32.5 94.6-49.1 128.2-57-34.6 37.8-84.3 61.6-139.5 61.6S151 421.4 116.4 383.6c33.6 8 85.4 24.6 126.9 56.6.4.3 1 .4 1.8.4 1.1 0 2.3-.3 3.4-.8 2.4-1.2 5-4 4-10.9m176.2-237.7v-.6c0-3.4-.2-6.4-.5-9 0-1.2 0-2.3.1-3.4 10.8 23.8 16.8 50.1 16.8 77.9 0 29.4-6.7 57.3-18.8 82.1.9-60.6 2.1-140.6 2.4-147m-17.9-51.5c.7.1 1.4.1 2.1.2 5.4.3 10.5.7 10.8 22.1 0 .5-.1 1.1-.1 2 0 1-.1 2.4-.1 4.1-2.8-3-6.2-3.2-9.2-3.4-.7 0-1.4-.1-2.1-.2h-.2c-81.7 4.4-122.7 24.7-139.8 36.4-.4.3-.8.5-.9.6-2.2 1.9-4.4 4.7-6.3 7.5.1-11.3.3-18.6.3-19.5.7-2.6 4.1-11.5 8.5-13.1l.2-.1c.3-.2.7-.4 1.4-.9 16.5-11.4 56.1-31.3 135.4-35.7m-312.8.6c.7 0 1.4-.1 2.1-.2 79.4 4.4 118.9 24.2 135.4 35.7.7.5 1.1.7 1.4.9l.2.1c4.6 1.7 8.1 11.4 8.5 13.4l.1 1.3c.1 2.5.1 8.1.2 16.1-1.7-2.6-3.9-4.9-6.4-5.9-.2-.1-.5-.4-.9-.6-17.1-11.7-58.1-31.9-139.8-36.3h-.2c-.7.1-1.4.1-2.1.2-3 .2-6.4.4-9.2 3.3 0-1.6-.1-2.9-.1-3.8s0-1.6-.1-2c.3-21.5 5.4-21.8 10.9-22.2" style="fill:#8e2222"/></svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M511.8 256c0 70.4-24.9 130.8-74.6 181.1-1.7 2-3.5 3.8-5.5 5.4-8.2 8-16.8 15.3-26 21.8Q341.05 512 256.3 512c-56.6 0-106.3-15.9-149.2-47.7-11.3-8-22-17.1-31.9-27.3C36.5 398.7 12.8 354 4 303.2c-1.7-9.9-2.9-20-3.4-30.2-.2-5.7-.4-11.3-.4-17 0-6 .1-11.7.4-17.1 0-.6.2-1.1.5-1.7 3.7-62.8 28.4-117 74.1-162.8C125.5 24.8 185.8 0 256.2 0c70.7 0 131 24.8 180.9 74.5q74.7 75.9 74.7 181.5" style="fill-rule:evenodd;clip-rule:evenodd;fill:#eee"/><path d="m459.7 100.3-52.9 52.9c-30.9 30.9-33.6 57.8-33.6 105.3 0 42.3 6.7 81.1 38.2 112.6 23 23 44.9 44.7 44.9 44.7-5.9 7.2-12.3 14.3-19.1 21.2-1.7 2-3.5 3.8-5.5 5.4-6 5.9-12.2 11.4-18.6 16.4l-41.4-41.4C334.9 380.6 305.6 377 257 377c-46.7 0-78.4 4.3-112.6 38.5-20.4 20.4-43.8 43.9-43.8 43.9-8.9-6.8-17.3-14.2-25.3-22.4-6.6-6.6-12.8-13.4-18.5-20.3 0 0 23.1-23.2 45.2-45.3 32.7-32.7 38-70.6 38-113 0-41.3-6.8-79.8-36.8-109.9C82.2 127.7 53.3 99 53.3 99c6.7-8.5 14-16.7 21.8-24.5 6.9-6.8 14-13.1 21.2-19l48 48c30.7 30.7 70 38.6 112.4 38.6 43.6 0 82.8-8.4 114.7-40.4C391 82.1 417 56.3 417 56.3c6.8 5.6 13.5 11.6 20.1 18.2 8.3 8.3 15.8 16.9 22.6 25.8" style="fill-rule:evenodd;clip-rule:evenodd;fill:#3a3f51"/><path d="M186 269.1c-.5-2.8-.8-5.5-.9-8.4-.1-1.6-.1-3.1-.1-4.7 0-1.7 0-3.2.1-4.7 0-.2 0-.3.1-.5 1-17.4 7.9-32.4 20.5-45.1 13.9-13.8 30.6-20.7 50.2-20.7s36.3 6.9 50.2 20.7c13.8 14 20.7 30.8 20.7 50.3s-6.9 36.2-20.7 50.2c-.5.5-1 1.1-1.5 1.5q-3.45 3.3-7.2 6-18 13.2-41.4 13.2c-23.4 0-29.4-4.4-41.3-13.2-3.1-2.2-6.1-4.7-8.9-7.6-10.8-10.6-17.3-22.9-19.8-37m189.2-125.6-1.8-1.8 66.4-64.6-1.4-1.4-66.4 64.6.7.7-.7-.7-1.8-1.8-40 40.6 5 5zm-238.3 2.1 40.6 40.5 5-5-40.6-40.5-1.8 1.8-66.5-66.3-1.4 1.4 66.5 66.3zm234.9 223.9-42.6-42.4-5 5 42.6 42.4 1.8-1.8 65.4 67.7 1.4-1.4-65.4-67.7zm-233.3 2.1 1.8 1.7-64.4 64.5 1.4 1.4 64.4-64.5 1.8 1.7 39.5-41.1-5-4.8z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#0cf"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="243.1" style="fill:#ff69b4"/><path d="M491.9 156.4c-1.4-3.3-2.8-6.5-4.4-9.7.3 0 .6-.1.9-.1 3.6-.5 6.8-1.8 9.7-3.7q7.95-4.2 9-8.7c.4-1.7-.3-2.5-2.3-2.5-3.7 0-7.5 1.7-11.3 5-2.9 2.5-5.8 4.1-8.5 4.7-12.3-24.5-28.4-46.8-48-66.4-23.5-23.5-50.9-42-81.3-54.9C324.1 6.8 290.6 0 256 0s-68.1 6.8-99.6 20.1C125.9 33 98.5 51.5 75 75s-42 50.9-54.9 81.3C6.8 187.9 0 221.4 0 256s6.8 68.1 20.1 99.6C33 386.1 51.5 413.5 75 437s50.9 42 81.3 54.9c31.6 13.3 65.1 20.1 99.7 20.1s68.1-6.8 99.6-20.1c30.5-12.9 57.9-31.4 81.4-54.9s42-50.9 54.9-81.2c13.3-31.6 20.1-65.1 20.1-99.7s-6.7-68.1-20.1-99.7M256 25.8c79.8 0 150.3 40.9 191.6 102.8-.1.2-.1.5-.2.7-.8 3.6.2 7 3.1 10.3 2.7 3.6 5.4 6.1 8.3 7.6 1.7 3.2 3.4 6.4 4.9 9.7v.1c-4.1 17.3-16.1 40.6-36 69.8-25.4 37.2-56 71.8-91.7 103.7-10 9.1-20.5 17.7-31.5 26-11 8.2-21.7 15-32.1 20.2s-19.8 7.8-28.1 7.8q-11.4 0-16.8-6.6c-3.6-4.4-4.2-11.6-1.9-21.5 4-16.8 16.8-41.4 38.4-73.9 30.3-45.5 60.5-83.9 90.4-115.3-1.3-3.9-5.5-5.8-12.6-5.8-6.8 0-15.2 3.5-25.1 10.7s-20.2 15.2-30.8 24c-12.3 10.5-23 19.7-32.2 27.5s-17 14.4-23.4 19.6c-13 10.5-27.4 22.7-43 36.6-15.7 13.9-32.6 29.7-50.7 47.3-29.5 28.3-44.6 42.5-45.3 42.5.2 0 15.4-16.7 45.6-50 36.4-40.2 66.3-74.9 89.6-104.1 33.3-42.4 52-72.2 56.1-89.6 3.4-14.3-4.6-21.5-23.9-21.5-10.7 0-23.5 2.3-38.3 6.8-14.7 4.5-29.7 10.3-44.8 17.3-15.2 7-28.6 14.4-40.5 22.1-28.8 18.4-54.4 39.5-76.6 63.2-14.8 15.7-25.7 29.9-32.7 42.5v-.2c0-127 103.3-230.3 230.2-230.3M64.8 384c-15.6-23.3-27.1-49.5-33.4-77.7 4.4 1.9 9.5 2.9 15.2 2.9 6.1 0 13.4-1.2 21.9-3.5s17.2-5.6 26-9.9c8.9-4.3 16.7-9.1 23.6-14.3s11.8-10.7 14.6-16.5c.7-1.9.6-2.9-.4-2.9s-5.4 3-13.2 9.1c-10.2 7.7-20.2 14-30.2 19-10 4.9-19.4 7.4-28.2 7.4-4.7 0-8.7-.6-12.1-1.7-9.2-2.5-12.4-9.7-9.6-21.5 3.6-15.4 15.5-34.4 35.5-57q15.3-16.95 32.1-31.8c11.2-10 23-18.9 35.4-26.9 44-28.7 76.4-43 97.2-43 3.4 0 7 .4 10.7 1.2s6.8 2.3 9.2 4.5c2.5 2.2 3.1 5.5 2.1 9.9-2.2 9.3-11.2 25.4-27.1 47.9q-23.85 33.75-69 86.7l-98 114.8c-.8 1.1-1.6 2.2-2.3 3.3M256 486.2c-71.4 0-135.3-32.7-177.6-83.9.6-.6 1.2-1.1 1.8-1.7l56.5-58.7c18.2-19 34-34.9 47.3-47.7s23.9-22.6 31.6-29.5c7.1-6.1 13.2-11.2 18.2-15.5s10.3-8.5 15.8-12.6c5.6-4.2 12.4-9.2 20.5-15.1 8.2-5.9 18.7-13.5 31.7-22.9L260 263.5c-10.1 15.7-19.3 31.5-27.7 47.3s-14.1 29.9-17 42.3c-6.3 26.5 3.2 39.7 28.4 39.7 23 0 52.6-14 88.9-42.1 18.2-13.8 35.1-28.5 50.8-44.2s30.3-32.4 43.8-50c23.6-30.8 38.9-56.6 45.8-77.4 8.6 24.1 13.2 49.9 13.2 76.9 0 126.9-103.3 230.2-230.2 230.2" style="fill:#333"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -19,13 +19,14 @@ import {
|
||||
tablerBrandGithub,
|
||||
tablerBrandDiscord,
|
||||
tablerHeart,
|
||||
tablerStar,
|
||||
tablerExternalLink,
|
||||
tablerQuestionMark,
|
||||
tablerPencil,
|
||||
tablerPlus,
|
||||
tablerAlertTriangle,
|
||||
tablerX,
|
||||
tablerAlertCircle,
|
||||
tablerTrendingUp,
|
||||
tablerPlugConnectedX,
|
||||
tablerListDetails,
|
||||
tablerEye,
|
||||
tablerEyeOff,
|
||||
tablerArrowDown,
|
||||
@@ -48,7 +49,6 @@ import {
|
||||
tablerGripVertical,
|
||||
tablerFilter,
|
||||
tablerPalette,
|
||||
tablerFileOff,
|
||||
} from '@ng-icons/tabler-icons';
|
||||
|
||||
export const APP_ICONS = {
|
||||
@@ -72,13 +72,14 @@ export const APP_ICONS = {
|
||||
tablerBrandGithub,
|
||||
tablerBrandDiscord,
|
||||
tablerHeart,
|
||||
tablerStar,
|
||||
tablerExternalLink,
|
||||
tablerQuestionMark,
|
||||
tablerPencil,
|
||||
tablerPlus,
|
||||
tablerAlertTriangle,
|
||||
tablerX,
|
||||
tablerAlertCircle,
|
||||
tablerTrendingUp,
|
||||
tablerPlugConnectedX,
|
||||
tablerListDetails,
|
||||
tablerEye,
|
||||
tablerEyeOff,
|
||||
tablerArrowDown,
|
||||
@@ -101,5 +102,4 @@ export const APP_ICONS = {
|
||||
tablerGripVertical,
|
||||
tablerFilter,
|
||||
tablerPalette,
|
||||
tablerFileOff,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AppEvent, ManualEvent, EventStats, ManualEventStats, EventFilter, ManualEventFilter, EventTypeTimelineResponse } from '@core/models/event.models';
|
||||
import { AppEvent, ManualEvent, EventFilter, ManualEventFilter, EventTypeTimelineResponse } from '@core/models/event.models';
|
||||
import { PaginatedResult } from '@core/models/pagination.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -23,18 +23,6 @@ export class EventsApi {
|
||||
return this.http.get<PaginatedResult<AppEvent>>('/api/events', { params });
|
||||
}
|
||||
|
||||
getEvent(id: string): Observable<AppEvent> {
|
||||
return this.http.get<AppEvent>(`/api/events/${id}`);
|
||||
}
|
||||
|
||||
getEventsByTracking(trackingId: string): Observable<AppEvent[]> {
|
||||
return this.http.get<AppEvent[]>(`/api/events/tracking/${trackingId}`);
|
||||
}
|
||||
|
||||
getEventStats(): Observable<EventStats> {
|
||||
return this.http.get<EventStats>('/api/events/stats');
|
||||
}
|
||||
|
||||
getEventTypes(): Observable<string[]> {
|
||||
return this.http.get<string[]>('/api/events/types');
|
||||
}
|
||||
@@ -64,10 +52,6 @@ export class EventsApi {
|
||||
return this.http.get<PaginatedResult<ManualEvent>>('/api/manualevents', { params });
|
||||
}
|
||||
|
||||
getManualEvent(id: string): Observable<ManualEvent> {
|
||||
return this.http.get<ManualEvent>(`/api/manualevents/${id}`);
|
||||
}
|
||||
|
||||
resolveManualEvent(id: string): Observable<void> {
|
||||
return this.http.post<void>(`/api/manualevents/${id}/resolve`, {});
|
||||
}
|
||||
@@ -75,12 +59,4 @@ export class EventsApi {
|
||||
resolveAllManualEvents(): Observable<{ resolvedCount: number }> {
|
||||
return this.http.post<{ resolvedCount: number }>('/api/manualevents/resolve_all', {});
|
||||
}
|
||||
|
||||
getManualEventStats(): Observable<ManualEventStats> {
|
||||
return this.http.get<ManualEventStats>('/api/manualevents/stats');
|
||||
}
|
||||
|
||||
getManualEventSeverities(): Observable<string[]> {
|
||||
return this.http.get<string[]>('/api/manualevents/severities');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,13 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { JobInfo, JobScheduleRequest } from '@core/models/job.models';
|
||||
import { JobType } from '@shared/models/enums';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class JobsApi {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getAll(): Observable<JobInfo[]> {
|
||||
return this.http.get<JobInfo[]>('/api/jobs');
|
||||
}
|
||||
|
||||
get(jobType: JobType): Observable<JobInfo> {
|
||||
return this.http.get<JobInfo>(`/api/jobs/${jobType}`);
|
||||
}
|
||||
|
||||
start(jobType: JobType): Observable<{ message: string }> {
|
||||
return this.http.post<{ message: string }>(`/api/jobs/${jobType}/start`, {});
|
||||
}
|
||||
|
||||
trigger(jobType: JobType): Observable<{ message: string }> {
|
||||
return this.http.post<{ message: string }>(`/api/jobs/${jobType}/trigger`, {});
|
||||
}
|
||||
|
||||
updateSchedule(jobType: JobType, schedule: JobScheduleRequest): Observable<{ message: string }> {
|
||||
return this.http.put<{ message: string }>(`/api/jobs/${jobType}/schedule`, schedule);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { DownloadItemStrikes, RecentStrike, StrikeFilter } from '@core/models/strike.models';
|
||||
import { DownloadItemStrikes, StrikeFilter } from '@core/models/strike.models';
|
||||
import { PaginatedResult } from '@core/models/pagination.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -19,11 +19,6 @@ export class StrikesApi {
|
||||
return this.http.get<PaginatedResult<DownloadItemStrikes>>('/api/strikes', { params });
|
||||
}
|
||||
|
||||
getRecentStrikes(count = 5): Observable<RecentStrike[]> {
|
||||
const params = new HttpParams().set('count', count);
|
||||
return this.http.get<RecentStrike[]>('/api/strikes/recent', { params });
|
||||
}
|
||||
|
||||
getStrikeTypes(): Observable<string[]> {
|
||||
return this.http.get<string[]>('/api/strikes/types');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - Box sections (`app-card`, `app-accordion`) render a flame "NEW" corner ribbon in the
|
||||
* top-right corner.
|
||||
* - Field components (`app-toggle`, `app-input`, `app-select`, `app-number-input`,
|
||||
* `app-chip-input`, `app-textarea`, `app-size-input`) render a small flame "NEW" pill next
|
||||
* `app-chip-input`, `app-size-input`) render a small flame "NEW" pill next
|
||||
* to their label.
|
||||
* You can also drop `<app-new-badge featureId="…">` (ribbon) or
|
||||
* `<app-new-badge variant="inline" featureId="…">` (pill) anywhere manually.
|
||||
|
||||
@@ -51,21 +51,6 @@ export interface ManualEvent {
|
||||
strikeCount?: number;
|
||||
}
|
||||
|
||||
export interface EventStats {
|
||||
totalEvents: number;
|
||||
eventsBySeverity: { severity: string; count: number }[];
|
||||
eventsByType: { eventType: string; count: number }[];
|
||||
recentEventsCount: number;
|
||||
}
|
||||
|
||||
export interface ManualEventStats {
|
||||
totalEvents: number;
|
||||
unresolvedEvents: number;
|
||||
resolvedEvents: number;
|
||||
eventsBySeverity: { severity: string; count: number }[];
|
||||
unresolvedBySeverity: { severity: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface EventTypeTimelineBucket {
|
||||
date: string;
|
||||
counts: Record<string, number>;
|
||||
|
||||
@@ -10,10 +10,3 @@ export interface JobInfo {
|
||||
previousRunTime?: Date;
|
||||
jobType: string;
|
||||
}
|
||||
|
||||
export interface JobScheduleRequest {
|
||||
schedule: {
|
||||
every: number;
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
@@ -17,23 +17,6 @@ export class ApplicationPathService {
|
||||
return 'https://cleanuparr.github.io';
|
||||
}
|
||||
|
||||
buildUrl(path: string): string {
|
||||
const basePath = this.getBasePath();
|
||||
const cleanPath = path.startsWith('/') ? path : '/' + path;
|
||||
return basePath === '/' ? cleanPath : basePath + cleanPath;
|
||||
}
|
||||
|
||||
buildApiUrl(apiPath: string): string {
|
||||
const basePath = this.getBasePath();
|
||||
const cleanApiPath = apiPath.startsWith('/') ? apiPath : '/' + apiPath;
|
||||
|
||||
if (isDevMode()) {
|
||||
return basePath + '/api' + cleanApiPath;
|
||||
}
|
||||
|
||||
return basePath === '/' ? '/api' + cleanApiPath : basePath + '/api' + cleanApiPath;
|
||||
}
|
||||
|
||||
buildHubUrl(hubPath: string): string {
|
||||
const basePath = this.getBasePath();
|
||||
const cleanPath = hubPath.startsWith('/') ? hubPath : '/' + hubPath;
|
||||
|
||||
@@ -137,9 +137,6 @@ export class DocumentationService {
|
||||
'blacklistPath': 'blacklist-path',
|
||||
},
|
||||
'notifications': {
|
||||
'enabled': 'enabled',
|
||||
'name': 'provider-name',
|
||||
'eventTriggers': 'event-configuration',
|
||||
'onFailedImportStrike': 'event-configuration',
|
||||
'onStalledStrike': 'event-configuration',
|
||||
'onSlowStrike': 'event-configuration',
|
||||
@@ -243,13 +240,4 @@ export class DocumentationService {
|
||||
const url = this.pathService.buildDocumentationUrl(section, anchor);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
getFieldDocumentationUrl(section: string, fieldName: string): string {
|
||||
const anchor = this.fieldMappings[section]?.[fieldName];
|
||||
return this.pathService.buildDocumentationUrl(section, anchor);
|
||||
}
|
||||
|
||||
hasFieldDocumentation(section: string, fieldName: string): boolean {
|
||||
return !!this.fieldMappings[section]?.[fieldName];
|
||||
}
|
||||
}
|
||||
@@ -88,22 +88,12 @@ export class ThemeService {
|
||||
localStorage.setItem(PERFORMANCE_MODE_KEY, String(next));
|
||||
}
|
||||
|
||||
setPerformanceMode(value: boolean): void {
|
||||
this._performanceMode.set(value);
|
||||
localStorage.setItem(PERFORMANCE_MODE_KEY, String(value));
|
||||
}
|
||||
|
||||
toggleFullWidth(): void {
|
||||
const next = !this._fullWidth();
|
||||
this._fullWidth.set(next);
|
||||
localStorage.setItem(FULL_WIDTH_KEY, String(next));
|
||||
}
|
||||
|
||||
setFullWidth(value: boolean): void {
|
||||
this._fullWidth.set(value);
|
||||
localStorage.setItem(FULL_WIDTH_KEY, String(value));
|
||||
}
|
||||
|
||||
setAccent(accent: Accent): void {
|
||||
this._accent.set(accent);
|
||||
localStorage.setItem(ACCENT_KEY, accent);
|
||||
|
||||
@@ -48,7 +48,6 @@ export class SetupComponent {
|
||||
// Step 3 - Plex
|
||||
plexLinking = signal(false);
|
||||
plexLinked = signal(false);
|
||||
plexUsername = signal('');
|
||||
plexPinId = signal(0);
|
||||
|
||||
// Auto-focus refs
|
||||
|
||||
@@ -582,20 +582,6 @@ app-stats-card {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__category {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&__instance-name {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
|
||||
@@ -309,10 +309,6 @@ export class DashboardComponent {
|
||||
return details;
|
||||
}
|
||||
|
||||
navigateTo(path: string): void {
|
||||
this.router.navigate([path]);
|
||||
}
|
||||
|
||||
private loadOrder(): DashboardRowId[] {
|
||||
try {
|
||||
const saved = localStorage.getItem(DASHBOARD_ROW_ORDER_KEY);
|
||||
|
||||
@@ -189,10 +189,6 @@ export class EventsComponent implements OnInit, OnDestroy {
|
||||
this.currentPage.set(1);
|
||||
}
|
||||
|
||||
viewLogsForJobRun(runId: string): void {
|
||||
this.router.navigate(['/logs'], { queryParams: { jobRunId: runId } });
|
||||
}
|
||||
|
||||
exportEvents(format: 'json' | 'csv' | 'text'): void {
|
||||
this.showExportMenu.set(false);
|
||||
const events = this.events();
|
||||
|
||||
@@ -82,15 +82,6 @@
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
&__category {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__message {
|
||||
color: var(--text-secondary);
|
||||
padding: 0 var(--space-4) var(--space-2);
|
||||
@@ -169,16 +160,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__instance-name {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__run-id {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -276,8 +257,4 @@
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.log-entry__category {
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -282,69 +282,4 @@
|
||||
}
|
||||
|
||||
// Table cell content
|
||||
.cell-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
&__text {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
&__chips {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
|
||||
&--below {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded detail
|
||||
.score-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
&__history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
@@ -253,48 +253,6 @@
|
||||
|
||||
// Expanded row detail
|
||||
|
||||
.event-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
&__chips {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
&__cycle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--glass-bg);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// List rows (legacy styles — kept for potential reuse)
|
||||
|
||||
.list-row {
|
||||
@@ -344,13 +302,6 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__extra {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
&__cycle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
@@ -160,55 +160,4 @@
|
||||
}
|
||||
|
||||
// Table cell content
|
||||
.cell-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__text {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
|
||||
&--new { color: var(--color-success); }
|
||||
&--old { color: var(--text-tertiary); }
|
||||
}
|
||||
|
||||
.score-delta {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
// Expanded detail
|
||||
.upgrade-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: var(--space-3);
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
@@ -180,15 +180,6 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__section-label-text {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--sidebar-section-label);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -62,25 +62,6 @@ export interface DownloadCleanerConfig {
|
||||
clients: ClientCleanerConfig[];
|
||||
}
|
||||
|
||||
export function createDefaultSeedingRule(): SeedingRule {
|
||||
return {
|
||||
name: '',
|
||||
categories: [],
|
||||
trackerPatterns: [],
|
||||
tagsAny: [],
|
||||
tagsAll: [],
|
||||
priority: 0,
|
||||
privacyType: TorrentPrivacyType.Public,
|
||||
maxRatio: -1,
|
||||
minSeedTime: 0,
|
||||
maxSeedTime: -1,
|
||||
minSeeders: 0,
|
||||
maxInactiveDays: -1,
|
||||
action: SeedingRuleAction.Delete,
|
||||
deleteSourceFiles: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultUnlinkedConfig(): UnlinkedConfigModel {
|
||||
return {
|
||||
enabled: false,
|
||||
|
||||
@@ -103,11 +103,6 @@ export enum SelectionStrategy {
|
||||
Random = 'Random',
|
||||
}
|
||||
|
||||
export enum SeriesSearchType {
|
||||
Season = 'Season',
|
||||
Series = 'Series',
|
||||
}
|
||||
|
||||
export enum SearchCommandStatus {
|
||||
Pending = 'Pending',
|
||||
Started = 'Started',
|
||||
@@ -129,12 +124,6 @@ export enum DeleteReason {
|
||||
AtLeastOneFileBlocked = 'AtLeastOneFileBlocked',
|
||||
}
|
||||
|
||||
export enum CleanReason {
|
||||
None = 'None',
|
||||
MaxRatioReached = 'MaxRatioReached',
|
||||
MaxSeedTimeReached = 'MaxSeedTimeReached',
|
||||
}
|
||||
|
||||
export enum InstanceType {
|
||||
Sonarr = 'Sonarr',
|
||||
Radarr = 'Radarr',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PatternMode } from './enums';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { JobSchedule } from '@shared/utils/schedule.util';
|
||||
export { ScheduleOptions } from '@shared/utils/schedule.util';
|
||||
|
||||
export interface FailedImportConfig {
|
||||
|
||||
@@ -26,11 +26,6 @@
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-subtle);
|
||||
}
|
||||
|
||||
:host:focus-within .textarea-label {
|
||||
color: var(--color-primary);
|
||||
transition: color var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<ng-icon [name]="name()" [class]="'icon icon--' + size()" />
|
||||
@@ -1,20 +0,0 @@
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
color: currentColor;
|
||||
|
||||
&--sm {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&--md {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&--lg {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&--xl {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-icon',
|
||||
standalone: true,
|
||||
imports: [NgIcon],
|
||||
templateUrl: './icon.component.html',
|
||||
styleUrl: './icon.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class IconComponent {
|
||||
name = input.required<string>();
|
||||
size = input<'sm' | 'md' | 'lg' | 'xl'>('md');
|
||||
}
|
||||
@@ -6,14 +6,12 @@ export { InputComponent } from './input/input.component';
|
||||
export { LabelComponent } from './label/label.component';
|
||||
export { SpinnerComponent } from './spinner/spinner.component';
|
||||
export { ToggleComponent } from './toggle/toggle.component';
|
||||
export { IconComponent } from './icon/icon.component';
|
||||
export { BadgeComponent } from './badge/badge.component';
|
||||
export type { BadgeSeverity, BadgeSize } from './badge/badge.component';
|
||||
export { NewBadgeComponent } from './new-badge/new-badge.component';
|
||||
export { SelectComponent } from './select/select.component';
|
||||
export type { SelectOption } from './select/select.component';
|
||||
export { NumberInputComponent } from './number-input/number-input.component';
|
||||
export { TextareaComponent } from './textarea/textarea.component';
|
||||
export { ChipInputComponent } from './chip-input/chip-input.component';
|
||||
export { AccordionComponent } from './accordion/accordion.component';
|
||||
export { ModalComponent } from './modal/modal.component';
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
@if (label()) {
|
||||
<label class="textarea-label" [attr.for]="controlId">
|
||||
{{ label() }}
|
||||
@if (helpKey()) {
|
||||
<button class="field-help-btn" (click)="onHelpClick($event)" aria-label="Open documentation" tabindex="-1">
|
||||
<ng-icon name="tablerQuestionMark" size="14" />
|
||||
</button>
|
||||
}
|
||||
@if (featureId()) {
|
||||
<app-new-badge variant="inline" [featureId]="featureId()!" />
|
||||
}
|
||||
</label>
|
||||
}
|
||||
<textarea
|
||||
[id]="controlId"
|
||||
class="textarea-field"
|
||||
[class.textarea-field--error]="error()"
|
||||
[placeholder]="placeholder()"
|
||||
[disabled]="disabled()"
|
||||
[readOnly]="readonly()"
|
||||
[rows]="rows()"
|
||||
[(ngModel)]="value"
|
||||
(blur)="blurred.emit($event)"
|
||||
></textarea>
|
||||
@if (error()) {
|
||||
<span class="textarea-error">{{ error() }}</span>
|
||||
} @else if (hint()) {
|
||||
<span class="textarea-hint">{{ hint() }}</span>
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
@use 'glass' as *;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.textarea-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.textarea-field {
|
||||
@include glass-input;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
line-height: 1.5;
|
||||
|
||||
&--error {
|
||||
border-color: var(--color-error) !important;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.textarea-error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.textarea-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
:host:focus-within .textarea-label {
|
||||
color: var(--color-primary);
|
||||
transition: color var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Component, ChangeDetectionStrategy, input, model, output, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { DocumentationService } from '@core/services/documentation.service';
|
||||
import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
|
||||
import { generateControlId } from '@ui/control-id';
|
||||
|
||||
@Component({
|
||||
selector: 'app-textarea',
|
||||
standalone: true,
|
||||
imports: [FormsModule, NgIcon, NewBadgeComponent],
|
||||
templateUrl: './textarea.component.html',
|
||||
styleUrl: './textarea.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TextareaComponent {
|
||||
private readonly docs = inject(DocumentationService);
|
||||
|
||||
protected readonly controlId = generateControlId('app-textarea');
|
||||
|
||||
label = input<string>();
|
||||
featureId = input<string>();
|
||||
placeholder = input('');
|
||||
disabled = input(false);
|
||||
readonly = input(false);
|
||||
rows = input(4);
|
||||
error = input<string>();
|
||||
hint = input<string>();
|
||||
helpKey = input<string>();
|
||||
value = model('');
|
||||
|
||||
blurred = output<FocusEvent>();
|
||||
|
||||
onHelpClick(event: Event): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const key = this.helpKey();
|
||||
if (key) {
|
||||
const [section, field] = key.split(':');
|
||||
this.docs.openFieldDocumentation(section, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,48 +13,6 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
// Skeleton loading utility - add class="skeleton" to any element
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--glass-bg) 0px,
|
||||
rgba(var(--accent-rgb), 0.08) 30px,
|
||||
rgba(var(--accent-rgb), 0.06) 50px,
|
||||
var(--glass-bg-hover) 70px,
|
||||
var(--glass-bg) 100px
|
||||
);
|
||||
background-size: 250px 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
color: transparent !important;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
* { visibility: hidden; }
|
||||
|
||||
&--text {
|
||||
height: 14px;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
&--title {
|
||||
height: 20px;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
&--input {
|
||||
height: 38px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
&--badge {
|
||||
height: 24px;
|
||||
width: 80px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared field help button (used across all form components)
|
||||
.field-help-btn {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -9,21 +9,11 @@
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
@@ -43,11 +33,6 @@
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
// Page enter: used for route transitions (opacity-only to avoid containing blocks that break sticky)
|
||||
@keyframes page-enter {
|
||||
from { opacity: 0; }
|
||||
@@ -83,12 +68,6 @@
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
// Shimmer for skeleton loading placeholders
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200px 0; }
|
||||
100% { background-position: calc(200px + 100%) 0; }
|
||||
}
|
||||
|
||||
@keyframes orb-hue {
|
||||
0% { filter: hue-rotate(0deg); }
|
||||
50% { filter: hue-rotate(-35deg); }
|
||||
|
||||
Reference in new issue
Block a user