mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-31 01:48:49 -05:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
823b73d9f0 | ||
|
|
31632d25a4 | ||
|
|
c59951a39c | ||
|
|
d9140d7b5b | ||
|
|
90865a73b5 | ||
|
|
cc45233223 | ||
|
|
5d12d601ae | ||
|
|
88f40438af | ||
|
|
0a9ec06841 | ||
|
|
a0ca6ec4b8 |
@@ -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
|
||||
@@ -45,6 +45,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
|
||||
|
||||
# Install required packages for user management and timezone support
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
tzdata \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -3,7 +3,6 @@ using Cleanuparr.Application.Features.Arr.Dtos;
|
||||
using Cleanuparr.Application.Features.DownloadClient.Dtos;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
@@ -327,6 +326,24 @@ public class ConfigurationController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("whisparr")]
|
||||
public async Task<IActionResult> GetWhisparrConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Whisparr);
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("notifications")]
|
||||
public async Task<IActionResult> GetNotificationsConfig()
|
||||
{
|
||||
@@ -822,6 +839,37 @@ public class ConfigurationController : ControllerBase
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("whisparr")]
|
||||
public async Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateWhisparrConfigDto newConfigDto)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Get existing config
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == InstanceType.Whisparr);
|
||||
|
||||
config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes;
|
||||
|
||||
// Validate the configuration
|
||||
config.Validate();
|
||||
|
||||
// Persist the configuration
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { Message = "Whisparr configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save Whisparr configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a job schedule based on configuration changes
|
||||
@@ -1296,4 +1344,114 @@ public class ConfigurationController : ControllerBase
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("whisparr/instances")]
|
||||
public async Task<IActionResult> CreateWhisparrInstance([FromBody] CreateArrInstanceDto newInstance)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Get the Whisparr config to add the instance to
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == InstanceType.Whisparr);
|
||||
|
||||
// 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(GetWhisparrConfig), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Whisparr instance");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("whisparr/instances/{id}")]
|
||||
public async Task<IActionResult> UpdateWhisparrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Get the Whisparr config and find the instance
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == InstanceType.Whisparr);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance == null)
|
||||
{
|
||||
return NotFound($"Whisparr 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 Whisparr instance with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("whisparr/instances/{id}")]
|
||||
public async Task<IActionResult> DeleteWhisparrInstance(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Get the Whisparr config and find the instance
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == InstanceType.Whisparr);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance == null)
|
||||
{
|
||||
return NotFound($"Whisparr 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 Whisparr instance with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,38 @@ public static class ApiDI
|
||||
// Map SignalR hubs
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health");
|
||||
app.MapHub<AppHub>("/api/hubs/app");
|
||||
|
||||
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
|
||||
{
|
||||
var basePath = context.Request.PathBase.HasValue
|
||||
? context.Request.PathBase.Value
|
||||
: "/";
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
name = "Cleanuparr",
|
||||
short_name = "Cleanuparr",
|
||||
start_url = basePath,
|
||||
display = "standalone",
|
||||
background_color = "#ffffff",
|
||||
theme_color = "#ffffff",
|
||||
icons = new[]
|
||||
{
|
||||
new {
|
||||
src = "assets/icons/icon-192x192.png",
|
||||
sizes = "192x192",
|
||||
type = "image/png"
|
||||
},
|
||||
new {
|
||||
src = "assets/icons/icon-512x512.png",
|
||||
sizes = "512x512",
|
||||
type = "image/png"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Json(manifest, contentType: "application/manifest+json");
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -26,7 +27,7 @@ public static class MainDI
|
||||
.AddMassTransit(config =>
|
||||
{
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SonarrSearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
@@ -48,7 +49,7 @@ public static class MainDI
|
||||
cfg.ReceiveEndpoint("download-remover-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SonarrSearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ public static class ServicesDI
|
||||
.AddTransient<RadarrClient>()
|
||||
.AddTransient<LidarrClient>()
|
||||
.AddTransient<ReadarrClient>()
|
||||
.AddTransient<WhisparrClient>()
|
||||
.AddTransient<ArrClientFactory>()
|
||||
.AddTransient<QueueCleaner>()
|
||||
.AddTransient<ContentBlocker>()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for updating Whisparr configuration basic settings (instances managed separately)
|
||||
/// </summary>
|
||||
public record UpdateWhisparrConfigDto
|
||||
{
|
||||
public short FailedImportMaxStrikes { get; init; } = -1;
|
||||
}
|
||||
@@ -13,7 +13,6 @@ using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Data.Models.Arr.Queue;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -65,6 +64,7 @@ public sealed class ContentBlocker : GenericHandler
|
||||
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
|
||||
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
|
||||
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
|
||||
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
|
||||
|
||||
if (config.Sonarr.Enabled)
|
||||
{
|
||||
@@ -85,6 +85,11 @@ public sealed class ContentBlocker : GenericHandler
|
||||
{
|
||||
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
|
||||
}
|
||||
|
||||
if (config.Whisparr.Enabled)
|
||||
{
|
||||
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
|
||||
@@ -11,7 +11,6 @@ using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Data.Models.Arr.Queue;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -132,7 +131,8 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
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);
|
||||
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), InstanceType.Whisparr, true);
|
||||
|
||||
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Found {count} potential downloads to change category", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
|
||||
@@ -12,7 +12,6 @@ using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Data.Models.Arr.Queue;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -44,11 +43,13 @@ public sealed class QueueCleaner : GenericHandler
|
||||
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
|
||||
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
|
||||
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
|
||||
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
|
||||
|
||||
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
|
||||
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
|
||||
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
|
||||
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
|
||||
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
@@ -145,7 +146,7 @@ public sealed class QueueCleaner : GenericHandler
|
||||
var config = ContextProvider.Get<QueueCleanerConfig>();
|
||||
|
||||
// failed import check
|
||||
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, config.FailedImport.MaxStrikes);
|
||||
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);
|
||||
DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.FailedImport;
|
||||
|
||||
if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public record QueueListResponse
|
||||
{
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueRecord
|
||||
{
|
||||
// Sonarr
|
||||
// Sonarr and Whisparr
|
||||
public long SeriesId { get; init; }
|
||||
public long EpisodeId { get; init; }
|
||||
public long SeasonNumber { get; init; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record TrackedDownloadStatusMessage
|
||||
{
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Data.Models.Arr;
|
||||
|
||||
namespace Data.Models.Arr;
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed class SonarrSearchItem : SearchItem
|
||||
public sealed class SeriesSearchItem : SearchItem
|
||||
{
|
||||
public long SeriesId { get; set; }
|
||||
|
||||
public SonarrSearchType SearchType { get; set; }
|
||||
public SeriesSearchType SearchType { get; set; }
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not SonarrSearchItem other)
|
||||
if (obj is not SeriesSearchItem other)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -12,5 +12,5 @@ public sealed record SonarrCommand
|
||||
|
||||
public List<long>? EpisodeIds { get; set; }
|
||||
|
||||
public SonarrSearchType SearchType { get; set; }
|
||||
public SeriesSearchType SearchType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Whisparr;
|
||||
|
||||
public sealed record WhisparrCommand
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
public long? SeriesId { get; set; }
|
||||
|
||||
public long? SeasonNumber { get; set; }
|
||||
|
||||
public List<long>? EpisodeIds { get; set; }
|
||||
|
||||
public SeriesSearchType SearchType { get; set; }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum SonarrSearchType
|
||||
public enum SeriesSearchType
|
||||
{
|
||||
Episode,
|
||||
Season,
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
@@ -47,7 +50,10 @@ public class EventPublisher
|
||||
EventType = eventType,
|
||||
Message = message,
|
||||
Severity = severity,
|
||||
Data = data != null ? JsonSerializer.Serialize(data) : null,
|
||||
Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
}) : null,
|
||||
TrackingId = trackingId
|
||||
};
|
||||
|
||||
@@ -75,12 +81,37 @@ public class EventPublisher
|
||||
StrikeType.SlowTime => EventType.SlowTimeStrike,
|
||||
};
|
||||
|
||||
dynamic data;
|
||||
|
||||
if (strikeType is StrikeType.FailedImport)
|
||||
{
|
||||
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
|
||||
data = new
|
||||
{
|
||||
hash,
|
||||
itemName,
|
||||
strikeCount,
|
||||
strikeType,
|
||||
failedImportReasons = record.StatusMessages ?? [],
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
data = new
|
||||
{
|
||||
hash,
|
||||
itemName,
|
||||
strikeCount,
|
||||
strikeType,
|
||||
};
|
||||
}
|
||||
|
||||
// Publish the event
|
||||
await PublishAsync(
|
||||
eventType,
|
||||
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
|
||||
EventSeverity.Important,
|
||||
data: new { hash, itemName, strikeCount, strikeType });
|
||||
data: data);
|
||||
|
||||
// Send notification (uses ContextProvider internally)
|
||||
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);
|
||||
|
||||
@@ -7,7 +7,6 @@ using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
@@ -66,7 +65,7 @@ public abstract class ArrClient : IArrClient
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes)
|
||||
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
|
||||
|
||||
@@ -106,6 +105,12 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : queueCleanerConfig.FailedImport.MaxStrikes;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Item {title} has failed import status with the following reason(s):\n{messages}",
|
||||
record.Title,
|
||||
string.Join("\n", record.StatusMessages?.Select(JsonConvert.SerializeObject) ?? [])
|
||||
);
|
||||
|
||||
return await _striker.StrikeAndCheckLimit(
|
||||
record.DownloadId,
|
||||
record.Title,
|
||||
@@ -207,7 +212,7 @@ public abstract class ArrClient : IArrClient
|
||||
return response;
|
||||
}
|
||||
|
||||
private bool HasIgnoredPatterns(QueueRecord record)
|
||||
private static bool HasIgnoredPatterns(QueueRecord record)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
|
||||
|
||||
|
||||
@@ -9,18 +9,21 @@ public sealed class ArrClientFactory
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ILidarrClient _lidarrClient;
|
||||
private readonly IReadarrClient _readarrClient;
|
||||
private readonly IWhisparrClient _whisparrClient;
|
||||
|
||||
public ArrClientFactory(
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
LidarrClient lidarrClient,
|
||||
ReadarrClient readarrClient
|
||||
ReadarrClient readarrClient,
|
||||
WhisparrClient whisparrClient
|
||||
)
|
||||
{
|
||||
_sonarrClient = sonarrClient;
|
||||
_radarrClient = radarrClient;
|
||||
_lidarrClient = lidarrClient;
|
||||
_readarrClient = readarrClient;
|
||||
_whisparrClient = whisparrClient;
|
||||
}
|
||||
|
||||
public IArrClient GetClient(InstanceType type) =>
|
||||
@@ -30,6 +33,7 @@ public sealed class ArrClientFactory
|
||||
InstanceType.Radarr => _radarrClient,
|
||||
InstanceType.Lidarr => _lidarrClient,
|
||||
InstanceType.Readarr => _readarrClient,
|
||||
InstanceType.Whisparr => _whisparrClient,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
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;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
@@ -10,7 +9,7 @@ public interface IArrClient
|
||||
{
|
||||
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
|
||||
|
||||
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes);
|
||||
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
|
||||
|
||||
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface IWhisparrClient : IArrClient
|
||||
{
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Sonarr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
@@ -6,7 +7,6 @@ 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;
|
||||
@@ -58,7 +58,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
|
||||
|
||||
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
|
||||
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
|
||||
{
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Content = new StringContent(
|
||||
@@ -97,7 +97,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
}
|
||||
|
||||
private static string GetSearchLog(
|
||||
SonarrSearchType searchType,
|
||||
SeriesSearchType searchType,
|
||||
Uri instanceUrl,
|
||||
SonarrCommand command,
|
||||
bool success,
|
||||
@@ -108,22 +108,22 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
|
||||
return searchType switch
|
||||
{
|
||||
SonarrSearchType.Episode =>
|
||||
SeriesSearchType.Episode =>
|
||||
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
|
||||
SonarrSearchType.Season =>
|
||||
SeriesSearchType.Season =>
|
||||
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}",
|
||||
SonarrSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
|
||||
SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SonarrSearchType searchType)
|
||||
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SeriesSearchType searchType)
|
||||
{
|
||||
try
|
||||
{
|
||||
StringBuilder log = new();
|
||||
|
||||
if (searchType is SonarrSearchType.Episode)
|
||||
if (searchType is SeriesSearchType.Episode)
|
||||
{
|
||||
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
|
||||
|
||||
@@ -165,7 +165,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
}
|
||||
}
|
||||
|
||||
if (searchType is SonarrSearchType.Season)
|
||||
if (searchType is SeriesSearchType.Season)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
@@ -177,7 +177,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
log.Append($"[{show.Title} season {command.SeasonNumber}]");
|
||||
}
|
||||
|
||||
if (searchType is SonarrSearchType.Series)
|
||||
if (searchType is SeriesSearchType.Series)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
@@ -230,7 +230,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
return JsonConvert.DeserializeObject<Series>(responseBody);
|
||||
}
|
||||
|
||||
private List<SonarrCommand> GetSearchCommands(HashSet<SonarrSearchItem> items)
|
||||
private List<SonarrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
|
||||
{
|
||||
const string episodeSearch = "EpisodeSearch";
|
||||
const string seasonSearch = "SeasonSearch";
|
||||
@@ -238,29 +238,29 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
|
||||
List<SonarrCommand> commands = new();
|
||||
|
||||
foreach (SonarrSearchItem item in items)
|
||||
foreach (SeriesSearchItem item in items)
|
||||
{
|
||||
SonarrCommand command = item.SearchType is SonarrSearchType.Episode
|
||||
SonarrCommand command = item.SearchType is SeriesSearchType.Episode
|
||||
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
|
||||
: new();
|
||||
|
||||
switch (item.SearchType)
|
||||
{
|
||||
case SonarrSearchType.Episode when command.EpisodeIds is null:
|
||||
case SeriesSearchType.Episode when command.EpisodeIds is null:
|
||||
command.EpisodeIds = [item.Id];
|
||||
break;
|
||||
|
||||
case SonarrSearchType.Episode when command.EpisodeIds is not null:
|
||||
case SeriesSearchType.Episode when command.EpisodeIds is not null:
|
||||
command.EpisodeIds.Add(item.Id);
|
||||
break;
|
||||
|
||||
case SonarrSearchType.Season:
|
||||
case SeriesSearchType.Season:
|
||||
command.Name = seasonSearch;
|
||||
command.SeasonNumber = item.Id;
|
||||
command.SeriesId = ((SonarrSearchItem)item).SeriesId;
|
||||
command.SeriesId = ((SeriesSearchItem)item).SeriesId;
|
||||
break;
|
||||
|
||||
case SonarrSearchType.Series:
|
||||
case SeriesSearchType.Series:
|
||||
command.Name = seriesSearch;
|
||||
command.SeriesId = item.Id;
|
||||
break;
|
||||
@@ -269,7 +269,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
|
||||
}
|
||||
|
||||
if (item.SearchType is SonarrSearchType.Episode && commands.Count > 0)
|
||||
if (item.SearchType is SeriesSearchType.Episode && commands.Count > 0)
|
||||
{
|
||||
// only one command will be generated for episodes search
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Sonarr;
|
||||
using Cleanuparr.Domain.Entities.Whisparr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr;
|
||||
|
||||
public class WhisparrClient : ArrClient, IWhisparrClient
|
||||
{
|
||||
public WhisparrClient(
|
||||
ILogger<WhisparrClient> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IStriker striker,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlPath()
|
||||
{
|
||||
return "/api/v3/queue";
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlQuery(int page)
|
||||
{
|
||||
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
|
||||
}
|
||||
|
||||
protected override string GetQueueDeleteUrlPath(long recordId)
|
||||
{
|
||||
return $"/api/v3/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;
|
||||
}
|
||||
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
|
||||
|
||||
foreach (WhisparrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
|
||||
{
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Content = new StringContent(
|
||||
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType);
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
|
||||
response?.Dispose();
|
||||
|
||||
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, false, logContext));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.EpisodeId is 0 || record.SeriesId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
|
||||
private static string GetSearchLog(
|
||||
SeriesSearchType searchType,
|
||||
Uri instanceUrl,
|
||||
WhisparrCommand command,
|
||||
bool success,
|
||||
string? logContext
|
||||
)
|
||||
{
|
||||
string status = success ? "triggered" : "failed";
|
||||
|
||||
return searchType switch
|
||||
{
|
||||
SeriesSearchType.Episode =>
|
||||
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
|
||||
SeriesSearchType.Season =>
|
||||
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}",
|
||||
SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, WhisparrCommand command, SeriesSearchType searchType)
|
||||
{
|
||||
try
|
||||
{
|
||||
StringBuilder log = new();
|
||||
|
||||
if (searchType is SeriesSearchType.Episode)
|
||||
{
|
||||
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
|
||||
|
||||
if (episodes?.Count is null or 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var seriesIds = episodes
|
||||
.Select(x => x.SeriesId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
List<Series> series = [];
|
||||
|
||||
foreach (long id in seriesIds)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, id);
|
||||
|
||||
if (show is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
series.Add(show);
|
||||
}
|
||||
|
||||
foreach (var group in command.EpisodeIds.GroupBy(id => episodes.First(x => x.Id == id).SeriesId))
|
||||
{
|
||||
var show = series.First(x => x.Id == group.Key);
|
||||
var episode = episodes
|
||||
.Where(ep => group.Any(x => x == ep.Id))
|
||||
.OrderBy(x => x.SeasonNumber)
|
||||
.ThenBy(x => x.EpisodeNumber)
|
||||
.Select(x => $"S{x.SeasonNumber.ToString().PadLeft(2, '0')}E{x.EpisodeNumber.ToString().PadLeft(2, '0')}")
|
||||
.ToList();
|
||||
|
||||
log.Append($"[{show.Title} {string.Join(',', episode)}]");
|
||||
}
|
||||
}
|
||||
|
||||
if (searchType is SeriesSearchType.Season)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
if (show is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
log.Append($"[{show.Title} season {command.SeasonNumber}]");
|
||||
}
|
||||
|
||||
if (searchType is SeriesSearchType.Series)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
if (show is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
log.Append($"[{show.Title}]");
|
||||
}
|
||||
|
||||
return log.ToString();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to compute log context");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
|
||||
uriBuilder.Query = $"episodeIds={string.Join(',', episodeIds)}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
HttpResponseMessage response = await SendRequestAsync(request);
|
||||
string responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
return JsonConvert.DeserializeObject<List<Episode>>(responseContent);
|
||||
}
|
||||
|
||||
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
HttpResponseMessage response = await SendRequestAsync(request);
|
||||
string responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
return JsonConvert.DeserializeObject<Series>(responseContent);
|
||||
}
|
||||
|
||||
private List<WhisparrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
|
||||
{
|
||||
const string episodeSearch = "EpisodeSearch";
|
||||
const string seasonSearch = "SeasonSearch";
|
||||
const string seriesSearch = "SeriesSearch";
|
||||
|
||||
List<WhisparrCommand> commands = new();
|
||||
|
||||
foreach (SeriesSearchItem item in items)
|
||||
{
|
||||
WhisparrCommand command = item.SearchType is SeriesSearchType.Episode
|
||||
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
|
||||
: new();
|
||||
|
||||
switch (item.SearchType)
|
||||
{
|
||||
case SeriesSearchType.Episode when command.EpisodeIds is null:
|
||||
command.EpisodeIds = [item.Id];
|
||||
break;
|
||||
|
||||
case SeriesSearchType.Episode when command.EpisodeIds is not null:
|
||||
command.EpisodeIds.Add(item.Id);
|
||||
break;
|
||||
|
||||
case SeriesSearchType.Season:
|
||||
command.Name = seasonSearch;
|
||||
command.SeasonNumber = item.Id;
|
||||
command.SeriesId = ((SeriesSearchItem)item).SeriesId;
|
||||
break;
|
||||
|
||||
case SeriesSearchType.Series:
|
||||
command.Name = seriesSearch;
|
||||
command.SeriesId = item.Id;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
|
||||
}
|
||||
|
||||
if (item.SearchType is SeriesSearchType.Episode && commands.Count > 0)
|
||||
{
|
||||
// only one command will be generated for episodes search
|
||||
continue;
|
||||
}
|
||||
|
||||
command.SearchType = item.SearchType;
|
||||
commands.Add(command);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
@@ -13,7 +14,6 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Data.Models.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -72,6 +72,9 @@ public abstract class GenericHandler : IHandler
|
||||
ContextProvider.Set(nameof(InstanceType.Readarr), await _dataContext.ArrConfigs.AsNoTracking()
|
||||
.Include(x => x.Instances)
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr));
|
||||
ContextProvider.Set(nameof(InstanceType.Whisparr), await _dataContext.ArrConfigs.AsNoTracking()
|
||||
.Include(x => x.Instances)
|
||||
.FirstAsync(x => x.Type == InstanceType.Whisparr));
|
||||
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());
|
||||
@@ -137,14 +140,14 @@ public abstract class GenericHandler : IHandler
|
||||
return;
|
||||
}
|
||||
|
||||
if (instanceType is InstanceType.Sonarr)
|
||||
if (instanceType is InstanceType.Sonarr or InstanceType.Whisparr)
|
||||
{
|
||||
QueueItemRemoveRequest<SonarrSearchItem> removeRequest = new()
|
||||
QueueItemRemoveRequest<SeriesSearchItem> removeRequest = new()
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = instance,
|
||||
Record = record,
|
||||
SearchItem = (SonarrSearchItem)GetRecordSearchItem(instanceType, record, isPack),
|
||||
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, record, isPack),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason
|
||||
};
|
||||
@@ -174,17 +177,17 @@ public abstract class GenericHandler : IHandler
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
InstanceType.Sonarr when !isPack => new SonarrSearchItem
|
||||
InstanceType.Sonarr when !isPack => new SeriesSearchItem
|
||||
{
|
||||
Id = record.EpisodeId,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Episode
|
||||
SearchType = SeriesSearchType.Episode
|
||||
},
|
||||
InstanceType.Sonarr when isPack => new SonarrSearchItem
|
||||
InstanceType.Sonarr when isPack => new SeriesSearchItem
|
||||
{
|
||||
Id = record.SeasonNumber,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Season
|
||||
SearchType = SeriesSearchType.Season
|
||||
},
|
||||
InstanceType.Radarr => new SearchItem
|
||||
{
|
||||
@@ -198,6 +201,18 @@ public abstract class GenericHandler : IHandler
|
||||
{
|
||||
Id = record.BookId
|
||||
},
|
||||
InstanceType.Whisparr when !isPack => new SeriesSearchItem
|
||||
{
|
||||
Id = record.EpisodeId,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SeriesSearchType.Episode
|
||||
},
|
||||
InstanceType.Whisparr when isPack => new SeriesSearchItem
|
||||
{
|
||||
Id = record.SeasonNumber,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SeriesSearchType.Season
|
||||
},
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
|
||||
public class AppriseException : Exception
|
||||
{
|
||||
public AppriseException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AppriseException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ public sealed record ApprisePayload
|
||||
public string Type { get; init; } = NotificationType.Info.ToString().ToLowerInvariant();
|
||||
|
||||
public string Format { get; init; } = FormatType.Text.ToString().ToLowerInvariant();
|
||||
|
||||
public string? Tags { get; init; }
|
||||
}
|
||||
|
||||
public enum NotificationType
|
||||
|
||||
@@ -3,13 +3,11 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
|
||||
public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IAppriseProxy _proxy;
|
||||
|
||||
public override string Name => "Apprise";
|
||||
@@ -17,7 +15,6 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
|
||||
public AppriseProvider(DataContext dataContext, IAppriseProxy proxy)
|
||||
: base(dataContext.AppriseConfigs)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
@@ -51,7 +48,7 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
|
||||
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
|
||||
}
|
||||
|
||||
private static ApprisePayload BuildPayload(ArrNotification notification, NotificationType notificationType)
|
||||
private ApprisePayload BuildPayload(ArrNotification notification, NotificationType notificationType)
|
||||
{
|
||||
StringBuilder body = new();
|
||||
body.AppendLine(notification.Description);
|
||||
@@ -70,12 +67,13 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
|
||||
Title = notification.Title,
|
||||
Body = body.ToString(),
|
||||
Type = notificationType.ToString().ToLowerInvariant(),
|
||||
Tags = Config.Tags,
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static ApprisePayload BuildPayload(Notification notification, NotificationType notificationType)
|
||||
private ApprisePayload BuildPayload(Notification notification, NotificationType notificationType)
|
||||
{
|
||||
StringBuilder body = new();
|
||||
body.AppendLine(notification.Description);
|
||||
@@ -91,6 +89,7 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
|
||||
Title = notification.Title,
|
||||
Body = body.ToString(),
|
||||
Type = notificationType.ToString().ToLowerInvariant(),
|
||||
Tags = Config.Tags,
|
||||
};
|
||||
|
||||
return payload;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Newtonsoft.Json;
|
||||
@@ -17,19 +18,50 @@ public sealed class AppriseProxy : IAppriseProxy
|
||||
|
||||
public async Task SendNotification(ApprisePayload payload, AppriseConfig config)
|
||||
{
|
||||
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
|
||||
try
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
});
|
||||
|
||||
UriBuilder uriBuilder = new(config.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/notify/{config.Key}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Method = HttpMethod.Post;
|
||||
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
});
|
||||
|
||||
UriBuilder uriBuilder = new(config.Url.ToString());
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/notify/{config.Key}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Method = HttpMethod.Post;
|
||||
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||
|
||||
if (!string.IsNullOrEmpty(config.Url.UserInfo))
|
||||
{
|
||||
var byteArray = Encoding.ASCII.GetBytes(config.Url.UserInfo);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray));
|
||||
}
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
if (exception.StatusCode is null)
|
||||
{
|
||||
throw new AppriseException("Unable to send notification", exception);
|
||||
}
|
||||
|
||||
switch ((int)exception.StatusCode)
|
||||
{
|
||||
case 401:
|
||||
throw new AppriseException("Unable to send notification | API key is invalid");
|
||||
case 424:
|
||||
throw new AppriseException("Your tags are not configured correctly", exception);
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
throw new AppriseException("Unable to send notification | service unavailable", exception);
|
||||
default:
|
||||
throw new AppriseException("Unable to send notification", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record CategoryChangedNotification : Notification
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record DownloadCleanedNotification : Notification
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record FailedImportStrikeNotification : ArrNotification
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public abstract record Notification
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record NotificationField
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public enum NotificationLevel
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record QueueItemDeletedNotification : ArrNotification
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record SlowStrikeNotification : ArrNotification
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record StalledStrikeNotification : ArrNotification
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Mapster;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications;
|
||||
@@ -10,7 +10,7 @@ public abstract class NotificationProvider<T> : INotificationProvider<T>
|
||||
protected readonly DbSet<T> _notificationConfig;
|
||||
protected T? _config;
|
||||
|
||||
public T Config => _config ??= _notificationConfig.First();
|
||||
public T Config => _config ??= _notificationConfig.AsNoTracking().First();
|
||||
|
||||
NotificationConfig INotificationProvider.Config => Config;
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Mapster;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -168,6 +166,7 @@ public class NotificationPublisher : INotificationPublisher
|
||||
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,
|
||||
InstanceType.Whisparr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(instanceType))
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Infrastructure.Verticals.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.Notifications;
|
||||
|
||||
637
code/backend/Cleanuparr.Persistence/Migrations/Data/20250702091059_AddWhisparr.Designer.cs
generated
Normal file
637
code/backend/Cleanuparr.Persistence/Migrations/Data/20250702091059_AddWhisparr.Designer.cs
generated
Normal file
@@ -0,0 +1,637 @@
|
||||
// <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("20250702091059_AddWhisparr")]
|
||||
partial class AddWhisparr
|
||||
{
|
||||
/// <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.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("whisparr_blocklist_path");
|
||||
|
||||
b1.Property<int>("BlocklistType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_enabled");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_content_blocker_configs");
|
||||
|
||||
b.ToTable("content_blocker_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.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 AddWhisparr : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "whisparr_blocklist_path",
|
||||
table: "content_blocker_configs",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "whisparr_blocklist_type",
|
||||
table: "content_blocker_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "whisparr_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("a7363ca9-224a-46c1-9a94-edda11fde7b2"), (short)-1, "whisparr" });
|
||||
|
||||
migrationBuilder.Sql("UPDATE content_blocker_configs SET whisparr_blocklist_type = 'blacklist' WHERE whisparr_blocklist_type = ''");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "whisparr_blocklist_path",
|
||||
table: "content_blocker_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "whisparr_blocklist_type",
|
||||
table: "content_blocker_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "whisparr_enabled",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
620
code/backend/Cleanuparr.Persistence/Migrations/Data/20250702192200_ChangeAppriseConfig.Designer.cs
generated
Normal file
620
code/backend/Cleanuparr.Persistence/Migrations/Data/20250702192200_ChangeAppriseConfig.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("20250702192200_ChangeAppriseConfig")]
|
||||
partial class ChangeAppriseConfig
|
||||
{
|
||||
/// <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>("FullUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("full_url");
|
||||
|
||||
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.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,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ChangeAppriseConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "url",
|
||||
table: "apprise_configs",
|
||||
newName: "full_url");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "full_url",
|
||||
table: "apprise_configs",
|
||||
newName: "url");
|
||||
}
|
||||
}
|
||||
}
|
||||
624
code/backend/Cleanuparr.Persistence/Migrations/Data/20250704152940_AddAppriseTags.Designer.cs
generated
Normal file
624
code/backend/Cleanuparr.Persistence/Migrations/Data/20250704152940_AddAppriseTags.Designer.cs
generated
Normal file
@@ -0,0 +1,624 @@
|
||||
// <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("20250704152940_AddAppriseTags")]
|
||||
partial class AddAppriseTags
|
||||
{
|
||||
/// <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>("FullUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("full_url");
|
||||
|
||||
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>("Tags")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tags");
|
||||
|
||||
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,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppriseTags : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "tags",
|
||||
table: "apprise_configs",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "tags",
|
||||
table: "apprise_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,23 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnName("sonarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("whisparr_blocklist_path");
|
||||
|
||||
b1.Property<int>("BlocklistType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_enabled");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_content_blocker_configs");
|
||||
|
||||
@@ -387,6 +404,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("FullUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("full_url");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("key");
|
||||
@@ -415,9 +436,9 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.Property<string>("Url")
|
||||
b.Property<string>("Tags")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
.HasColumnName("tags");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_apprise_configs");
|
||||
|
||||
@@ -28,12 +28,15 @@ public sealed record ContentBlockerConfig : IJobConfig
|
||||
|
||||
public BlocklistSettings Readarr { get; set; } = new();
|
||||
|
||||
public BlocklistSettings Whisparr { get; set; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
ValidateBlocklistSettings(Sonarr, "Sonarr");
|
||||
ValidateBlocklistSettings(Radarr, "Radarr");
|
||||
ValidateBlocklistSettings(Lidarr, "Lidarr");
|
||||
ValidateBlocklistSettings(Readarr, "Readarr");
|
||||
ValidateBlocklistSettings(Whisparr, "Whisparr");
|
||||
}
|
||||
|
||||
private static void ValidateBlocklistSettings(BlocklistSettings settings, string context)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
|
||||
public sealed record AppriseConfig : NotificationConfig
|
||||
{
|
||||
public Uri? Url { get; init; }
|
||||
public string? FullUrl { get; set; }
|
||||
|
||||
public string? Key { get; init; }
|
||||
[NotMapped]
|
||||
public Uri? Url => string.IsNullOrEmpty(FullUrl) ? null : new Uri(FullUrl, UriKind.Absolute);
|
||||
|
||||
public string? Key { get; set; }
|
||||
|
||||
public string? Tags { get; set; }
|
||||
|
||||
public override bool IsValid()
|
||||
{
|
||||
|
||||
BIN
code/frontend/public/icons/apple-touch-icon.png
Normal file
BIN
code/frontend/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
code/frontend/public/icons/icon-192x192.png
Normal file
BIN
code/frontend/public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
code/frontend/public/icons/icon-512x512.png
Normal file
BIN
code/frontend/public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"name": "Cleanuparr",
|
||||
"short_name": "Cleanuparr",
|
||||
"theme_color": "#1976d2",
|
||||
"background_color": "#fafafa",
|
||||
"display": "standalone",
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const routes: Routes = [
|
||||
{ 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: 'whisparr', loadComponent: () => import('./settings/whisparr/whisparr-settings.component').then(m => m.WhisparrSettingsComponent) },
|
||||
{ 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) },
|
||||
];
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { WhisparrConfig } from "../../shared/models/whisparr-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";
|
||||
@@ -354,6 +355,29 @@ export class ConfigurationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Whisparr configuration
|
||||
*/
|
||||
getWhisparrConfig(): Observable<WhisparrConfig> {
|
||||
return this.http.get<WhisparrConfig>(this.ApplicationPathService.buildApiUrl('/configuration/whisparr')).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error fetching Whisparr config:", error);
|
||||
return throwError(() => new Error("Failed to load Whisparr configuration"));
|
||||
})
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Update Whisparr configuration
|
||||
*/
|
||||
updateWhisparrConfig(config: {failedImportMaxStrikes: number}): Observable<any> {
|
||||
return this.http.put<any>(this.ApplicationPathService.buildApiUrl('/configuration/whisparr'), config).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error updating Whisparr config:", error);
|
||||
return throwError(() => new Error(error.error?.error || "Failed to update Whisparr configuration"));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Download Client configuration
|
||||
*/
|
||||
@@ -565,4 +589,42 @@ export class ConfigurationService {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ===== WHISPARR INSTANCE MANAGEMENT =====
|
||||
|
||||
/**
|
||||
* Create a new Whisparr instance
|
||||
*/
|
||||
createWhisparrInstance(instance: CreateArrInstanceDto): Observable<ArrInstance> {
|
||||
return this.http.post<ArrInstance>(this.ApplicationPathService.buildApiUrl('/configuration/whisparr/instances'), instance).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error creating Whisparr instance:", error);
|
||||
return throwError(() => new Error(error.error?.error || "Failed to create Whisparr instance"));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Whisparr instance by ID
|
||||
*/
|
||||
updateWhisparrInstance(id: string, instance: CreateArrInstanceDto): Observable<ArrInstance> {
|
||||
return this.http.put<ArrInstance>(this.ApplicationPathService.buildApiUrl(`/configuration/whisparr/instances/${id}`), instance).pipe(
|
||||
catchError((error) => {
|
||||
console.error(`Error updating Whisparr instance with ID ${id}:`, error);
|
||||
return throwError(() => new Error(error.error?.error || `Failed to update Whisparr instance with ID ${id}`));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Whisparr instance by ID
|
||||
*/
|
||||
deleteWhisparrInstance(id: string): Observable<void> {
|
||||
return this.http.delete<void>(this.ApplicationPathService.buildApiUrl(`/configuration/whisparr/instances/${id}`)).pipe(
|
||||
catchError((error) => {
|
||||
console.error(`Error deleting Whisparr instance with ID ${id}:`, error);
|
||||
return throwError(() => new Error(error.error?.error || `Failed to delete Whisparr instance with ID ${id}`));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class DocumentationService {
|
||||
'notifications': {
|
||||
'notifiarr.apiKey': 'notifiarr-api-key',
|
||||
'notifiarr.channelId': 'notifiarr-channel-id',
|
||||
'apprise.url': 'apprise-url',
|
||||
'apprise.fullUrl': 'apprise-url',
|
||||
'apprise.key': 'apprise-key',
|
||||
'eventTriggers': 'event-triggers'
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
</div>
|
||||
<span>Readarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/whisparr']" class="nav-item" [class.active]="router.url.includes('/whisparr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-lock"></i>
|
||||
</div>
|
||||
<span>Whisparr</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>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="contentBlockerForm.get('cronExpression')?.hasError('required') && contentBlockerForm.get('cronExpression')?.touched" class="p-error">Cron expression is required</small>
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,6 +414,76 @@
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
|
||||
<!-- Whisparr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="4">
|
||||
<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>
|
||||
Whisparr Settings
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="whisparr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
(click)="openFieldDocs('whisparr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Whisparr Blocklist
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, the Whisparr 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('whisparr.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('whisparr', 'blocklistPath', 'required')" class="p-error">Path is required when Whisparr 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('whisparr.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 -->
|
||||
|
||||
@@ -154,6 +154,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
blocklistPath: [{ value: "", disabled: true }],
|
||||
blocklistType: [{ value: BlocklistType.Blacklist, disabled: true }],
|
||||
}),
|
||||
whisparr: 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
|
||||
@@ -175,6 +180,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
radarr: config.radarr,
|
||||
lidarr: config.lidarr,
|
||||
readarr: config.readarr,
|
||||
whisparr: config.whisparr,
|
||||
});
|
||||
|
||||
// Update all form control states
|
||||
@@ -284,7 +290,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
}
|
||||
|
||||
// Listen for changes to blocklist enabled states
|
||||
['sonarr', 'radarr', 'lidarr', 'readarr'].forEach(arrType => {
|
||||
['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr'].forEach(arrType => {
|
||||
const enabledControl = this.contentBlockerForm.get(`${arrType}.enabled`);
|
||||
|
||||
if (enabledControl) {
|
||||
@@ -355,6 +361,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
this.updateBlocklistDependentControls('radarr', config.radarr?.enabled || false);
|
||||
this.updateBlocklistDependentControls('lidarr', config.lidarr?.enabled || false);
|
||||
this.updateBlocklistDependentControls('readarr', config.readarr?.enabled || false);
|
||||
this.updateBlocklistDependentControls('whisparr', config.whisparr?.enabled || false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,17 +422,20 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
this.contentBlockerForm.get("radarr.enabled")?.enable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("lidarr.enabled")?.enable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("readarr.enabled")?.enable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("whisparr.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;
|
||||
const whisparrEnabled = this.contentBlockerForm.get("whisparr.enabled")?.value || false;
|
||||
|
||||
this.updateBlocklistDependentControls('sonarr', sonarrEnabled);
|
||||
this.updateBlocklistDependentControls('radarr', radarrEnabled);
|
||||
this.updateBlocklistDependentControls('lidarr', lidarrEnabled);
|
||||
this.updateBlocklistDependentControls('readarr', readarrEnabled);
|
||||
this.updateBlocklistDependentControls('whisparr', whisparrEnabled);
|
||||
} else {
|
||||
// Disable all scheduling controls
|
||||
cronExpressionControl?.disable();
|
||||
@@ -453,6 +463,9 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
this.contentBlockerForm.get("readarr.enabled")?.disable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("readarr.blocklistPath")?.disable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("readarr.blocklistType")?.disable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("whisparr.enabled")?.disable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("whisparr.blocklistPath")?.disable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("whisparr.blocklistType")?.disable({ onlySelf: true });
|
||||
|
||||
// Save current active accordion state before clearing it
|
||||
this.activeAccordionIndices = [];
|
||||
@@ -501,6 +514,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
blocklistPath: "",
|
||||
blocklistType: BlocklistType.Blacklist,
|
||||
},
|
||||
whisparr: formValue.whisparr || {
|
||||
enabled: false,
|
||||
blocklistPath: "",
|
||||
blocklistType: BlocklistType.Blacklist,
|
||||
},
|
||||
};
|
||||
|
||||
// Save the configuration
|
||||
@@ -574,6 +592,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
blocklistPath: "",
|
||||
blocklistType: BlocklistType.Blacklist,
|
||||
},
|
||||
whisparr: {
|
||||
enabled: false,
|
||||
blocklistPath: "",
|
||||
blocklistType: BlocklistType.Blacklist,
|
||||
},
|
||||
});
|
||||
|
||||
// Manually update control states after reset
|
||||
@@ -582,6 +605,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
this.updateBlocklistDependentControls('radarr', false);
|
||||
this.updateBlocklistDependentControls('lidarr', false);
|
||||
this.updateBlocklistDependentControls('readarr', false);
|
||||
this.updateBlocklistDependentControls('whisparr', false);
|
||||
|
||||
// Mark form as dirty so the save button is enabled after reset
|
||||
this.contentBlockerForm.markAsDirty();
|
||||
@@ -605,7 +629,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
*/
|
||||
hasError(controlName: string, errorName: string): boolean {
|
||||
const control = this.contentBlockerForm.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -633,7 +657,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
}
|
||||
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="downloadCleanerForm.get('cronExpression')?.hasError('required') && downloadCleanerForm.get('cronExpression')?.touched" class="p-error">Cron expression is required</small>
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -660,14 +660,14 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
*/
|
||||
hasError(controlName: string, errorName: string): boolean {
|
||||
const control = this.downloadCleanerForm.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the form has the unlinked categories validation error
|
||||
*/
|
||||
hasUnlinkedCategoriesError(): boolean {
|
||||
return this.downloadCleanerForm.touched && this.downloadCleanerForm.hasError('unlinkedCategoriesRequired');
|
||||
return this.downloadCleanerForm.dirty && this.downloadCleanerForm.hasError('unlinkedCategoriesRequired');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -695,7 +695,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
}
|
||||
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -706,7 +706,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
if (!categoryGroup) return false;
|
||||
|
||||
const control = categoryGroup.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -715,7 +715,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
hasCategoryControlError(categoryIndex: number, controlName: string, errorName: string): boolean {
|
||||
const categoryGroup = this.categoriesFormArray.at(categoryIndex);
|
||||
const control = categoryGroup.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -723,7 +723,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
*/
|
||||
hasCategoryGroupError(categoryIndex: number, errorName: string): boolean {
|
||||
const categoryGroup = this.categoriesFormArray.at(categoryIndex);
|
||||
return categoryGroup ? categoryGroup.touched && categoryGroup.hasError(errorName) : false;
|
||||
return categoryGroup ? categoryGroup.dirty && categoryGroup.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -156,7 +156,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
*/
|
||||
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
|
||||
const control = form.get(controlName);
|
||||
return control !== null && control.hasError(errorName) && control.touched;
|
||||
return control !== null && control.hasError(errorName) && control.dirty;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -355,7 +355,7 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
*/
|
||||
hasError(controlName: string, errorName: string): boolean {
|
||||
const control = this.generalForm.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<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>
|
||||
|
||||
@@ -82,7 +82,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
|
||||
constructor() {
|
||||
// Initialize forms
|
||||
this.globalForm = this.formBuilder.group({
|
||||
failedImportMaxStrikes: [-1],
|
||||
failedImportMaxStrikes: [-1, [Validators.required, Validators.min(-1), Validators.max(5000)]],
|
||||
});
|
||||
|
||||
this.instanceForm = this.formBuilder.group({
|
||||
@@ -211,11 +211,31 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form control has an error
|
||||
* Check if a form control has an error after it's been touched
|
||||
*/
|
||||
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
|
||||
const control = form.get(controlName);
|
||||
return control !== null && control.hasError(errorName) && control.touched;
|
||||
hasError(formOrControlName: FormGroup | string, controlNameOrErrorName: string, errorName?: string): boolean {
|
||||
if (formOrControlName instanceof FormGroup) {
|
||||
// For instance form
|
||||
const control = formOrControlName.get(controlNameOrErrorName);
|
||||
return control !== null && control.hasError(errorName!) && control.dirty;
|
||||
} else {
|
||||
// For global form
|
||||
const control = this.globalForm.get(formOrControlName);
|
||||
return control ? control.dirty && control.hasError(controlNameOrErrorName) : false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested form control errors
|
||||
*/
|
||||
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
|
||||
const parentControl = this.globalForm.get(parentName);
|
||||
if (!parentControl || !(parentControl instanceof FormGroup)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -406,8 +426,6 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get modal title based on mode
|
||||
*/
|
||||
|
||||
@@ -53,10 +53,7 @@
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Channel ID
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
(click)="openFieldDocs('notifiarr.channelId')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="channelId" inputId="notifiarrChannelId" placeholder="Enter channel ID" />
|
||||
@@ -114,13 +111,13 @@
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
(click)="openFieldDocs('apprise.url')"
|
||||
(click)="openFieldDocs('apprise.fullUrl')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
URL
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="url" inputId="appriseUrl" placeholder="Enter Apprise URL" />
|
||||
<input type="text" pInputText formControlName="fullUrl" inputId="appriseUrl" placeholder="Enter Apprise URL" />
|
||||
<small class="form-helper-text">The Apprise server URL</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,6 +136,21 @@
|
||||
<small class="form-helper-text">The key of your Apprise config</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
(click)="openFieldDocs('apprise.tags')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Tags
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="tags" inputId="appriseTags" placeholder="Enter tags (comma or space separated)" />
|
||||
<small class="form-helper-text">Optionally notify only those tagged accordingly. Use a comma (,) to OR your tags and a space ( ) to AND them.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Triggers -->
|
||||
<div class="field-row">
|
||||
|
||||
@@ -84,8 +84,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
}),
|
||||
// Apprise configuration
|
||||
apprise: this.formBuilder.group({
|
||||
url: [''],
|
||||
fullUrl: [''],
|
||||
key: [''],
|
||||
tags: [''],
|
||||
onFailedImportStrike: [false],
|
||||
onStalledStrike: [false],
|
||||
onSlowStrike: [false],
|
||||
@@ -112,8 +113,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
onCategoryChanged: false,
|
||||
},
|
||||
apprise: config.apprise || {
|
||||
url: '',
|
||||
fullUrl: '',
|
||||
key: '',
|
||||
tags: '',
|
||||
onFailedImportStrike: false,
|
||||
onStalledStrike: false,
|
||||
onSlowStrike: false,
|
||||
@@ -268,8 +270,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
onCategoryChanged: false,
|
||||
},
|
||||
apprise: {
|
||||
url: '',
|
||||
fullUrl: '',
|
||||
key: '',
|
||||
tags: '',
|
||||
onFailedImportStrike: false,
|
||||
onStalledStrike: false,
|
||||
onSlowStrike: false,
|
||||
@@ -311,7 +314,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
*/
|
||||
hasError(controlName: string, errorName: string): boolean {
|
||||
const control = this.notificationForm.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,7 +322,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
*/
|
||||
hasNestedError(groupName: string, controlName: string, errorName: string): boolean {
|
||||
const control = this.notificationForm.get(`${groupName}.${controlName}`);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="queueCleanerForm.get('cronExpression')?.hasError('required') && queueCleanerForm.get('cronExpression')?.touched" class="p-error">Cron expression is required</small>
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,15 +128,18 @@
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="maxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
[max]="10"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="maxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text"
|
||||
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
|
||||
>
|
||||
@@ -232,7 +235,7 @@
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('stalled', 'maxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('stalled', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 100</small>
|
||||
<small *ngIf="hasNestedError('stalled', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text"
|
||||
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
|
||||
>
|
||||
@@ -311,7 +314,7 @@
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('stalled', 'downloadingMetadataMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('stalled', 'downloadingMetadataMaxStrikes', 'max')" class="p-error">Value cannot exceed 100</small>
|
||||
<small *ngIf="hasNestedError('stalled', 'downloadingMetadataMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text"
|
||||
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
|
||||
>
|
||||
@@ -351,7 +354,7 @@
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('slow', 'maxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('slow', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 100</small>
|
||||
<small *ngIf="hasNestedError('slow', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text"
|
||||
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
|
||||
>
|
||||
@@ -422,16 +425,18 @@
|
||||
title="Click for documentation"></i>
|
||||
Maximum Time (hours)
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="maxTime"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="maxTime"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('slow', 'maxTime', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('slow', 'maxTime', 'max')" class="p-error">Value cannot exceed 168</small>
|
||||
<small *ngIf="hasNestedError('slow', 'maxTime', 'max')" class="p-error">Value cannot exceed 1000</small>
|
||||
<small class="form-helper-text">Maximum time allowed for slow downloads (0 means disabled)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +142,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
|
||||
// Failed Import settings - nested group
|
||||
failedImport: this.formBuilder.group({
|
||||
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
|
||||
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
|
||||
ignorePrivate: [{ value: false, disabled: true }],
|
||||
deletePrivate: [{ value: false, disabled: true }],
|
||||
ignoredPatterns: [{ value: [], disabled: true }],
|
||||
@@ -150,21 +150,21 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
|
||||
// Stalled settings - nested group
|
||||
stalled: this.formBuilder.group({
|
||||
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
|
||||
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
|
||||
resetStrikesOnProgress: [{ value: false, disabled: true }],
|
||||
ignorePrivate: [{ value: false, disabled: true }],
|
||||
deletePrivate: [{ value: false, disabled: true }],
|
||||
downloadingMetadataMaxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
|
||||
downloadingMetadataMaxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
|
||||
}),
|
||||
|
||||
// Slow Download settings - nested group
|
||||
slow: this.formBuilder.group({
|
||||
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
|
||||
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
|
||||
resetStrikesOnProgress: [{ value: false, disabled: true }],
|
||||
ignorePrivate: [{ value: false, disabled: true }],
|
||||
deletePrivate: [{ value: false, disabled: true }],
|
||||
minSpeed: [{ value: "", disabled: true }],
|
||||
maxTime: [{ value: 0, disabled: true }, [Validators.required, Validators.min(0), Validators.max(168)]],
|
||||
maxTime: [{ value: 0, disabled: true }, [Validators.required, Validators.min(0), Validators.max(1000)]],
|
||||
ignoreAboveSize: [{ value: "", disabled: true }],
|
||||
}),
|
||||
|
||||
@@ -675,7 +675,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
*/
|
||||
hasError(controlName: string, errorName: string): boolean {
|
||||
const control = this.queueCleanerForm.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -703,7 +703,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
}
|
||||
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.touched && control.hasError(errorName) : false;
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<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>
|
||||
|
||||
@@ -82,7 +82,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
|
||||
constructor() {
|
||||
// Initialize forms
|
||||
this.globalForm = this.formBuilder.group({
|
||||
failedImportMaxStrikes: [-1],
|
||||
failedImportMaxStrikes: [-1, [Validators.required, Validators.min(-1), Validators.max(5000)]],
|
||||
});
|
||||
|
||||
this.instanceForm = this.formBuilder.group({
|
||||
@@ -211,11 +211,31 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form control has an error
|
||||
* Check if a form control has an error after it's been touched
|
||||
*/
|
||||
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
|
||||
const control = form.get(controlName);
|
||||
return control !== null && control.hasError(errorName) && control.touched;
|
||||
hasError(formOrControlName: FormGroup | string, controlNameOrErrorName: string, errorName?: string): boolean {
|
||||
if (formOrControlName instanceof FormGroup) {
|
||||
// For instance form
|
||||
const control = formOrControlName.get(controlNameOrErrorName);
|
||||
return control !== null && control.hasError(errorName!) && control.dirty;
|
||||
} else {
|
||||
// For global form
|
||||
const control = this.globalForm.get(formOrControlName);
|
||||
return control ? control.dirty && control.hasError(controlNameOrErrorName) : false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested form control errors
|
||||
*/
|
||||
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
|
||||
const parentControl = this.globalForm.get(parentName);
|
||||
if (!parentControl || !(parentControl instanceof FormGroup)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -406,8 +426,6 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get modal title based on mode
|
||||
*/
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<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>
|
||||
|
||||
@@ -82,7 +82,7 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
constructor() {
|
||||
// Initialize forms
|
||||
this.globalForm = this.formBuilder.group({
|
||||
failedImportMaxStrikes: [-1],
|
||||
failedImportMaxStrikes: [-1, [Validators.required, Validators.min(-1), Validators.max(5000)]],
|
||||
});
|
||||
|
||||
this.instanceForm = this.formBuilder.group({
|
||||
@@ -211,11 +211,18 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form control has an error
|
||||
* Check if a form control has an error after it's been touched
|
||||
*/
|
||||
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
|
||||
const control = form.get(controlName);
|
||||
return control !== null && control.hasError(errorName) && control.touched;
|
||||
hasError(formOrControlName: FormGroup | string, controlNameOrErrorName: string, errorName?: string): boolean {
|
||||
if (formOrControlName instanceof FormGroup) {
|
||||
// For instance form
|
||||
const control = formOrControlName.get(controlNameOrErrorName);
|
||||
return control !== null && control.hasError(errorName!) && control.dirty;
|
||||
} else {
|
||||
// For global form
|
||||
const control = this.globalForm.get(formOrControlName);
|
||||
return control ? control.dirty && control.hasError(controlNameOrErrorName) : false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,4 +419,17 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
get modalTitle(): string {
|
||||
return this.modalMode === 'add' ? 'Add Readarr Instance' : 'Edit Readarr Instance';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested form control errors
|
||||
*/
|
||||
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
|
||||
const parentControl = this.globalForm.get(parentName);
|
||||
if (!parentControl || !(parentControl instanceof FormGroup)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,9 @@
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<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>
|
||||
|
||||
@@ -82,7 +82,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
|
||||
constructor() {
|
||||
// Initialize forms
|
||||
this.globalForm = this.formBuilder.group({
|
||||
failedImportMaxStrikes: [-1],
|
||||
failedImportMaxStrikes: [-1, [Validators.required, Validators.min(-1), Validators.max(5000)]],
|
||||
});
|
||||
|
||||
this.instanceForm = this.formBuilder.group({
|
||||
@@ -211,11 +211,31 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form control has an error
|
||||
* Check if a form control has an error after it's been touched
|
||||
*/
|
||||
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
|
||||
const control = form.get(controlName);
|
||||
return control !== null && control.hasError(errorName) && control.touched;
|
||||
hasError(formOrControlName: FormGroup | string, controlNameOrErrorName: string, errorName?: string): boolean {
|
||||
if (formOrControlName instanceof FormGroup) {
|
||||
// For instance form
|
||||
const control = formOrControlName.get(controlNameOrErrorName);
|
||||
return control !== null && control.hasError(errorName!) && control.dirty;
|
||||
} else {
|
||||
// For global form
|
||||
const control = this.globalForm.get(formOrControlName);
|
||||
return control ? control.dirty && control.hasError(controlNameOrErrorName) : false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested form control errors
|
||||
*/
|
||||
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
|
||||
const parentControl = this.globalForm.get(parentName);
|
||||
if (!parentControl || !(parentControl instanceof FormGroup)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -406,8 +426,6 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get modal title based on mode
|
||||
*/
|
||||
|
||||
365
code/frontend/src/app/settings/whisparr/whisparr-config.store.ts
Normal file
365
code/frontend/src/app/settings/whisparr/whisparr-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 { WhisparrConfig } from '../../shared/models/whisparr-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 WhisparrConfigState {
|
||||
config: WhisparrConfig | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
instanceOperations: number;
|
||||
}
|
||||
|
||||
const initialState: WhisparrConfigState = {
|
||||
config: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
instanceOperations: 0
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WhisparrConfigStore extends signalStore(
|
||||
withState(initialState),
|
||||
withMethods((store, configService = inject(ConfigurationService)) => ({
|
||||
|
||||
/**
|
||||
* Load the Whisparr configuration
|
||||
*/
|
||||
loadConfig: rxMethod<void>(
|
||||
pipe => pipe.pipe(
|
||||
tap(() => patchState(store, { loading: true, error: null })),
|
||||
switchMap(() => configService.getWhisparrConfig().pipe(
|
||||
tap({
|
||||
next: (config) => patchState(store, { config, loading: false }),
|
||||
error: (error) => {
|
||||
patchState(store, {
|
||||
loading: false,
|
||||
error: error.message || 'Failed to load Whisparr configuration'
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError(() => EMPTY)
|
||||
))
|
||||
)
|
||||
),
|
||||
|
||||
/**
|
||||
* Save the Whisparr global configuration
|
||||
*/
|
||||
saveConfig: rxMethod<{failedImportMaxStrikes: number}>(
|
||||
(globalConfig$: Observable<{failedImportMaxStrikes: number}>) => globalConfig$.pipe(
|
||||
tap(() => patchState(store, { saving: true, error: null })),
|
||||
switchMap(globalConfig => configService.updateWhisparrConfig(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 Whisparr configuration'
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError(() => EMPTY)
|
||||
))
|
||||
)
|
||||
),
|
||||
|
||||
/**
|
||||
* Save the Whisparr configuration
|
||||
*/
|
||||
saveFullConfig: rxMethod<WhisparrConfig>(
|
||||
(config$: Observable<WhisparrConfig>) => config$.pipe(
|
||||
tap(() => patchState(store, { saving: true, error: null })),
|
||||
switchMap(config => configService.updateWhisparrConfig(config).pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
patchState(store, {
|
||||
config,
|
||||
saving: false
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
patchState(store, {
|
||||
saving: false,
|
||||
error: error.message || 'Failed to save Whisparr configuration'
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError(() => EMPTY)
|
||||
))
|
||||
)
|
||||
),
|
||||
|
||||
/**
|
||||
* Update config in the store without saving to the backend
|
||||
*/
|
||||
updateConfigLocally(config: Partial<WhisparrConfig>) {
|
||||
const currentConfig = store.config();
|
||||
if (currentConfig) {
|
||||
patchState(store, {
|
||||
config: { ...currentConfig, ...config }
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset any errors
|
||||
*/
|
||||
resetError() {
|
||||
patchState(store, { error: null });
|
||||
},
|
||||
|
||||
// ===== INSTANCE MANAGEMENT =====
|
||||
|
||||
/**
|
||||
* Create a new Whisparr instance
|
||||
*/
|
||||
createInstance: rxMethod<CreateArrInstanceDto>(
|
||||
(instance$: Observable<CreateArrInstanceDto>) => instance$.pipe(
|
||||
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
|
||||
switchMap(instance => configService.createWhisparrInstance(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 Whisparr instance'
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError(() => EMPTY)
|
||||
))
|
||||
)
|
||||
),
|
||||
|
||||
/**
|
||||
* Update a Whisparr 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.updateWhisparrInstance(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 Whisparr instance with ID ${id}`
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError(() => EMPTY)
|
||||
))
|
||||
)
|
||||
),
|
||||
|
||||
/**
|
||||
* Delete a Whisparr 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.deleteWhisparrInstance(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 Whisparr instance with ID ${id}`
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError(() => EMPTY)
|
||||
))
|
||||
)
|
||||
),
|
||||
|
||||
/**
|
||||
* Save config and then process instance operations sequentially
|
||||
*/
|
||||
saveConfigAndInstances: rxMethod<{
|
||||
config: WhisparrConfig,
|
||||
instanceOperations: {
|
||||
creates: CreateArrInstanceDto[],
|
||||
updates: Array<{ id: string, instance: CreateArrInstanceDto }>,
|
||||
deletes: string[]
|
||||
}
|
||||
}>(
|
||||
(params$: Observable<{
|
||||
config: WhisparrConfig,
|
||||
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.updateWhisparrConfig(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.createWhisparrInstance(instance).pipe(
|
||||
catchError(error => {
|
||||
console.error('Failed to create Whisparr instance:', error);
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const updateOps = updates.map(({ id, instance }) =>
|
||||
configService.updateWhisparrInstance(id, instance).pipe(
|
||||
catchError(error => {
|
||||
console.error('Failed to update Whisparr instance:', error);
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const deleteOps = deletes.map(id =>
|
||||
configService.deleteWhisparrInstance(id).pipe(
|
||||
catchError(error => {
|
||||
console.error('Failed to delete Whisparr 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 Whisparr configuration'
|
||||
});
|
||||
return EMPTY;
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
})),
|
||||
withHooks({
|
||||
onInit({ loadConfig }) {
|
||||
loadConfig();
|
||||
}
|
||||
})
|
||||
) {}
|
||||
@@ -0,0 +1,233 @@
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Whisparr</h1>
|
||||
</div>
|
||||
|
||||
<!-- Loading/Error State Component -->
|
||||
<div class="mb-4">
|
||||
<app-loading-error-state
|
||||
*ngIf="whisparrLoading() || whisparrError()"
|
||||
[loading]="whisparrLoading()"
|
||||
[error]="whisparrError()"
|
||||
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="!whisparrLoading() && !whisparrError()">
|
||||
|
||||
<!-- 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">Whisparr Settings</h2>
|
||||
<span class="card-subtitle">Configure general Whisparr 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 *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<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 || whisparrSaving()"
|
||||
[loading]="whisparrSaving()"
|
||||
(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 Whisparr 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 Whisparr instances configured</p>
|
||||
<small>Add an instance to start using Whisparr 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]="whisparrSaving()"
|
||||
(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]="whisparrSaving()"
|
||||
(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]="whisparrSaving()"
|
||||
(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 Whisparr instance</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="instance-name">Name *</label>
|
||||
<input
|
||||
id="instance-name"
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="name"
|
||||
placeholder="My Whisparr 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:6969"
|
||||
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 Whisparr 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 || whisparrSaving()"
|
||||
[loading]="whisparrSaving()"
|
||||
(click)="saveInstance()"
|
||||
></button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Whisparr Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -0,0 +1,435 @@
|
||||
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 { WhisparrConfigStore } from "./whisparr-config.store";
|
||||
import { CanComponentDeactivate } from "../../core/guards";
|
||||
import { WhisparrConfig } from "../../shared/models/whisparr-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-whisparr-settings",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
CardModule,
|
||||
InputTextModule,
|
||||
CheckboxModule,
|
||||
ButtonModule,
|
||||
InputNumberModule,
|
||||
ToastModule,
|
||||
DialogModule,
|
||||
ConfirmDialogModule,
|
||||
LoadingErrorStateComponent,
|
||||
],
|
||||
providers: [WhisparrConfigStore, ConfirmationService],
|
||||
templateUrl: "./whisparr-settings.component.html",
|
||||
styleUrls: ["./whisparr-settings.component.scss"],
|
||||
})
|
||||
export class WhisparrSettingsComponent 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 whisparrStore = inject(WhisparrConfigStore);
|
||||
|
||||
// Signals from store
|
||||
whisparrConfig = this.whisparrStore.config;
|
||||
whisparrLoading = this.whisparrStore.loading;
|
||||
whisparrError = this.whisparrStore.error;
|
||||
whisparrSaving = this.whisparrStore.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, [Validators.required, Validators.min(-1), Validators.max(5000)]],
|
||||
});
|
||||
|
||||
this.instanceForm = this.formBuilder.group({
|
||||
enabled: [true],
|
||||
name: ['', Validators.required],
|
||||
url: ['', [Validators.required, this.uriValidator.bind(this)]],
|
||||
apiKey: ['', Validators.required],
|
||||
});
|
||||
|
||||
// Load Whisparr config data
|
||||
this.whisparrStore.loadConfig();
|
||||
|
||||
// Setup effect to update form when config changes
|
||||
effect(() => {
|
||||
const config = this.whisparrConfig();
|
||||
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: WhisparrConfig): 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 after it's been touched
|
||||
*/
|
||||
hasError(formOrControlName: FormGroup | string, controlNameOrErrorName: string, errorName?: string): boolean {
|
||||
if (formOrControlName instanceof FormGroup) {
|
||||
// For instance form
|
||||
const control = formOrControlName.get(controlNameOrErrorName);
|
||||
return control !== null && control.hasError(errorName!) && control.dirty;
|
||||
} else {
|
||||
// For global form
|
||||
const control = this.globalForm.get(formOrControlName);
|
||||
return control ? control.dirty && control.hasError(controlNameOrErrorName) : false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested form control errors
|
||||
*/
|
||||
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
|
||||
const parentControl = this.globalForm.get(parentName);
|
||||
if (!parentControl || !(parentControl instanceof FormGroup)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the global Whisparr 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.whisparrStore.saveConfig(updatedConfig);
|
||||
|
||||
// Monitor saving completion
|
||||
this.monitorGlobalSaving();
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor global saving completion
|
||||
*/
|
||||
private monitorGlobalSaving(): void {
|
||||
const checkSavingStatus = () => {
|
||||
const saving = this.whisparrSaving();
|
||||
const error = this.whisparrError();
|
||||
|
||||
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.whisparrConfig()?.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.whisparrStore.createInstance(instanceData);
|
||||
} else if (this.editingInstance) {
|
||||
this.whisparrStore.updateInstance({
|
||||
id: this.editingInstance.id!,
|
||||
instance: instanceData
|
||||
});
|
||||
}
|
||||
|
||||
this.monitorInstanceSaving();
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor instance saving completion
|
||||
*/
|
||||
private monitorInstanceSaving(): void {
|
||||
const checkSavingStatus = () => {
|
||||
const saving = this.whisparrSaving();
|
||||
const error = this.whisparrError();
|
||||
|
||||
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.whisparrStore.deleteInstance(instance.id!);
|
||||
|
||||
// Monitor deletion
|
||||
const checkDeletionStatus = () => {
|
||||
const saving = this.whisparrSaving();
|
||||
const error = this.whisparrError();
|
||||
|
||||
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 Whisparr Instance' : 'Edit Whisparr Instance';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NotificationConfig } from './notification-config.model';
|
||||
|
||||
export interface AppriseConfig extends NotificationConfig {
|
||||
url?: string;
|
||||
fullUrl?: string;
|
||||
key?: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
@@ -42,4 +42,5 @@ export interface ContentBlockerConfig {
|
||||
radarr: BlocklistSettings;
|
||||
lidarr: BlocklistSettings;
|
||||
readarr: BlocklistSettings;
|
||||
whisparr: BlocklistSettings;
|
||||
}
|
||||
|
||||
14
code/frontend/src/app/shared/models/whisparr-config.model.ts
Normal file
14
code/frontend/src/app/shared/models/whisparr-config.model.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* WhisparrConfig model definitions for the UI
|
||||
* These models represent the structures used in the API for Whisparr configuration
|
||||
*/
|
||||
|
||||
import { ArrInstance } from "./arr-config.model";
|
||||
|
||||
/**
|
||||
* Main WhisparrConfig model representing the configuration for Whisparr integration
|
||||
*/
|
||||
export interface WhisparrConfig {
|
||||
failedImportMaxStrikes: number;
|
||||
instances: ArrInstance[];
|
||||
}
|
||||
@@ -33,9 +33,17 @@
|
||||
<link rel="icon" type="image/x-icon" href="icons/128.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
||||
<meta name="theme-color" content="#1976d2">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#1976d2">
|
||||
|
||||
<!-- iOS support -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="Cleanuparr">
|
||||
<link rel="apple-touch-icon" href="assets/icons/apple-touch-icon.png">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { APP_BASE_HREF } from '@angular/common';
|
||||
import { isDevMode } from '@angular/core';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
|
||||
async function bootstrap() {
|
||||
const basePath = (window as any)['_app_base'] || '/';
|
||||
@@ -12,7 +14,10 @@ async function bootstrap() {
|
||||
provide: APP_BASE_HREF,
|
||||
useValue: basePath
|
||||
},
|
||||
...appConfig.providers
|
||||
...appConfig.providers, provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -83,6 +83,16 @@ The key that identifies your Apprise configuration. This corresponds to a config
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
id="apprise-tags"
|
||||
title="Apprise Tags"
|
||||
icon="🏷️"
|
||||
>
|
||||
|
||||
Optionally notify only those tagged accordingly. Use a comma (,) to OR your tags and a space ( ) to AND them. More details on this can be seen in the [Apprise documentation](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging).
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
@@ -84,14 +84,14 @@ export const alternativeSupport: AlternativeSupport[] = [
|
||||
title: 'Star on GitHub',
|
||||
description: 'Give us a star on GitHub to help increase visibility and show your support.',
|
||||
icon: '⭐',
|
||||
link: 'https://github.com/Cleanupparr/Cleanupparr',
|
||||
link: 'https://github.com/Cleanuparr/Cleanuparr',
|
||||
linkText: 'Star the Repository'
|
||||
},
|
||||
{
|
||||
title: 'Report Bugs',
|
||||
description: 'Help improve Cleanuparr by reporting bugs and issues you encounter.',
|
||||
icon: '🐛',
|
||||
link: 'https://github.com/Cleanupparr/Cleanupparr/issues',
|
||||
link: 'https://github.com/Cleanuparr/Cleanuparr/issues',
|
||||
linkText: 'Report an Issue'
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user