mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-30 17:39:03 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb6cf96470 | ||
|
|
2ca0616771 | ||
|
|
bc85144e60 | ||
|
|
236e31c841 | ||
|
|
7a15139aa6 | ||
|
|
fb6ccfd011 | ||
|
|
ef85e2b690 |
20
.github/workflows/build-executable.yml
vendored
20
.github/workflows/build-executable.yml
vendored
@@ -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
|
||||
12
.github/workflows/build-macos-arm-installer.yml
vendored
12
.github/workflows/build-macos-arm-installer.yml
vendored
@@ -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
|
||||
25
.github/workflows/build-windows-installer.yml
vendored
25
.github/workflows/build-windows-installer.yml
vendored
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ public static class ServicesDI
|
||||
.AddTransient<SonarrClient>()
|
||||
.AddTransient<RadarrClient>()
|
||||
.AddTransient<LidarrClient>()
|
||||
.AddTransient<ReadarrClient>()
|
||||
.AddTransient<ArrClientFactory>()
|
||||
.AddTransient<QueueCleaner>()
|
||||
.AddTransient<ContentBlocker>()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public record Image
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public record LidarrImage
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueAlbum
|
||||
{
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueBook
|
||||
{
|
||||
public List<ReadarrImage> Images { get; init; } = [];
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public record QueueListResponse
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueMovie
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueSeries
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
12
code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs
Normal file
12
code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs
Normal 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();
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface IReadarrClient : IArrClient
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
620
code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.Designer.cs
generated
Normal file
620
code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ApplicationPathService {
|
||||
*/
|
||||
getDocumentationBaseUrl(): string {
|
||||
if (isDevMode()) {
|
||||
return 'http://localhost:3000';
|
||||
return 'http://localhost:3000/Cleanuparr';
|
||||
}
|
||||
|
||||
return 'https://cleanuparr.github.io/Cleanuparr';
|
||||
@@ -59,7 +59,7 @@ export class ApplicationPathService {
|
||||
*/
|
||||
buildDocumentationUrl(section: string, fieldAnchor?: string): string {
|
||||
const baseUrl = this.getDocumentationBaseUrl();
|
||||
let url = `${baseUrl}/cleanuparr/docs/configuration/${section}`;
|
||||
let url = `${baseUrl}/docs/configuration/${section}`;
|
||||
|
||||
if (fieldAnchor) {
|
||||
url += `#${fieldAnchor}`;
|
||||
|
||||
@@ -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}`));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})),
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
365
code/frontend/src/app/settings/readarr/readarr-config.store.ts
Normal file
365
code/frontend/src/app/settings/readarr/readarr-config.store.ts
Normal 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();
|
||||
}
|
||||
})
|
||||
) {}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Readarr Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
@media (max-width: 768px) {
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Documentation info icon styles
|
||||
.field-info-icon {
|
||||
margin-right: 0.5rem;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,4 +41,5 @@ export interface ContentBlockerConfig {
|
||||
sonarr: BlocklistSettings;
|
||||
radarr: BlocklistSettings;
|
||||
lidarr: BlocklistSettings;
|
||||
readarr: BlocklistSettings;
|
||||
}
|
||||
|
||||
14
code/frontend/src/app/shared/models/readarr-config.model.ts
Normal file
14
code/frontend/src/app/shared/models/readarr-config.model.ts
Normal 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[];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user