Compare commits

...

6 Commits

Author SHA1 Message Date
Flaminel
eb6cf96470 Fix cron expression inputs (#203) 2025-07-01 01:00:43 +03:00
Flaminel
2ca0616771 Add date on dashboard logs and events (#205) 2025-07-01 01:00:30 +03:00
Flaminel
bc85144e60 Improve deploy workflows (#206) 2025-07-01 01:00:16 +03:00
Flaminel
236e31c841 Add download client name on debug logs (#207) 2025-07-01 00:59:52 +03:00
Flaminel
7a15139aa6 Fix autocomplete input on mobile phones (#196) 2025-06-30 13:28:14 +03:00
Flaminel
fb6ccfd011 Add Readarr support (#191) 2025-06-29 19:54:15 +03:00
75 changed files with 2702 additions and 360 deletions

View File

@@ -134,22 +134,4 @@ jobs:
./artifacts/*.zip
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
id: release
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
fail_on_unmatched_files: true
target_commitish: main
generate_release_notes: true
files: |
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip
# Removed individual release step - handled by main release workflow

View File

@@ -363,14 +363,4 @@ jobs:
path: '${{ env.pkgName }}'
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
${{ env.pkgName }}
# Removed individual release step - handled by main release workflow

View File

@@ -88,19 +88,6 @@ jobs:
run: |
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
- name: Create sample configuration
shell: pwsh
run: |
# Create config directory
New-Item -ItemType Directory -Force -Path "config"
$config = @{
"HTTP_PORTS" = 11011
"BASE_PATH" = "/"
}
$config | ConvertTo-Json | Out-File -FilePath "config/cleanuparr.json" -Encoding UTF8
- name: Setup Inno Setup
shell: pwsh
run: |
@@ -158,14 +145,4 @@ jobs:
path: installer/${{ env.installerName }}
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
installer/${{ env.installerName }}
# Removed individual release step - handled by main release workflow

View File

@@ -309,6 +309,24 @@ public class ConfigurationController : ControllerBase
}
}
[HttpGet("readarr")]
public async Task<IActionResult> GetReadarrConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
return Ok(config.Adapt<ArrConfigDto>());
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("notifications")]
public async Task<IActionResult> GetNotificationsConfig()
{
@@ -773,6 +791,37 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPut("readarr")]
public async Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateReadarrConfigDto newConfigDto)
{
await DataContext.Lock.WaitAsync();
try
{
// Get existing config
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Readarr);
config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes;
// Validate the configuration
config.Validate();
// Persist the configuration
await _dataContext.SaveChangesAsync();
return Ok(new { Message = "Readarr configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Readarr configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
/// <summary>
/// Updates a job schedule based on configuration changes
@@ -1137,4 +1186,114 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPost("readarr/instances")]
public async Task<IActionResult> CreateReadarrInstance([FromBody] CreateArrInstanceDto newInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config to add the instance to
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Readarr);
// Create the new instance
var instance = new ArrInstance
{
Enabled = newInstance.Enabled,
Name = newInstance.Name,
Url = new Uri(newInstance.Url),
ApiKey = newInstance.ApiKey,
ArrConfigId = config.Id,
};
// Add to the config's instances collection
await _dataContext.ArrInstances.AddAsync(instance);
// Save changes
await _dataContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetReadarrConfig), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Readarr instance");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("readarr/instances/{id}")]
public async Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Readarr instance with ID {id} not found");
}
// Update the instance properties
instance.Enabled = updatedInstance.Enabled;
instance.Name = updatedInstance.Name;
instance.Url = new Uri(updatedInstance.Url);
instance.ApiKey = updatedInstance.ApiKey;
await _dataContext.SaveChangesAsync();
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Readarr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("readarr/instances/{id}")]
public async Task<IActionResult> DeleteReadarrInstance(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Readarr instance with ID {id} not found");
}
// Remove the instance
config.Instances.Remove(instance);
await _dataContext.SaveChangesAsync();
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete Readarr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -52,6 +52,10 @@ public class StatusController : ControllerBase
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
@@ -80,6 +84,10 @@ public class StatusController : ControllerBase
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};

View File

@@ -40,9 +40,6 @@ public static class ApiDI
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
// Add logging initializer service
services.AddHostedService<LoggingInitializer>();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo

View File

@@ -37,6 +37,7 @@ public static class ServicesDI
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<ReadarrClient>()
.AddTransient<ArrClientFactory>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()

View File

@@ -0,0 +1,9 @@
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Readarr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateReadarrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -63,6 +64,7 @@ public sealed class ContentBlocker : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
if (config.Sonarr.Enabled)
{
@@ -78,6 +80,11 @@ public sealed class ContentBlocker : GenericHandler
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -130,6 +131,7 @@ public sealed class DownloadCleaner : GenericHandler
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), InstanceType.Radarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
{

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -42,10 +43,12 @@ public sealed class QueueCleaner : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record Image
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record LidarrImage
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueAlbum
{

View File

@@ -0,0 +1,6 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueBook
{
public List<ReadarrImage> Images { get; init; } = [];
}

View File

@@ -1,4 +1,6 @@
namespace Data.Models.Arr.Queue;
using Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record QueueListResponse
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueMovie
{

View File

@@ -1,4 +1,6 @@
namespace Data.Models.Arr.Queue;
using Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueRecord
{
@@ -21,6 +23,13 @@ public sealed record QueueRecord
public QueueAlbum? Album { get; init; }
// Readarr
public long AuthorId { get; init; }
public long BookId { get; init; }
public QueueBook? Book { get; init; }
// common
public required string Title { get; init; }
public string Status { get; init; }

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueSeries
{

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record ReadarrImage
{
public required string CoverType { get; init; }
public required Uri Url { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Author
{
public long Id { get; set; }
public string AuthorName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Book
{
public required long Id { get; init; }
public required string Title { get; init; }
public long AuthorId { get; set; }
public Author Author { get; set; } = new();
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record ReadarrCommand
{
public string Name { get; set; } = string.Empty;
public List<long> BookIds { get; set; } = [];
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;

View File

@@ -8,16 +8,19 @@ public sealed class ArrClientFactory
private readonly ISonarrClient _sonarrClient;
private readonly IRadarrClient _radarrClient;
private readonly ILidarrClient _lidarrClient;
private readonly IReadarrClient _readarrClient;
public ArrClientFactory(
SonarrClient sonarrClient,
RadarrClient radarrClient,
LidarrClient lidarrClient
LidarrClient lidarrClient,
ReadarrClient readarrClient
)
{
_sonarrClient = sonarrClient;
_radarrClient = radarrClient;
_lidarrClient = lidarrClient;
_readarrClient = readarrClient;
}
public IArrClient GetClient(InstanceType type) =>
@@ -26,6 +29,7 @@ public sealed class ArrClientFactory
InstanceType.Sonarr => _sonarrClient,
InstanceType.Radarr => _radarrClient,
InstanceType.Lidarr => _lidarrClient,
InstanceType.Readarr => _readarrClient,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr.Queue;
using Microsoft.Extensions.Logging;

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IReadarrClient : IArrClient
{
}

View File

@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Lidarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;

View File

@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;

View File

@@ -0,0 +1,152 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Readarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Arr;
public class ReadarrClient : ArrClient, IReadarrClient
{
public ReadarrClient(
ILogger<ReadarrClient> logger,
IHttpClientFactory httpClientFactory,
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetQueueUrlPath()
{
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
{
return $"page={page}&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true";
}
protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v1/queue/{recordId}";
}
protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
}
List<long> ids = items.Select(item => item.Id).ToList();
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/command";
ReadarrCommand command = new()
{
Name = "BookSearch",
BookIds = ids,
};
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command),
Encoding.UTF8,
"application/json"
);
SetApiKey(request, arrInstance.ApiKey);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command);
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
}
catch
{
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.AuthorId is 0 || record.BookId is 0)
{
_logger.LogDebug("skip | author id and/or book id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
private static string GetSearchLog(Uri instanceUrl, ReadarrCommand command, bool success, string? logContext)
{
string status = success ? "triggered" : "failed";
string message = logContext ?? $"book ids: {string.Join(',', command.BookIds)}";
return $"book search {status} | {instanceUrl} | {message}";
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, ReadarrCommand command)
{
try
{
StringBuilder log = new();
foreach (long bookId in command.BookIds)
{
Book? book = await GetBookAsync(arrInstance, bookId);
if (book is null)
{
return null;
}
log.Append($"[{book.Title}]");
}
return log.ToString();
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to compute log context");
}
return null;
}
private async Task<Book?> GetBookAsync(ArrInstance arrInstance, long bookId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/book/{bookId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Book>(responseBody);
}
}

View File

@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Sonarr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;

View File

@@ -95,6 +95,17 @@ public sealed class BlocklistProvider
changedCount++;
}
// Check and update Lidarr blocklist if needed
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
{
_logger.LogDebug("Loading Readarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Readarr, InstanceType.Readarr);
_configHashes[InstanceType.Readarr] = readarrHash;
changedCount++;
}
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);

View File

@@ -21,7 +21,7 @@ public partial class DelugeService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -52,7 +52,7 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
}
if (contents is null)

View File

@@ -25,7 +25,7 @@ public partial class DelugeService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -44,7 +44,7 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
}

View File

@@ -20,7 +20,7 @@ public partial class QBitService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -39,7 +39,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent properties {name}", download.Name);
return result;
}

View File

@@ -97,7 +97,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name);
_logger.LogDebug("failed to find torrent properties | {name}", download.Name);
return;
}

View File

@@ -19,7 +19,7 @@ public partial class QBitService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -38,7 +38,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent properties {hash}", download.Name);
return result;
}

View File

@@ -19,7 +19,7 @@ public partial class TransmissionService
if (download?.FileStats is null || download.FileStats.Length == 0)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}

View File

@@ -22,7 +22,7 @@ public partial class TransmissionService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Context;

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -52,68 +53,6 @@ public abstract class GenericHandler : IHandler
_dataContext = dataContext;
}
// /// <summary>
// /// Initialize download services based on configuration
// /// </summary>
// protected async Task<List<IDownloadService>> GetDownloadServices()
// {
// var clients = await _dataContext.DownloadClients
// .AsNoTracking()
// .ToListAsync();
//
// if (clients.Count is 0)
// {
// _logger.LogWarning("No download clients configured");
// return [];
// }
//
// var enabledClients = await _dataContext.DownloadClients
// .Where(c => c.Enabled)
// .ToListAsync();
//
// if (enabledClients.Count == 0)
// {
// _logger.LogWarning("No enabled download clients available");
// return [];
// }
//
// List<IDownloadService> downloadServices = [];
//
// // Add all enabled clients
// foreach (var client in enabledClients)
// {
// try
// {
// var service = _downloadServiceFactory.GetDownloadService(client);
// if (service != null)
// {
// await service.LoginAsync();
// downloadServices.Add(service);
// _logger.LogDebug("Initialized download client: {name}", client.Name);
// }
// else
// {
// _logger.LogWarning("Download client service not available for: {name}", client.Name);
// }
// }
// catch (Exception ex)
// {
// _logger.LogError(ex, "Failed to initialize download client: {name}", client.Name);
// }
// }
//
// if (downloadServices.Count == 0)
// {
// _logger.LogWarning("No valid download clients found");
// }
// else
// {
// _logger.LogDebug("Initialized {count} download clients", downloadServices.Count);
// }
//
// return downloadServices;
// }
public async Task ExecuteAsync()
{
await DataContext.Lock.WaitAsync();
@@ -130,6 +69,9 @@ public abstract class GenericHandler : IHandler
ContextProvider.Set(nameof(InstanceType.Lidarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Lidarr));
ContextProvider.Set(nameof(InstanceType.Readarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr));
ContextProvider.Set(nameof(QueueCleanerConfig), await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(ContentBlockerConfig), await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.Include(x => x.Categories).AsNoTracking().FirstAsync());
@@ -252,6 +194,10 @@ public abstract class GenericHandler : IHandler
{
Id = record.AlbumId
},
InstanceType.Readarr => new SearchItem
{
Id = record.BookId
},
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -1,4 +1,5 @@
using System.Globalization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
@@ -166,6 +167,7 @@ public class NotificationPublisher : INotificationPublisher
InstanceType.Sonarr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Radarr => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Lidarr => record.Album?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
InstanceType.Readarr => record.Book?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
_ => throw new ArgumentOutOfRangeException(nameof(instanceType))
};

View File

@@ -1,52 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Helpers;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog.Context;
namespace Cleanuparr.Infrastructure.Logging;
// TODO remove
public class LoggingInitializer : BackgroundService
{
private readonly ILogger<LoggingInitializer> _logger;
private readonly EventPublisher _eventPublisher;
private readonly Random random = new();
public LoggingInitializer(ILogger<LoggingInitializer> logger, EventPublisher eventPublisher)
{
_logger = logger;
_eventPublisher = eventPublisher;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
return;
while (true)
{
using var _ = LogContext.PushProperty(LogProperties.Category,
random.Next(0, 100) > 50 ? InstanceType.Sonarr.ToString() : InstanceType.Radarr.ToString());
try
{
await _eventPublisher.PublishAsync(
random.Next(0, 100) > 50 ? EventType.DownloadCleaned : EventType.StalledStrike,
"This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.",
EventSeverity.Important,
data: new { Hash = "hash", Name = "name", StrikeCount = "1", Type = "stalled" });
throw new Exception("test exception");
}
catch (Exception exception)
{
_logger.LogCritical("test critical");
_logger.LogTrace("test trace");
_logger.LogDebug("test debug");
_logger.LogWarning("test warn");
_logger.LogError(exception, "This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.");
}
await Task.Delay(10000, stoppingToken);
}
}
}

View File

@@ -77,6 +77,10 @@ public class DataContext : DbContext
{
cp.Property(s => s.BlocklistType).HasConversion<LowercaseEnumConverter<BlocklistType>>();
});
entity.ComplexProperty(e => e.Readarr, cp =>
{
cp.Property(s => s.BlocklistType).HasConversion<LowercaseEnumConverter<BlocklistType>>();
});
});
// Configure ArrConfig -> ArrInstance relationship

View File

@@ -0,0 +1,620 @@
// <auto-generated />
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("20250628231105_AddReadarr")]
partial class AddReadarr
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("url");
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.ContentBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("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.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Key")
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Url")
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("stalled_reset_strikes_on_progress");
});
b.HasKey("Id")
.HasName("pk_queue_cleaner_configs");
b.ToTable("queue_cleaner_configs", (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.CleanCategory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
b.Navigation("DownloadCleanerConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddReadarr : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "readarr_blocklist_path",
table: "content_blocker_configs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "readarr_blocklist_type",
table: "content_blocker_configs",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "readarr_enabled",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.InsertData(
table: "arr_configs",
columns: new[] { "id", "failed_import_max_strikes", "type" },
values: new object[] { new Guid("013994ea-0a5e-4eed-91b7-271f494b6259"), (short)-1, "readarr" });
migrationBuilder.Sql("UPDATE content_blocker_configs SET readarr_blocklist_type = 'blacklist' WHERE readarr_blocklist_type = ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "readarr_blocklist_path",
table: "content_blocker_configs");
migrationBuilder.DropColumn(
name: "readarr_blocklist_type",
table: "content_blocker_configs");
migrationBuilder.DropColumn(
name: "readarr_enabled",
table: "content_blocker_configs");
}
}
}

View File

@@ -143,6 +143,24 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();

View File

@@ -26,11 +26,14 @@ public sealed record ContentBlockerConfig : IJobConfig
public BlocklistSettings Lidarr { get; set; } = new();
public BlocklistSettings Readarr { get; set; } = new();
public void Validate()
{
ValidateBlocklistSettings(Sonarr, "Sonarr");
ValidateBlocklistSettings(Radarr, "Radarr");
ValidateBlocklistSettings(Lidarr, "Lidarr");
ValidateBlocklistSettings(Readarr, "Readarr");
}
private static void ValidateBlocklistSettings(BlocklistSettings settings, string context)

View File

@@ -14,6 +14,7 @@ export const routes: Routes = [
{ path: 'sonarr', loadComponent: () => import('./settings/sonarr/sonarr-settings.component').then(m => m.SonarrSettingsComponent) },
{ path: 'radarr', loadComponent: () => import('./settings/radarr/radarr-settings.component').then(m => m.RadarrSettingsComponent) },
{ path: 'lidarr', loadComponent: () => import('./settings/lidarr/lidarr-settings.component').then(m => m.LidarrSettingsComponent) },
{ path: 'readarr', loadComponent: () => import('./settings/readarr/readarr-settings.component').then(m => m.ReadarrSettingsComponent) },
{ path: 'download-clients', loadComponent: () => import('./settings/download-client/download-client-settings.component').then(m => m.DownloadClientSettingsComponent) },
{ path: 'notifications', loadComponent: () => import('./settings/notification-settings/notification-settings.component').then(m => m.NotificationSettingsComponent) },
];

View File

@@ -6,6 +6,7 @@ import { ContentBlockerConfig, JobSchedule as ContentBlockerJobSchedule, Schedul
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-config.model";
import { GeneralConfig } from "../../shared/models/general-config.model";
@@ -63,7 +64,10 @@ export class ConfigurationService {
* Update queue cleaner configuration
*/
updateQueueCleanerConfig(config: QueueCleanerConfig): Observable<QueueCleanerConfig> {
config.cronExpression = this.convertJobScheduleToCron(config.jobSchedule!);
// Generate cron expression if using basic scheduling
if (!config.useAdvancedScheduling && config.jobSchedule) {
config.cronExpression = this.convertJobScheduleToCron(config.jobSchedule);
}
return this.http.put<QueueCleanerConfig>(this.ApplicationPathService.buildApiUrl('/configuration/queue_cleaner'), config).pipe(
catchError((error) => {
console.error("Error updating queue cleaner config:", error);
@@ -112,32 +116,32 @@ export class ConfigurationService {
*/
private tryExtractJobScheduleFromCron(cronExpression: string): JobSchedule | undefined {
// Patterns we support:
// Seconds: */n * * ? * * *
// Minutes: 0 */n * ? * * *
// Hours: 0 0 */n ? * * *
// Seconds: */n * * ? * * * or 0/n * * ? * * * (Quartz.NET format)
// Minutes: 0 */n * ? * * * or 0 0/n * ? * * * (Quartz.NET format)
// Hours: 0 0 */n ? * * * or 0 0 0/n ? * * * (Quartz.NET format)
try {
const parts = cronExpression.split(" ");
if (parts.length !== 7) return undefined;
// Every n seconds
if (parts[0].startsWith("*/") && parts[1] === "*") {
// Every n seconds - handle both */n and 0/n formats
if ((parts[0].startsWith("*/") || parts[0].startsWith("0/")) && parts[1] === "*") {
const seconds = parseInt(parts[0].substring(2));
if (!isNaN(seconds) && seconds > 0 && seconds < 60) {
return { every: seconds, type: ScheduleUnit.Seconds };
}
}
// Every n minutes
if (parts[0] === "0" && parts[1].startsWith("*/")) {
// Every n minutes - handle both */n and 0/n formats
if (parts[0] === "0" && (parts[1].startsWith("*/") || parts[1].startsWith("0/"))) {
const minutes = parseInt(parts[1].substring(2));
if (!isNaN(minutes) && minutes > 0 && minutes < 60) {
return { every: minutes, type: ScheduleUnit.Minutes };
}
}
// Every n hours
if (parts[0] === "0" && parts[1] === "0" && parts[2].startsWith("*/")) {
// Every n hours - handle both */n and 0/n formats
if (parts[0] === "0" && parts[1] === "0" && (parts[2].startsWith("*/") || parts[2].startsWith("0/"))) {
const hours = parseInt(parts[2].substring(2));
if (!isNaN(hours) && hours > 0 && hours < 24) {
return { every: hours, type: ScheduleUnit.Hours };
@@ -155,31 +159,31 @@ export class ConfigurationService {
*/
private convertJobScheduleToCron(schedule: JobSchedule): string {
if (!schedule || schedule.every <= 0) {
return "0 0/5 * * * ?"; // Default: every 5 minutes
return "0 0/5 * * * ?"; // Default: every 5 minutes (Quartz.NET format)
}
switch (schedule.type) {
case ScheduleUnit.Seconds:
if (schedule.every < 60) {
return `*/${schedule.every} * * ? * * *`;
return `0/${schedule.every} * * ? * * *`; // Quartz.NET format
}
break;
case ScheduleUnit.Minutes:
if (schedule.every < 60) {
return `0 */${schedule.every} * ? * * *`;
return `0 0/${schedule.every} * ? * * *`; // Quartz.NET format
}
break;
case ScheduleUnit.Hours:
if (schedule.every < 24) {
return `0 0 */${schedule.every} ? * * *`;
return `0 0 0/${schedule.every} ? * * *`; // Quartz.NET format
}
break;
}
// Fallback to default
return "0 0/5 * * * ?";
return "0 0/5 * * * ?"; // Default: every 5 minutes (Quartz.NET format)
}
/**
@@ -188,32 +192,32 @@ export class ConfigurationService {
*/
private tryExtractContentBlockerJobScheduleFromCron(cronExpression: string): ContentBlockerJobSchedule | undefined {
// Patterns we support:
// Seconds: */n * * ? * * *
// Minutes: 0 */n * ? * * *
// Hours: 0 0 */n ? * * *
// Seconds: */n * * ? * * * or 0/n * * ? * * * (Quartz.NET format)
// Minutes: 0 */n * ? * * * or 0 0/n * ? * * * (Quartz.NET format)
// Hours: 0 0 */n ? * * * or 0 0 0/n ? * * * (Quartz.NET format)
try {
const parts = cronExpression.split(" ");
if (parts.length !== 7) return undefined;
// Every n seconds
if (parts[0].startsWith("*/") && parts[1] === "*") {
// Every n seconds - handle both */n and 0/n formats
if ((parts[0].startsWith("*/") || parts[0].startsWith("0/")) && parts[1] === "*") {
const seconds = parseInt(parts[0].substring(2));
if (!isNaN(seconds) && seconds > 0 && seconds < 60) {
return { every: seconds, type: ContentBlockerScheduleUnit.Seconds };
}
}
// Every n minutes
if (parts[0] === "0" && parts[1].startsWith("*/")) {
// Every n minutes - handle both */n and 0/n formats
if (parts[0] === "0" && (parts[1].startsWith("*/") || parts[1].startsWith("0/"))) {
const minutes = parseInt(parts[1].substring(2));
if (!isNaN(minutes) && minutes > 0 && minutes < 60) {
return { every: minutes, type: ContentBlockerScheduleUnit.Minutes };
}
}
// Every n hours
if (parts[0] === "0" && parts[1] === "0" && parts[2].startsWith("*/")) {
// Every n hours - handle both */n and 0/n formats
if (parts[0] === "0" && parts[1] === "0" && (parts[2].startsWith("*/") || parts[2].startsWith("0/"))) {
const hours = parseInt(parts[2].substring(2));
if (!isNaN(hours) && hours > 0 && hours < 24) {
return { every: hours, type: ContentBlockerScheduleUnit.Hours };
@@ -231,31 +235,31 @@ export class ConfigurationService {
*/
private convertContentBlockerJobScheduleToCron(schedule: ContentBlockerJobSchedule): string {
if (!schedule || schedule.every <= 0) {
return "0 0/5 * * * ?"; // Default: every 5 minutes
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
switch (schedule.type) {
case ContentBlockerScheduleUnit.Seconds:
if (schedule.every < 60) {
return `*/${schedule.every} * * ? * * *`;
return `0/${schedule.every} * * ? * * *`; // Quartz.NET format
}
break;
case ContentBlockerScheduleUnit.Minutes:
if (schedule.every < 60) {
return `0 */${schedule.every} * ? * * *`;
return `0 0/${schedule.every} * ? * * *`; // Quartz.NET format
}
break;
case ContentBlockerScheduleUnit.Hours:
if (schedule.every < 24) {
return `0 0 */${schedule.every} ? * * *`;
return `0 0 0/${schedule.every} ? * * *`; // Quartz.NET format
}
break;
}
// Fallback to default
return "0 0/5 * * * ?";
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
/**
@@ -327,6 +331,29 @@ export class ConfigurationService {
);
}
/**
* Get Readarr configuration
*/
getReadarrConfig(): Observable<ReadarrConfig> {
return this.http.get<ReadarrConfig>(this.ApplicationPathService.buildApiUrl('/configuration/readarr')).pipe(
catchError((error) => {
console.error("Error fetching Readarr config:", error);
return throwError(() => new Error("Failed to load Readarr configuration"));
})
);
}
/**
* Update Readarr configuration
*/
updateReadarrConfig(config: {failedImportMaxStrikes: number}): Observable<any> {
return this.http.put<any>(this.ApplicationPathService.buildApiUrl('/configuration/readarr'), config).pipe(
catchError((error) => {
console.error("Error updating Readarr config:", error);
return throwError(() => new Error(error.error?.error || "Failed to update Readarr configuration"));
})
);
}
/**
* Get Download Client configuration
*/
@@ -500,4 +527,42 @@ export class ConfigurationService {
})
);
}
// ===== READARR INSTANCE MANAGEMENT =====
/**
* Create a new Readarr instance
*/
createReadarrInstance(instance: CreateArrInstanceDto): Observable<ArrInstance> {
return this.http.post<ArrInstance>(this.ApplicationPathService.buildApiUrl('/configuration/readarr/instances'), instance).pipe(
catchError((error) => {
console.error("Error creating Readarr instance:", error);
return throwError(() => new Error(error.error?.error || "Failed to create Readarr instance"));
})
);
}
/**
* Update a Readarr instance by ID
*/
updateReadarrInstance(id: string, instance: CreateArrInstanceDto): Observable<ArrInstance> {
return this.http.put<ArrInstance>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`), instance).pipe(
catchError((error) => {
console.error(`Error updating Readarr instance with ID ${id}:`, error);
return throwError(() => new Error(error.error?.error || `Failed to update Readarr instance with ID ${id}`));
})
);
}
/**
* Delete a Readarr instance by ID
*/
deleteReadarrInstance(id: string): Observable<void> {
return this.http.delete<void>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`)).pipe(
catchError((error) => {
console.error(`Error deleting Readarr instance with ID ${id}:`, error);
return throwError(() => new Error(error.error?.error || `Failed to delete Readarr instance with ID ${id}`));
})
);
}
}

View File

@@ -46,7 +46,7 @@
<p-tag [severity]="getLogSeverity(log.level)" [value]="log.level"></p-tag>
<span class="text-xs text-color-secondary" *ngIf="log.category">{{log.category}}</span>
</div>
<span class="text-xs text-color-secondary">{{log.timestamp | date:'HH:mm:ss'}}</span>
<span class="text-xs text-color-secondary">{{ log.timestamp | date: 'yyyy-MM-dd HH:mm:ss' }}</span>
</div>
<div class="timeline-message"
[pTooltip]="log.message"
@@ -112,7 +112,7 @@
<p-tag [severity]="getEventSeverity(event.severity)" [value]="event.severity"></p-tag>
<span class="text-xs text-color-secondary">{{formatEventType(event.eventType)}}</span>
</div>
<span class="text-xs text-color-secondary">{{event.timestamp | date:'HH:mm:ss'}}</span>
<span class="text-xs text-color-secondary">{{event.timestamp | date: 'yyyy-MM-dd HH:mm:ss'}}</span>
</div>
<div class="timeline-message"
[pTooltip]="event.message"

View File

@@ -47,6 +47,12 @@
</div>
<span>Lidarr</span>
</a>
<a [routerLink]="['/readarr']" class="nav-item" [class.active]="router.url.includes('/readarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-book"></i>
</div>
<span>Readarr</span>
</a>
<a [routerLink]="['/download-clients']" class="nav-item" [class.active]="router.url.includes('/download-clients')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-download"></i>

View File

@@ -122,22 +122,22 @@ export class ContentBlockerConfigStore extends signalStore(
*/
generateCronExpression(schedule: JobSchedule): string {
if (!schedule) {
return "0/5 * * * * ?"; // Default: every 5 seconds
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
// Cron format: Seconds Minutes Hours Day-of-month Month Day-of-week Year
switch (schedule.type) {
case ScheduleUnit.Seconds:
return `0/${schedule.every} * * ? * * *`; // Every n seconds
return `0/${schedule.every} * * ? * * *`; // Every n seconds (Quartz.NET format)
case ScheduleUnit.Minutes:
return `0 0/${schedule.every} * ? * * *`; // Every n minutes
return `0 0/${schedule.every} * ? * * *`; // Every n minutes (Quartz.NET format)
case ScheduleUnit.Hours:
return `0 0 0/${schedule.every} ? * * *`; // Every n hours
return `0 0 0/${schedule.every} ? * * *`; // Every n hours (Quartz.NET format)
default:
return "0/5 * * * * ?"; // Default: every 5 seconds
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
}
})),

View File

@@ -344,6 +344,76 @@
</div>
</p-accordion-content>
</p-accordion-panel>
<!-- Readarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="3">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Readarr Settings
</p-accordion-header>
<p-accordion-content>
<div formGroupName="readarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.enabled')"
title="Click for documentation"></i>
Enable Readarr Blocklist
</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, the Readarr blocklist will be used for content filtering</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
</label>
<p-fluid>
<div class="field-input">
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
</div>
<small *ngIf="hasNestedError('readarr', 'blocklistPath', 'required')" class="p-error">Path is required when Readarr blocklist is enabled</small>
<small class="form-helper-text">Path to the blocklist file or URL</small>
</p-fluid>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
</label>
<div class="field-input">
<p-select
formControlName="blocklistType"
[options]="[
{ label: 'Blacklist', value: 'Blacklist' },
{ label: 'Whitelist', value: 'Whitelist' }
]"
optionLabel="label"
optionValue="value"
appendTo="body"
></p-select>
<small class="form-helper-text"
>Type of blocklist: Blacklist (block matches) or Whitelist (only allow matches)</small
>
</div>
</div>
</div>
</p-accordion-content>
</p-accordion-panel>
</p-accordion>
<!-- Action buttons -->

View File

@@ -127,7 +127,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
cronExpression: [{ value: '', disabled: true }, [Validators.required]],
jobSchedule: this.formBuilder.group({
every: [{ value: 5, disabled: true }, [Validators.required, Validators.min(1)]],
type: [{ value: ScheduleUnit.Minutes, disabled: true }],
type: [{ value: ScheduleUnit.Seconds, disabled: true }],
}),
ignorePrivate: [{ value: false, disabled: true }],
@@ -149,6 +149,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
blocklistPath: [{ value: "", disabled: true }],
blocklistType: [{ value: BlocklistType.Blacklist, disabled: true }],
}),
readarr: this.formBuilder.group({
enabled: [{ value: false, disabled: true }],
blocklistPath: [{ value: "", disabled: true }],
blocklistType: [{ value: BlocklistType.Blacklist, disabled: true }],
}),
});
// Create an effect to update the form when the configuration changes
@@ -162,13 +167,14 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
cronExpression: config.cronExpression,
jobSchedule: config.jobSchedule || {
every: 5,
type: ScheduleUnit.Minutes
type: ScheduleUnit.Seconds
},
ignorePrivate: config.ignorePrivate,
deletePrivate: config.deletePrivate,
sonarr: config.sonarr,
radarr: config.radarr,
lidarr: config.lidarr,
readarr: config.readarr,
});
// Update all form control states
@@ -278,7 +284,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
// Listen for changes to blocklist enabled states
['sonarr', 'radarr', 'lidarr'].forEach(arrType => {
['sonarr', 'radarr', 'lidarr', 'readarr'].forEach(arrType => {
const enabledControl = this.contentBlockerForm.get(`${arrType}.enabled`);
if (enabledControl) {
@@ -348,6 +354,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.updateBlocklistDependentControls('sonarr', config.sonarr?.enabled || false);
this.updateBlocklistDependentControls('radarr', config.radarr?.enabled || false);
this.updateBlocklistDependentControls('lidarr', config.lidarr?.enabled || false);
this.updateBlocklistDependentControls('readarr', config.readarr?.enabled || false);
}
}
@@ -407,15 +414,18 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.contentBlockerForm.get("sonarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("radarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("readarr.enabled")?.enable({ onlySelf: true });
// Update dependent controls based on current enabled states
const sonarrEnabled = this.contentBlockerForm.get("sonarr.enabled")?.value || false;
const radarrEnabled = this.contentBlockerForm.get("radarr.enabled")?.value || false;
const lidarrEnabled = this.contentBlockerForm.get("lidarr.enabled")?.value || false;
const readarrEnabled = this.contentBlockerForm.get("readarr.enabled")?.value || false;
this.updateBlocklistDependentControls('sonarr', sonarrEnabled);
this.updateBlocklistDependentControls('radarr', radarrEnabled);
this.updateBlocklistDependentControls('lidarr', lidarrEnabled);
this.updateBlocklistDependentControls('readarr', readarrEnabled);
} else {
// Disable all scheduling controls
cronExpressionControl?.disable();
@@ -440,6 +450,9 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.contentBlockerForm.get("lidarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.blocklistType")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.blocklistType")?.disable({ onlySelf: true });
// Save current active accordion state before clearing it
this.activeAccordionIndices = [];
@@ -483,6 +496,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
readarr: formValue.readarr || {
enabled: false,
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
};
// Save the configuration
@@ -551,6 +569,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
readarr: {
enabled: false,
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
});
// Manually update control states after reset
@@ -558,6 +581,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.updateBlocklistDependentControls('sonarr', false);
this.updateBlocklistDependentControls('radarr', false);
this.updateBlocklistDependentControls('lidarr', false);
this.updateBlocklistDependentControls('readarr', false);
// Mark form as dirty so the save button is enabled after reset
this.contentBlockerForm.markAsDirty();
@@ -596,7 +620,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
} else if (scheduleType === ScheduleUnit.Hours) {
return this.scheduleValueOptions[ScheduleUnit.Hours];
}
return this.scheduleValueOptions[ScheduleUnit.Minutes]; // Default to minutes
return this.scheduleValueOptions[ScheduleUnit.Seconds]; // Default to seconds
}
/**

View File

@@ -317,6 +317,13 @@
</label>
<div>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="unlinkedCategories"
placeholder="Add category"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="unlinkedCategories"
multiple
@@ -325,6 +332,7 @@
[suggestions]="unlinkedCategoriesSuggestions"
(completeMethod)="onUnlinkedCategoriesComplete($event)"
placeholder="Add category and press Enter"
class="desktop-only"
>
</p-autocomplete>
</div>

View File

@@ -11,6 +11,7 @@ import {
createDefaultCategory
} from "../../shared/models/download-cleaner-config.model";
import { ScheduleUnit, ScheduleOptions } from "../../shared/models/queue-cleaner-config.model";
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -54,7 +55,8 @@ import { DocumentationService } from "../../core/services/documentation.service"
TableModule,
LoadingErrorStateComponent,
ConfirmDialogModule,
NgIf
NgIf,
MobileAutocompleteComponent,
],
providers: [ConfirmationService],
templateUrl: "./download-cleaner-settings.component.html",
@@ -360,21 +362,27 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
.pipe(takeUntil(this.destroy$))
.subscribe(useAdvanced => {
const enabled = this.downloadCleanerForm.get('enabled')?.value || false;
if (enabled) {
const cronExpressionControl = this.downloadCleanerForm.get('cronExpression');
const jobScheduleGroup = this.downloadCleanerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup?.get('every');
const typeControl = jobScheduleGroup?.get('type');
if (useAdvanced) {
if (cronExpressionControl) cronExpressionControl.enable();
if (everyControl) everyControl.disable();
if (typeControl) typeControl.disable();
} else {
if (cronExpressionControl) cronExpressionControl.disable();
if (everyControl) everyControl.enable();
if (typeControl) typeControl.enable();
}
const cronExpressionControl = this.downloadCleanerForm.get('cronExpression');
const jobScheduleGroup = this.downloadCleanerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup?.get('every');
const typeControl = jobScheduleGroup?.get('type');
// Update scheduling controls based on mode, regardless of enabled state
if (useAdvanced) {
if (cronExpressionControl) cronExpressionControl.enable();
if (everyControl) everyControl.disable();
if (typeControl) typeControl.disable();
} else {
if (cronExpressionControl) cronExpressionControl.disable();
if (everyControl) everyControl.enable();
if (typeControl) typeControl.enable();
}
// Then respect the main enabled state - if disabled, disable all scheduling controls
if (!enabled) {
cronExpressionControl?.disable();
everyControl?.disable();
typeControl?.disable();
}
});
}
@@ -461,19 +469,14 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
* Update form control disabled states based on the configuration
*/
private updateFormControlDisabledStates(config: DownloadCleanerConfig): void {
// Update main controls based on enabled state
// Update main form controls based on the 'enabled' state
this.updateMainControlsState(config.enabled);
// Update schedule controls based on advanced scheduling
const cronControl = this.downloadCleanerForm.get('cronExpression');
const jobScheduleControl = this.downloadCleanerForm.get('jobSchedule');
if (config.useAdvancedScheduling) {
jobScheduleControl?.disable({ emitEvent: false });
cronControl?.enable({ emitEvent: false });
} else {
cronControl?.disable({ emitEvent: false });
jobScheduleControl?.enable({ emitEvent: false });
// Update other dependent controls only if the main feature is enabled
if (config.enabled) {
// Update unlinked controls based on current unlinkedEnabled value
const unlinkedEnabled = config.unlinkedEnabled || false;
this.updateUnlinkedControlsState(unlinkedEnabled);
}
}
@@ -550,14 +553,17 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
// Get form values including disabled controls
const formValues = this.downloadCleanerForm.getRawValue();
// Determine the correct cron expression to use
const cronExpression: string = formValues.useAdvancedScheduling ?
formValues.cronExpression :
// If in basic mode, generate cron expression from the schedule
this.downloadCleanerStore.generateCronExpression(formValues.jobSchedule);
// Create config object from form values
const config: DownloadCleanerConfig = {
enabled: formValues.enabled,
useAdvancedScheduling: formValues.useAdvancedScheduling,
cronExpression: formValues.useAdvancedScheduling ?
formValues.cronExpression :
// If in basic mode, generate cron expression from the schedule
this.downloadCleanerStore.generateCronExpression(formValues.jobSchedule),
cronExpression: cronExpression,
jobSchedule: formValues.jobSchedule,
categories: formValues.categories,
deletePrivate: formValues.deletePrivate,

View File

@@ -27,7 +27,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('displaySupportBanner')"
title="View documentation for support banner display">
title="Click for documentation">
</i>
Display Support Banner
</label>
@@ -42,7 +42,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('dryRun')"
title="View documentation for dry run mode">
title="Click for documentation">
</i>
Dry Run
</label>
@@ -57,7 +57,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('httpMaxRetries')"
title="View documentation for HTTP retry configuration">
title="Click for documentation">
</i>
Maximum HTTP Retries
</label>
@@ -81,7 +81,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('httpTimeout')"
title="View documentation for HTTP timeout configuration">
title="Click for documentation">
</i>
HTTP Timeout (seconds)
</label>
@@ -105,7 +105,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('httpCertificateValidation')"
title="View documentation for certificate validation options">
title="Click for documentation">
</i>
Certificate Validation
</label>
@@ -126,7 +126,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('searchEnabled')"
title="View documentation for automatic search functionality">
title="Click for documentation">
</i>
Enable Search
</label>
@@ -140,7 +140,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('searchDelay')"
title="View documentation for search delay configuration">
title="Click for documentation">
</i>
Search Delay (seconds)
</label>
@@ -165,7 +165,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('logLevel')"
title="View documentation for log level configuration">
title="Click for documentation">
</i>
Log Level
</label>
@@ -186,11 +186,18 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="View documentation for download ignore patterns">
title="Click for documentation">
</i>
Ignored Downloads
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredDownloads"
placeholder="Add download pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredDownloads"
inputId="ignoredDownloads"
@@ -198,6 +205,7 @@
fluid
[typeahead]="false"
placeholder="Add download pattern and press enter"
class="desktop-only"
></p-autocomplete>
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
</div>

View File

@@ -19,10 +19,12 @@ import { NotificationService } from '../../core/services/notification.service';
import { DocumentationService } from '../../core/services/documentation.service';
import { SelectModule } from "primeng/select";
import { ChipsModule } from "primeng/chips";
import { ChipModule } from "primeng/chip";
import { AutoCompleteModule } from "primeng/autocomplete";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
@Component({
selector: "app-general-settings",
@@ -36,11 +38,13 @@ import { ConfirmationService } from "primeng/api";
ButtonModule,
InputNumberModule,
ChipsModule,
ChipModule,
ToastModule,
SelectModule,
AutoCompleteModule,
LoadingErrorStateComponent,
ConfirmDialogModule,
MobileAutocompleteComponent,
],
providers: [GeneralConfigStore, ConfirmationService],
templateUrl: "./general-settings.component.html",

View File

@@ -35,7 +35,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.apiKey')"
title="View documentation for Notifiarr API key setup">
title="Click for documentation">
</i>
API Key
</label>
@@ -50,12 +50,12 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
title="View documentation for Discord channel ID setup">
title="Click for documentation">
</i>
Channel ID
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
title="View documentation for Discord channel ID setup">
title="Click for documentation">
</i>
</label>
<div class="field-input">
@@ -69,7 +69,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="View documentation for notification event types">
title="Click for documentation">
</i>
Event Triggers
</label>
@@ -115,7 +115,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('apprise.url')"
title="View documentation for Apprise server URL setup">
title="Click for documentation">
</i>
URL
</label>
@@ -130,7 +130,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('apprise.key')"
title="View documentation for Apprise configuration key">
title="Click for documentation">
</i>
Key
</label>
@@ -145,7 +145,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="View documentation for notification event types">
title="Click for documentation">
</i>
Event Triggers
</label>

View File

@@ -26,9 +26,8 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="View documentation for this setting">
</i>
(click)="openFieldDocs('enabled')"
title="Click for documentation"></i>
Enable Queue Cleaner
</label>
<div class="field-input">
@@ -41,9 +40,8 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('useAdvancedScheduling')"
title="View documentation for scheduling modes">
</i>
(click)="openFieldDocs('useAdvancedScheduling')"
title="Click for documentation"></i>
Scheduling Mode
</label>
<div class="field-input">
@@ -95,9 +93,8 @@
<div class="field-row" *ngIf="queueCleanerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('cronExpression')"
title="View cron expression documentation and examples">
</i>
(click)="openFieldDocs('cronExpression')"
title="Click for documentation"></i>
Cron Expression
</label>
<div>
@@ -127,9 +124,8 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('failedImport.maxStrikes')"
title="View documentation for failed import strike system">
</i>
(click)="openFieldDocs('failedImport.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
</label>
<div class="field-input">
@@ -150,9 +146,8 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('failedImport.ignorePrivate')"
title="View documentation for private torrent handling">
</i>
(click)="openFieldDocs('failedImport.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
@@ -164,9 +159,8 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('failedImport.deletePrivate')"
title="View documentation for private torrent deletion">
</i>
(click)="openFieldDocs('failedImport.deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
@@ -178,18 +172,25 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('failedImport.ignoredPatterns')"
title="View documentation for pattern matching and examples">
</i>
(click)="openFieldDocs('failedImport.ignoredPatterns')"
title="Click for documentation"></i>
Ignored Patterns
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredPatterns"
placeholder="Add pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredPatterns"
multiple
fluid
[typeahead]="false"
placeholder="Add pattern and press Enter"
class="desktop-only"
>
</p-autocomplete>
<small class="form-helper-text"
@@ -216,9 +217,8 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.maxStrikes')"
title="View documentation for stalled download strike system">
</i>
(click)="openFieldDocs('stalled.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
</label>
<div>
@@ -242,9 +242,8 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.resetStrikesOnProgress')"
title="View documentation for strike reset behavior">
</i>
(click)="openFieldDocs('stalled.resetStrikesOnProgress')"
title="Click for documentation"></i>
Reset Strikes On Progress
</label>
<div class="field-input">
@@ -256,9 +255,8 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.ignorePrivate')"
title="View documentation for private torrent handling">
</i>
(click)="openFieldDocs('stalled.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
@@ -270,9 +268,8 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.deletePrivate')"
title="View documentation for private torrent deletion">
</i>
(click)="openFieldDocs('stalled.deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
@@ -299,9 +296,8 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.downloadingMetadataMaxStrikes')"
title="View documentation for metadata download handling">
</i>
(click)="openFieldDocs('stalled.downloadingMetadataMaxStrikes')"
title="Click for documentation"></i>
Max Strikes for Downloading Metadata
</label>
<div>
@@ -340,9 +336,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.maxStrikes')"
title="View documentation for slow download strike system">
</i>
(click)="openFieldDocs('slow.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
</label>
<div>
@@ -366,9 +361,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.resetStrikesOnProgress')"
title="View documentation for strike reset behavior">
</i>
(click)="openFieldDocs('slow.resetStrikesOnProgress')"
title="Click for documentation"></i>
Reset Strikes On Progress
</label>
<div class="field-input">
@@ -380,9 +374,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.ignorePrivate')"
title="View documentation for private torrent handling">
</i>
(click)="openFieldDocs('slow.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
@@ -394,9 +387,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.deletePrivate')"
title="View documentation for private torrent deletion">
</i>
(click)="openFieldDocs('slow.deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
@@ -408,9 +400,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.minSpeed')"
title="View speed threshold guidelines and recommendations">
</i>
(click)="openFieldDocs('slow.minSpeed')"
title="Click for documentation"></i>
Minimum Speed
</label>
<div class="field-input">
@@ -427,9 +418,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.maxTime')"
title="View documentation for maximum slow download time">
</i>
(click)="openFieldDocs('slow.maxTime')"
title="Click for documentation"></i>
Maximum Time (hours)
</label>
<div class="field-input">
@@ -449,9 +439,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.ignoreAboveSize')"
title="View size exemption strategy and recommended thresholds">
</i>
(click)="openFieldDocs('slow.ignoreAboveSize')"
title="Click for documentation"></i>
Ignore Above Size
</label>
<div class="field-input">

View File

@@ -14,6 +14,7 @@ import {
} from "../../shared/models/queue-cleaner-config.model";
import { SettingsCardComponent } from "../components/settings-card/settings-card.component";
import { ByteSizeInputComponent } from "../../shared/components/byte-size-input/byte-size-input.component";
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -54,6 +55,7 @@ import { ErrorHandlerUtil } from "../../core/utils/error-handler.util";
AutoCompleteModule,
DropdownModule,
LoadingErrorStateComponent,
MobileAutocompleteComponent,
],
providers: [QueueCleanerConfigStore],
templateUrl: "./queue-cleaner-settings.component.html",
@@ -260,21 +262,27 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
advancedControl.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((useAdvanced: boolean) => {
const enabled = this.queueCleanerForm.get('enabled')?.value || false;
if (enabled) {
const cronExpressionControl = this.queueCleanerForm.get('cronExpression');
const jobScheduleGroup = this.queueCleanerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup?.get('every');
const typeControl = jobScheduleGroup?.get('type');
if (useAdvanced) {
if (cronExpressionControl) cronExpressionControl.enable();
if (everyControl) everyControl.disable();
if (typeControl) typeControl.disable();
} else {
if (cronExpressionControl) cronExpressionControl.disable();
if (everyControl) everyControl.enable();
if (typeControl) typeControl.enable();
}
const cronExpressionControl = this.queueCleanerForm.get('cronExpression');
const jobScheduleGroup = this.queueCleanerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup?.get('every');
const typeControl = jobScheduleGroup?.get('type');
// Update scheduling controls based on mode, regardless of enabled state
if (useAdvanced) {
if (cronExpressionControl) cronExpressionControl.enable();
if (everyControl) everyControl.disable();
if (typeControl) typeControl.disable();
} else {
if (cronExpressionControl) cronExpressionControl.disable();
if (everyControl) everyControl.enable();
if (typeControl) typeControl.enable();
}
// Then respect the main enabled state - if disabled, disable all scheduling controls
if (!enabled) {
cronExpressionControl?.disable();
everyControl?.disable();
typeControl?.disable();
}
});
}
@@ -519,14 +527,17 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
// Make a copy of the form values
const formValue = this.queueCleanerForm.getRawValue();
// Determine the correct cron expression to use
const cronExpression: string = formValue.useAdvancedScheduling ?
formValue.cronExpression :
// If in basic mode, generate cron expression from the schedule
this.queueCleanerStore.generateCronExpression(formValue.jobSchedule);
// Create the config object to be saved
const queueCleanerConfig: QueueCleanerConfig = {
enabled: formValue.enabled,
useAdvancedScheduling: formValue.useAdvancedScheduling,
cronExpression: formValue.useAdvancedScheduling ?
formValue.cronExpression :
// If in basic mode, generate cron expression from the schedule
this.queueCleanerStore.generateCronExpression(formValue.jobSchedule),
cronExpression: cronExpression,
jobSchedule: formValue.jobSchedule,
failedImport: {
maxStrikes: formValue.failedImport?.maxStrikes || 0,

View File

@@ -0,0 +1,365 @@
import { Injectable, inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ReadarrConfig } from '../../shared/models/readarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
export interface ReadarrConfigState {
config: ReadarrConfig | null;
loading: boolean;
saving: boolean;
error: string | null;
instanceOperations: number;
}
const initialState: ReadarrConfigState = {
config: null,
loading: false,
saving: false,
error: null,
instanceOperations: 0
};
@Injectable()
export class ReadarrConfigStore extends signalStore(
withState(initialState),
withMethods((store, configService = inject(ConfigurationService)) => ({
/**
* Load the Readarr configuration
*/
loadConfig: rxMethod<void>(
pipe => pipe.pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() => configService.getReadarrConfig().pipe(
tap({
next: (config) => patchState(store, { config, loading: false }),
error: (error) => {
patchState(store, {
loading: false,
error: error.message || 'Failed to load Readarr configuration'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Save the Readarr global configuration
*/
saveConfig: rxMethod<{failedImportMaxStrikes: number}>(
(globalConfig$: Observable<{failedImportMaxStrikes: number}>) => globalConfig$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
switchMap(globalConfig => configService.updateReadarrConfig(globalConfig).pipe(
tap({
next: () => {
const currentConfig = store.config();
if (currentConfig) {
// Update the local config with the new global settings
patchState(store, {
config: { ...currentConfig, ...globalConfig },
saving: false
});
}
},
error: (error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save Readarr configuration'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Save the Readarr configuration
*/
saveFullConfig: rxMethod<ReadarrConfig>(
(config$: Observable<ReadarrConfig>) => config$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
switchMap(config => configService.updateReadarrConfig(config).pipe(
tap({
next: () => {
patchState(store, {
config,
saving: false
});
},
error: (error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save Readarr configuration'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Update config in the store without saving to the backend
*/
updateConfigLocally(config: Partial<ReadarrConfig>) {
const currentConfig = store.config();
if (currentConfig) {
patchState(store, {
config: { ...currentConfig, ...config }
});
}
},
/**
* Reset any errors
*/
resetError() {
patchState(store, { error: null });
},
// ===== INSTANCE MANAGEMENT =====
/**
* Create a new Readarr instance
*/
createInstance: rxMethod<CreateArrInstanceDto>(
(instance$: Observable<CreateArrInstanceDto>) => instance$.pipe(
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
switchMap(instance => configService.createReadarrInstance(instance).pipe(
tap({
next: (newInstance) => {
const currentConfig = store.config();
if (currentConfig) {
patchState(store, {
config: { ...currentConfig, instances: [...currentConfig.instances, newInstance] },
saving: false,
instanceOperations: store.instanceOperations() - 1
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: store.instanceOperations() - 1,
error: error.message || 'Failed to create Readarr instance'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Update a Readarr instance by ID
*/
updateInstance: rxMethod<{ id: string, instance: CreateArrInstanceDto }>(
(params$: Observable<{ id: string, instance: CreateArrInstanceDto }>) => params$.pipe(
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
switchMap(({ id, instance }) => configService.updateReadarrInstance(id, instance).pipe(
tap({
next: (updatedInstance) => {
const currentConfig = store.config();
if (currentConfig) {
const updatedInstances = currentConfig.instances.map((inst: ArrInstance) =>
inst.id === id ? updatedInstance : inst
);
patchState(store, {
config: { ...currentConfig, instances: updatedInstances },
saving: false,
instanceOperations: store.instanceOperations() - 1
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: store.instanceOperations() - 1,
error: error.message || `Failed to update Readarr instance with ID ${id}`
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Delete a Readarr instance by ID
*/
deleteInstance: rxMethod<string>(
(id$: Observable<string>) => id$.pipe(
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
switchMap(id => configService.deleteReadarrInstance(id).pipe(
tap({
next: () => {
const currentConfig = store.config();
if (currentConfig) {
const updatedInstances = currentConfig.instances.filter((inst: ArrInstance) => inst.id !== id);
patchState(store, {
config: { ...currentConfig, instances: updatedInstances },
saving: false,
instanceOperations: store.instanceOperations() - 1
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: store.instanceOperations() - 1,
error: error.message || `Failed to delete Readarr instance with ID ${id}`
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Save config and then process instance operations sequentially
*/
saveConfigAndInstances: rxMethod<{
config: ReadarrConfig,
instanceOperations: {
creates: CreateArrInstanceDto[],
updates: Array<{ id: string, instance: CreateArrInstanceDto }>,
deletes: string[]
}
}>(
(params$: Observable<{
config: ReadarrConfig,
instanceOperations: {
creates: CreateArrInstanceDto[],
updates: Array<{ id: string, instance: CreateArrInstanceDto }>,
deletes: string[]
}
}>) => params$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
switchMap(({ config, instanceOperations }) => {
// First save the main config
return configService.updateReadarrConfig(config).pipe(
tap(() => {
patchState(store, { config });
}),
switchMap(() => {
// Then process instance operations if any
const { creates, updates, deletes } = instanceOperations;
const totalOperations = creates.length + updates.length + deletes.length;
if (totalOperations === 0) {
patchState(store, { saving: false });
return EMPTY;
}
patchState(store, { instanceOperations: totalOperations });
// Prepare all operations
const createOps = creates.map(instance =>
configService.createReadarrInstance(instance).pipe(
catchError(error => {
console.error('Failed to create Readarr instance:', error);
return of(null);
})
)
);
const updateOps = updates.map(({ id, instance }) =>
configService.updateReadarrInstance(id, instance).pipe(
catchError(error => {
console.error('Failed to update Readarr instance:', error);
return of(null);
})
)
);
const deleteOps = deletes.map(id =>
configService.deleteReadarrInstance(id).pipe(
catchError(error => {
console.error('Failed to delete Readarr instance:', error);
return of(null);
})
)
);
// Execute all operations in parallel
return forkJoin([...createOps, ...updateOps, ...deleteOps]).pipe(
tap({
next: (results) => {
const currentConfig = store.config();
if (currentConfig) {
let updatedInstances = [...currentConfig.instances];
let failedCount = 0;
// Process create results
const createResults = results.slice(0, creates.length);
const successfulCreates = createResults.filter(instance => instance !== null) as ArrInstance[];
updatedInstances = [...updatedInstances, ...successfulCreates];
failedCount += createResults.filter(instance => instance === null).length;
// Process update results
const updateResults = results.slice(creates.length, creates.length + updates.length);
updateResults.forEach((result, index) => {
if (result !== null) {
const instanceIndex = updatedInstances.findIndex(inst => inst.id === updates[index].id);
if (instanceIndex !== -1) {
updatedInstances[instanceIndex] = result as ArrInstance;
}
} else {
failedCount++;
}
});
// Process delete results
const deleteResults = results.slice(creates.length + updates.length);
deleteResults.forEach((result, index) => {
if (result !== null) {
// Delete was successful, remove from array
updatedInstances = updatedInstances.filter(inst => inst.id !== deletes[index]);
} else {
failedCount++;
}
});
patchState(store, {
config: { ...currentConfig, instances: updatedInstances },
saving: false,
instanceOperations: 0,
error: failedCount > 0 ? `${failedCount} operation(s) failed` : null
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: 0,
error: error.message || 'Failed to process instance operations'
});
}
})
);
}),
catchError((error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save Readarr configuration'
});
return EMPTY;
})
);
})
)
)
})),
withHooks({
onInit({ loadConfig }) {
loadConfig();
}
})
) {}

View File

@@ -0,0 +1,230 @@
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Readarr</h1>
</div>
<!-- Loading/Error State Component -->
<div class="mb-4">
<app-loading-error-state
*ngIf="readarrLoading() || readarrError()"
[loading]="readarrLoading()"
[error]="readarrError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
</div>
<!-- Content - only shown when not loading and no error -->
<div *ngIf="!readarrLoading() && !readarrError()">
<!-- Global Configuration Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Readarr Settings</h2>
<span class="card-subtitle">Configure general Readarr integration settings</span>
</div>
</div>
</ng-template>
<form [formGroup]="globalForm" class="p-fluid">
<div class="field-row">
<label class="field-label">Failed Import Max Strikes</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="failedImportMaxStrikes"
[min]="-1"
[showButtons]="true"
buttonLayout="horizontal"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
></p-inputNumber>
</div>
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
</div>
</div>
<!-- Save Button -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="!globalForm.dirty || !hasGlobalChanges || globalForm.invalid || readarrSaving()"
[loading]="readarrSaving()"
(click)="saveGlobalConfig()"
></button>
</div>
</form>
</p-card>
<!-- Instance Management Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Instances</h2>
<span class="card-subtitle">Manage Readarr server instances</span>
</div>
</div>
</ng-template>
<!-- Empty state when no instances -->
<div *ngIf="instances.length === 0" class="empty-instances-message p-3 text-center">
<i class="pi pi-inbox empty-icon"></i>
<p>No Readarr instances configured</p>
<small>Add an instance to start using Readarr integration</small>
</div>
<!-- Instances List -->
<div *ngIf="instances.length > 0" class="instances-list">
<div *ngFor="let instance of instances" class="instance-item">
<div class="instance-header">
<div class="instance-title">
<i class="pi pi-server instance-icon"></i>
<span class="instance-name">{{ instance.name }}</span>
</div>
<div class="instance-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-text p-button-sm"
[disabled]="readarrSaving()"
(click)="openEditInstanceModal(instance)"
pTooltip="Edit instance"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-text p-button-sm p-button-danger"
[disabled]="readarrSaving()"
(click)="deleteInstance(instance)"
pTooltip="Delete instance"
></button>
</div>
</div>
<div class="instance-content">
<div class="instance-field">
<label>{{ instance.url }}</label>
</div>
<div class="instance-field">
<label>Status:
<span [class]="instance.enabled ? 'text-green-500' : 'text-red-500'">
{{ instance.enabled ? 'Enabled' : 'Disabled' }}
</span>
</label>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
icon="pi pi-plus"
label="Add Instance"
class="p-button-outlined"
[disabled]="readarrSaving()"
(click)="openAddInstanceModal()"
></button>
</div>
</p-card>
</div>
</div>
<!-- Instance Modal -->
<p-dialog
[(visible)]="showInstanceModal"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="instance-modal"
[header]="modalTitle"
(onHide)="closeInstanceModal()"
>
<form [formGroup]="instanceForm" class="p-fluid instance-form">
<div class="field flex flex-row">
<label class="field-label">Enabled</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">Enable this Readarr instance</small>
</div>
</div>
<div class="field">
<label for="instance-name">Name *</label>
<input
id="instance-name"
type="text"
pInputText
formControlName="name"
placeholder="My Readarr Instance"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
</div>
<div class="field">
<label for="instance-url">URL *</label>
<input
id="instance-url"
type="text"
pInputText
formControlName="url"
placeholder="http://localhost:8787"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
</div>
<div class="field">
<label for="instance-apikey">API Key *</label>
<input
id="instance-apikey"
type="password"
pInputText
formControlName="apiKey"
placeholder="Your Readarr API key"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
</div>
</form>
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || readarrSaving()"
[loading]="readarrSaving()"
(click)="saveInstance()"
></button>
</div>
</ng-template>
</p-dialog>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -0,0 +1,5 @@
/* Readarr Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';

View File

@@ -0,0 +1,415 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { ReadarrConfigStore } from "./readarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
import { InputTextModule } from "primeng/inputtext";
import { CheckboxModule } from "primeng/checkbox";
import { ButtonModule } from "primeng/button";
import { InputNumberModule } from "primeng/inputnumber";
import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@Component({
selector: "app-readarr-settings",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
CardModule,
InputTextModule,
CheckboxModule,
ButtonModule,
InputNumberModule,
ToastModule,
DialogModule,
ConfirmDialogModule,
LoadingErrorStateComponent,
],
providers: [ReadarrConfigStore, ConfirmationService],
templateUrl: "./readarr-settings.component.html",
styleUrls: ["./readarr-settings.component.scss"],
})
export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactivate {
@Output() saved = new EventEmitter<void>();
@Output() error = new EventEmitter<string>();
// Forms
globalForm: FormGroup;
instanceForm: FormGroup;
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
editingInstance: ArrInstance | null = null;
// Original form values for tracking changes
private originalGlobalValues: any;
hasGlobalChanges = false;
// Clean up subscriptions
private destroy$ = new Subject<void>();
// Services
private formBuilder = inject(FormBuilder);
private notificationService = inject(NotificationService);
private confirmationService = inject(ConfirmationService);
private readarrStore = inject(ReadarrConfigStore);
// Signals from store
readarrConfig = this.readarrStore.config;
readarrLoading = this.readarrStore.loading;
readarrError = this.readarrStore.error;
readarrSaving = this.readarrStore.saving;
/**
* Check if component can be deactivated (navigation guard)
*/
canDeactivate(): boolean {
return !this.globalForm?.dirty || !this.hasGlobalChanges;
}
constructor() {
// Initialize forms
this.globalForm = this.formBuilder.group({
failedImportMaxStrikes: [-1],
});
this.instanceForm = this.formBuilder.group({
enabled: [true],
name: ['', Validators.required],
url: ['', [Validators.required, this.uriValidator.bind(this)]],
apiKey: ['', Validators.required],
});
// Load Readarr config data
this.readarrStore.loadConfig();
// Setup effect to update form when config changes
effect(() => {
const config = this.readarrConfig();
if (config) {
this.updateGlobalFormFromConfig(config);
}
});
// Track global form changes
this.globalForm.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
}
/**
* Clean up subscriptions when component is destroyed
*/
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Update global form with values from the configuration
*/
private updateGlobalFormFromConfig(config: ReadarrConfig): void {
this.globalForm.patchValue({
failedImportMaxStrikes: config.failedImportMaxStrikes,
});
// Store original values for dirty checking
this.storeOriginalGlobalValues();
}
/**
* Store original global form values for dirty checking
*/
private storeOriginalGlobalValues(): void {
this.originalGlobalValues = JSON.parse(JSON.stringify(this.globalForm.value));
this.globalForm.markAsPristine();
this.hasGlobalChanges = false;
}
/**
* Check if the current global form values are different from the original values
*/
private globalFormValuesChanged(): boolean {
return !this.isEqual(this.globalForm.value, this.originalGlobalValues);
}
/**
* Deep compare two objects for equality
*/
private isEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 == null || obj2 == null) {
return false;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
const val1 = obj1[key];
const val2 = obj2[key];
const areObjects = typeof val1 === "object" && typeof val2 === "object";
if ((areObjects && !this.isEqual(val1, val2)) || (!areObjects && val1 !== val2)) {
return false;
}
}
return true;
}
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
/**
* Mark all controls in a form group as touched
*/
private markFormGroupTouched(formGroup: FormGroup): void {
Object.values(formGroup.controls).forEach((control) => {
control.markAsTouched();
if ((control as any).controls) {
this.markFormGroupTouched(control as FormGroup);
}
});
}
/**
* Check if a form control has an error
*/
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
const control = form.get(controlName);
return control !== null && control.hasError(errorName) && control.touched;
}
/**
* Save the global Readarr configuration
*/
saveGlobalConfig(): void {
this.markFormGroupTouched(this.globalForm);
if (this.globalForm.invalid) {
this.notificationService.showError('Please fix the validation errors before saving');
return;
}
if (!this.hasGlobalChanges) {
this.notificationService.showSuccess('No changes detected');
return;
}
const updatedConfig = {
failedImportMaxStrikes: this.globalForm.get('failedImportMaxStrikes')?.value
};
this.readarrStore.saveConfig(updatedConfig);
// Monitor saving completion
this.monitorGlobalSaving();
}
/**
* Monitor global saving completion
*/
private monitorGlobalSaving(): void {
const checkSavingStatus = () => {
const saving = this.readarrSaving();
const error = this.readarrError();
if (!saving) {
if (error) {
this.notificationService.showError(`Save failed: ${error}`);
this.error.emit(error);
} else {
this.notificationService.showSuccess('Global configuration saved successfully');
this.saved.emit();
// Reset form state without reloading from backend
this.globalForm.markAsPristine();
this.hasGlobalChanges = false;
this.storeOriginalGlobalValues();
}
} else {
setTimeout(checkSavingStatus, 100);
}
};
setTimeout(checkSavingStatus, 100);
}
/**
* Get instances from current config
*/
get instances(): ArrInstance[] {
return this.readarrConfig()?.instances || [];
}
/**
* Open modal to add new instance
*/
openAddInstanceModal(): void {
this.modalMode = 'add';
this.editingInstance = null;
this.instanceForm.reset({
enabled: true,
name: '',
url: '',
apiKey: ''
});
this.showInstanceModal = true;
}
/**
* Open modal to edit existing instance
*/
openEditInstanceModal(instance: ArrInstance): void {
this.modalMode = 'edit';
this.editingInstance = instance;
this.instanceForm.patchValue({
enabled: instance.enabled,
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
});
this.showInstanceModal = true;
}
/**
* Close instance modal
*/
closeInstanceModal(): void {
this.showInstanceModal = false;
this.editingInstance = null;
this.instanceForm.reset();
}
/**
* Save instance (add or edit)
*/
saveInstance(): void {
this.markFormGroupTouched(this.instanceForm);
if (this.instanceForm.invalid) {
this.notificationService.showError('Please fix the validation errors before saving');
return;
}
const instanceData: CreateArrInstanceDto = {
enabled: this.instanceForm.get('enabled')?.value,
name: this.instanceForm.get('name')?.value,
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
if (this.modalMode === 'add') {
this.readarrStore.createInstance(instanceData);
} else if (this.editingInstance) {
this.readarrStore.updateInstance({
id: this.editingInstance.id!,
instance: instanceData
});
}
this.monitorInstanceSaving();
}
/**
* Monitor instance saving completion
*/
private monitorInstanceSaving(): void {
const checkSavingStatus = () => {
const saving = this.readarrSaving();
const error = this.readarrError();
if (!saving) {
if (error) {
this.notificationService.showError(`Operation failed: ${error}`);
} else {
const action = this.modalMode === 'add' ? 'created' : 'updated';
this.notificationService.showSuccess(`Instance ${action} successfully`);
this.closeInstanceModal();
}
} else {
setTimeout(checkSavingStatus, 100);
}
};
setTimeout(checkSavingStatus, 100);
}
/**
* Delete instance with confirmation
*/
deleteInstance(instance: ArrInstance): void {
this.confirmationService.confirm({
message: `Are you sure you want to delete the instance "${instance.name}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.readarrStore.deleteInstance(instance.id!);
// Monitor deletion
const checkDeletionStatus = () => {
const saving = this.readarrSaving();
const error = this.readarrError();
if (!saving) {
if (error) {
this.notificationService.showError(`Deletion failed: ${error}`);
} else {
this.notificationService.showSuccess('Instance deleted successfully');
}
} else {
setTimeout(checkDeletionStatus, 100);
}
};
setTimeout(checkDeletionStatus, 100);
}
});
}
/**
* Get modal title based on mode
*/
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Readarr Instance' : 'Edit Readarr Instance';
}
}

View File

@@ -1,3 +1,9 @@
@media (max-width: 768px) {
.desktop-only {
display: none !important;
}
}
// Documentation info icon styles
.field-info-icon {
margin-right: 0.5rem;

View File

@@ -0,0 +1,41 @@
/* Mobile-friendly autocomplete styles */
.mobile-autocomplete-container {
.input-with-button {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
.mobile-input {
flex: 1;
min-height: 40px;
}
.add-button {
flex-shrink: 0;
min-width: 40px;
height: 40px;
}
}
.chips-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
}
/* Responsive design - show mobile component on mobile devices */
@media (max-width: 768px) {
:host {
display: block;
}
}
/* Hide mobile component on larger screens */
@media (min-width: 769px) {
:host {
display: none;
}
}

View File

@@ -0,0 +1,107 @@
import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
import { InputTextModule } from 'primeng/inputtext';
import { ButtonModule } from 'primeng/button';
import { ChipModule } from 'primeng/chip';
@Component({
selector: 'app-mobile-autocomplete',
standalone: true,
imports: [
CommonModule,
FormsModule,
InputTextModule,
ButtonModule,
ChipModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MobileAutocompleteComponent),
multi: true
}
],
template: `
<div class="mobile-autocomplete-container">
<div class="input-with-button">
<input
type="text"
pInputText
#inputField
[placeholder]="placeholder"
(keyup.enter)="addItem(inputField.value); inputField.value = ''"
class="mobile-input"
/>
<button
pButton
type="button"
icon="pi pi-plus"
class="p-button-sm add-button"
(click)="addItem(inputField.value); inputField.value = ''"
[title]="'Add ' + placeholder"
></button>
</div>
<div class="chips-container" *ngIf="value && value.length > 0">
<p-chip
*ngFor="let item of value; let i = index"
[label]="item"
[removable]="true"
(onRemove)="removeItem(i)"
class="mb-2 mr-2"
></p-chip>
</div>
</div>
`,
styleUrls: ['./mobile-autocomplete.component.scss']
})
export class MobileAutocompleteComponent implements ControlValueAccessor {
@Input() placeholder: string = 'Add item and press Enter';
@Input() multiple: boolean = true;
value: string[] = [];
disabled: boolean = false;
// ControlValueAccessor implementation
private onChange = (value: string[]) => {};
private onTouched = () => {};
writeValue(value: string[]): void {
this.value = value || [];
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
addItem(item: string): void {
if (item && item.trim() && !this.disabled) {
const trimmedItem = item.trim();
// Check if item already exists
if (!this.value.includes(trimmedItem)) {
const newValue = [...this.value, trimmedItem];
this.value = newValue;
this.onChange(this.value);
this.onTouched();
}
}
}
removeItem(index: number): void {
if (!this.disabled) {
const newValue = this.value.filter((_, i) => i !== index);
this.value = newValue;
this.onChange(this.value);
this.onTouched();
}
}
}

View File

@@ -41,4 +41,5 @@ export interface ContentBlockerConfig {
sonarr: BlocklistSettings;
radarr: BlocklistSettings;
lidarr: BlocklistSettings;
readarr: BlocklistSettings;
}

View File

@@ -0,0 +1,14 @@
/**
* ReadarrConfig model definitions for the UI
* These models represent the structures used in the API for Readarr configuration
*/
import { ArrInstance } from "./arr-config.model";
/**
* Main ReadarrConfig model representing the configuration for Readarr integration
*/
export interface ReadarrConfig {
failedImportMaxStrikes: number;
instances: ArrInstance[];
}

View File

@@ -18,7 +18,7 @@ Cleanuparr integrates with popular *arr applications and download clients for co
| **Category** | **Application** | **Integration** |
|--------------|-----------------|-----------------|
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr, Whisparr | Full API integration for queue monitoring and search triggers |
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr | Full API integration for queue monitoring and search triggers |
| **Download Clients** | qBittorrent, Deluge, Transmission | Complete download management and monitoring |
</div>

View File

@@ -207,7 +207,7 @@ function IntegrationsSection() {
{ name: "Sonarr", icon: "📺", color: "#3578e5" },
{ name: "Radarr", icon: "🎬", color: "#ffc107" },
{ name: "Lidarr", icon: "🎵", color: "#28a745" },
//{ name: "Readarr", icon: "📚", color: "#6f42c1" },
{ name: "Readarr", icon: "📚", color: "#6f42c1" },
//{ name: "Whisparr", icon: "🔞", color: "#dc3545" },
{ name: "qBittorrent", icon: "⬇️", color: "#17a2b8" },
{ name: "Deluge", icon: "🌊", color: "#fd7e14" },