New: Selecting multiple indexers per release profile

This commit is contained in:
Bogdan 2025-03-11 14:15:10 +02:00
parent bee7e4325f
commit 3bbc744493
12 changed files with 179 additions and 62 deletions

View file

@ -40,9 +40,9 @@ function createIndexersSelector(includeAny: boolean) {
export interface IndexerSelectInputProps {
name: string;
value: number;
value: number | number[];
includeAny?: boolean;
onChange: (change: EnhancedSelectInputChanged<number>) => void;
onChange: (change: EnhancedSelectInputChanged<number | number[]>) => void;
}
function IndexerSelectInput({

View file

@ -39,7 +39,7 @@ function EditReleaseProfileModalContent({
saveProvider,
} = useManageReleaseProfile(id ?? 0);
const { name, enabled, required, ignored, tags, excludedTags, indexerId } =
const { name, enabled, required, ignored, indexerIds, tags, excludedTags } =
item;
const wasSaving = usePrevious(isSaving);
@ -136,13 +136,12 @@ function EditReleaseProfileModalContent({
<FormInputGroup
type={inputTypes.INDEXER_SELECT}
name="indexerId"
name="indexerIds"
helpText={translate('ReleaseProfileIndexerHelpText')}
helpTextWarning={translate(
'ReleaseProfileIndexerHelpTextWarning'
)}
{...indexerId}
includeAny={true}
{...indexerIds}
onChange={handleInputChange}
/>
</FormGroup>

View file

@ -28,9 +28,9 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
enabled = true,
required = [],
ignored = [],
indexerIds = [],
tags,
excludedTags,
indexerId = 0,
tagList,
indexerList,
} = props;
@ -53,8 +53,7 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
deleteReleaseProfile();
}, [deleteReleaseProfile]);
const indexer =
indexerId !== 0 && indexerList.find((i) => i.id === indexerId);
const indexers = indexerList.filter((i) => indexerIds.includes(i.id));
return (
<Card
@ -103,11 +102,11 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
</Label>
)}
{indexer ? (
<Label kind={kinds.INFO} outline={true}>
{indexers.map((indexer) => (
<Label key={indexer.id} kind={kinds.INFO} outline={true}>
{indexer.name}
</Label>
) : null}
))}
</div>
<EditReleaseProfileModal

View file

@ -10,7 +10,7 @@ export interface ReleaseProfileModel extends ModelBase {
enabled: boolean;
required: string[];
ignored: string[];
indexerId: number;
indexerIds: number[];
tags: number[];
excludedTags: number[];
}
@ -23,7 +23,7 @@ const NEW_RELEASE_PROFILE: ReleaseProfileModel = {
enabled: true,
required: [],
ignored: [],
indexerId: 0,
indexerIds: [],
tags: [],
excludedTags: [],
};

View file

@ -0,0 +1,67 @@
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration;
[TestFixture]
public class release_profile_indexer_idsFixture : MigrationTest<release_profile_indexer_ids>
{
[Test]
public void should_convert_default_value_for_indexer_id_to_list()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Enabled = true,
Required = "[]",
Ignored = "[]",
IndexerId = 0,
Tags = "[]",
ExcludedTags = "[]",
});
});
var releaseProfiles = db.Query<ReleaseProfile224>("SELECT \"Id\", \"Name\", \"IndexerIds\" FROM \"ReleaseProfiles\"");
releaseProfiles.Should().HaveCount(1);
releaseProfiles.First().Name.Should().Be("Profile");
releaseProfiles.First().IndexerIds.Should().BeEmpty();
}
[Test]
public void should_convert_single_value_for_indexer_id_to_list()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Enabled = true,
Required = "[]",
Ignored = "[]",
IndexerId = 42,
Tags = "[]",
ExcludedTags = "[]",
});
});
var releaseProfiles = db.Query<ReleaseProfile224>("SELECT \"Id\", \"Name\", \"IndexerIds\" FROM \"ReleaseProfiles\"");
releaseProfiles.Should().HaveCount(1);
releaseProfiles.First().Name.Should().Be("Profile");
releaseProfiles.First().IndexerIds.Should().BeEquivalentTo(new List<int> { 42 });
}
}
internal class ReleaseProfile224
{
public int Id { get; set; }
public string Name { get; set; }
public List<int> IndexerIds { get; set; }
}

View file

@ -0,0 +1,59 @@
using System.Collections.Generic;
using System.Data;
using Dapper;
using FluentMigrator;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(224)]
public class release_profile_indexer_ids : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("ReleaseProfiles").AddColumn("IndexerIds").AsString().WithDefaultValue("[]");
Execute.WithConnection(MigrateIndexerIds);
Delete.Column("IndexerId").FromTable("ReleaseProfiles");
}
private void MigrateIndexerIds(IDbConnection conn, IDbTransaction tran)
{
var updated = new List<object>();
using (var cmd = conn.CreateCommand())
{
cmd.Transaction = tran;
cmd.CommandText = "SELECT \"Id\", \"IndexerId\" FROM \"ReleaseProfiles\"";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var id = reader.GetInt32(0);
var indexerId = reader.GetInt32(1);
var indexerIds = new List<int>();
if (indexerId > 0)
{
indexerIds.Add(indexerId);
}
updated.Add(new
{
Id = id,
IndexerIds = indexerIds.ToJson()
});
}
}
conn.Execute(
"UPDATE \"ReleaseProfiles\" SET \"IndexerIds\" = @IndexerIds WHERE \"Id\" = @Id",
updated,
transaction: tran);
}
}
}

View file

@ -9,7 +9,7 @@ public class ReleaseProfile : ModelBase
public bool Enabled { get; set; }
public List<string> Required { get; set; }
public List<string> Ignored { get; set; }
public int IndexerId { get; set; }
public List<int> IndexerIds { get; set; }
public HashSet<int> Tags { get; set; }
public HashSet<int> ExcludedTags { get; set; }
@ -18,9 +18,9 @@ public ReleaseProfile()
Enabled = true;
Required = new List<string>();
Ignored = new List<string>();
IndexerIds = new List<int>();
Tags = new HashSet<int>();
ExcludedTags = new HashSet<int>();
IndexerId = 0;
}
}

View file

@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Core.Profiles.Releases
@ -21,12 +20,10 @@ public interface IReleaseProfileService
public class ReleaseProfileService : IReleaseProfileService
{
private readonly IRestrictionRepository _repo;
private readonly Logger _logger;
public ReleaseProfileService(IRestrictionRepository repo, Logger logger)
public ReleaseProfileService(IRestrictionRepository repo)
{
_repo = repo;
_logger = logger;
}
public List<ReleaseProfile> All()
@ -55,7 +52,8 @@ public List<ReleaseProfile> EnabledForTags(HashSet<int> tagIds, int indexerId)
{
return AllForTags(tagIds)
.Where(r => r.Enabled)
.Where(r => r.IndexerId == indexerId || r.IndexerId == 0).ToList();
.Where(r => r.IndexerIds.Contains(indexerId) || r.IndexerIds.Empty())
.ToList();
}
public ReleaseProfile Get(int id)

View file

@ -15,57 +15,53 @@ namespace Sonarr.Api.V3.Profiles.Release
[V3ApiController]
public class ReleaseProfileController : RestController<ReleaseProfileResource>
{
private readonly IReleaseProfileService _profileService;
private readonly IIndexerFactory _indexerFactory;
private readonly ITagService _tagService;
private readonly IReleaseProfileService _releaseProfileService;
public ReleaseProfileController(IReleaseProfileService profileService, IIndexerFactory indexerFactory, ITagService tagService)
public ReleaseProfileController(IReleaseProfileService releaseProfileService, IIndexerFactory indexerFactory, ITagService tagService)
{
_profileService = profileService;
_indexerFactory = indexerFactory;
_tagService = tagService;
_releaseProfileService = releaseProfileService;
SharedValidator.RuleFor(d => d).Custom((restriction, context) =>
{
if (restriction.MapRequired().Empty() && restriction.MapIgnored().Empty())
{
context.AddFailure(nameof(ReleaseProfile.Required), "'Must contain' or 'Must not contain' is required");
context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' or 'Must not contain' is required");
}
if (restriction.MapRequired().Any(t => t.IsNullOrWhiteSpace()))
{
context.AddFailure(nameof(ReleaseProfile.Required), "'Must contain' should not contain whitespaces or an empty string");
context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' should not contain whitespaces or an empty string");
}
if (restriction.MapIgnored().Any(t => t.IsNullOrWhiteSpace()))
{
context.AddFailure(nameof(ReleaseProfile.Ignored), "'Must not contain' should not contain whitespaces or an empty string");
context.AddFailure(nameof(ReleaseProfileResource.Ignored), "'Must not contain' should not contain whitespaces or an empty string");
}
if (restriction.Enabled && restriction.IndexerId != 0 && !_indexerFactory.Exists(restriction.IndexerId))
if (restriction.Enabled && restriction.IndexerId != 0 && !indexerFactory.Exists(restriction.IndexerId))
{
context.AddFailure(nameof(ReleaseProfile.IndexerId), "Indexer does not exist");
context.AddFailure(nameof(ReleaseProfileResource.IndexerId), "Indexer does not exist");
}
});
SharedValidator.RuleFor(d => d.Tags.Intersect(d.ExcludedTags))
.Empty()
.WithName("ExcludedTags")
.WithMessage(d => $"'{string.Join(", ", _tagService.GetTags(d.Tags.Intersect(d.ExcludedTags)).Select(t => t.Label))}' cannot be in both 'Tags' and 'Excluded Tags'");
.WithMessage(d => $"'{string.Join(", ", tagService.GetTags(d.Tags.Intersect(d.ExcludedTags)).Select(t => t.Label))}' cannot be in both 'Tags' and 'Excluded Tags'");
}
[RestPostById]
public ActionResult<ReleaseProfileResource> Create([FromBody] ReleaseProfileResource resource)
{
var model = resource.ToModel();
model = _profileService.Add(model);
model = _releaseProfileService.Add(model);
return Created(model.Id);
}
[RestDeleteById]
public void DeleteProfile(int id)
{
_profileService.Delete(id);
_releaseProfileService.Delete(id);
}
[RestPutById]
@ -73,20 +69,20 @@ public ActionResult<ReleaseProfileResource> Update([FromBody] ReleaseProfileReso
{
var model = resource.ToModel();
_profileService.Update(model);
_releaseProfileService.Update(model);
return Accepted(model.Id);
}
protected override ReleaseProfileResource GetResourceById(int id)
{
return _profileService.Get(id).ToResource();
return _releaseProfileService.Get(id).ToResource();
}
[HttpGet]
public List<ReleaseProfileResource> GetAll()
{
return _profileService.All().ToResource();
return _releaseProfileService.All().ToResource();
}
}
}

View file

@ -42,7 +42,7 @@ public static ReleaseProfileResource ToResource(this ReleaseProfile model)
Enabled = model.Enabled,
Required = model.Required ?? new List<string>(),
Ignored = model.Ignored ?? new List<string>(),
IndexerId = model.IndexerId,
IndexerId = model.IndexerIds.FirstOrDefault(0),
Tags = new HashSet<int>(model.Tags),
ExcludedTags = new HashSet<int>(model.ExcludedTags)
};
@ -62,7 +62,7 @@ public static ReleaseProfile ToModel(this ReleaseProfileResource resource)
Enabled = resource.Enabled,
Required = resource.MapRequired(),
Ignored = resource.MapIgnored(),
IndexerId = resource.IndexerId,
IndexerIds = new List<int> { resource.IndexerId },
Tags = new HashSet<int>(resource.Tags),
ExcludedTags = new HashSet<int>(resource.ExcludedTags)
};

View file

@ -13,57 +13,56 @@ namespace Sonarr.Api.V5.Profiles.Release;
[V5ApiController]
public class ReleaseProfileController : RestController<ReleaseProfileResource>
{
private readonly IReleaseProfileService _profileService;
private readonly IIndexerFactory _indexerFactory;
private readonly ITagService _tagService;
private readonly IReleaseProfileService _releaseProfileService;
public ReleaseProfileController(IReleaseProfileService profileService, IIndexerFactory indexerFactory, ITagService tagService)
public ReleaseProfileController(IReleaseProfileService releaseProfileService, IIndexerFactory indexerFactory, ITagService tagService)
{
_profileService = profileService;
_indexerFactory = indexerFactory;
_tagService = tagService;
_releaseProfileService = releaseProfileService;
SharedValidator.RuleFor(d => d).Custom((restriction, context) =>
{
if (restriction.Required.Empty() && restriction.Ignored.Empty())
{
context.AddFailure(nameof(ReleaseProfile.Required), "'Must contain' or 'Must not contain' is required");
context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' or 'Must not contain' is required");
}
if (restriction.Required.Any(t => t.IsNullOrWhiteSpace()))
{
context.AddFailure(nameof(ReleaseProfile.Required), "'Must contain' should not contain whitespaces or an empty string");
context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' should not contain whitespaces or an empty string");
}
if (restriction.Ignored.Any(t => t.IsNullOrWhiteSpace()))
{
context.AddFailure(nameof(ReleaseProfile.Ignored), "'Must not contain' should not contain whitespaces or an empty string");
context.AddFailure(nameof(ReleaseProfileResource.Ignored), "'Must not contain' should not contain whitespaces or an empty string");
}
if (restriction.Enabled && restriction.IndexerId != 0 && !_indexerFactory.Exists(restriction.IndexerId))
if (restriction is { Enabled: true, IndexerIds.Count: > 0 })
{
context.AddFailure(nameof(ReleaseProfile.IndexerId), "Indexer does not exist");
foreach (var indexerId in restriction.IndexerIds.Where(indexerId => !indexerFactory.Exists(indexerId)))
{
context.AddFailure(nameof(ReleaseProfileResource.IndexerIds), $"Indexer does not exist: {indexerId}");
}
}
});
SharedValidator.RuleFor(d => d.Tags.Intersect(d.ExcludedTags))
.Empty()
.WithName("ExcludedTags")
.WithMessage(d => $"'{string.Join(", ", _tagService.GetTags(d.Tags.Intersect(d.ExcludedTags)).Select(t => t.Label))}' cannot be in both 'Tags' and 'Excluded Tags'");
.WithMessage(d => $"'{string.Join(", ", tagService.GetTags(d.Tags.Intersect(d.ExcludedTags)).Select(t => t.Label))}' cannot be in both 'Tags' and 'Excluded Tags'");
}
[RestPostById]
public ActionResult<ReleaseProfileResource> Create([FromBody] ReleaseProfileResource resource)
{
var model = resource.ToModel();
model = _profileService.Add(model);
model = _releaseProfileService.Add(model);
return Created(model.Id);
}
[RestDeleteById]
public ActionResult DeleteProfile(int id)
{
_profileService.Delete(id);
_releaseProfileService.Delete(id);
return NoContent();
}
@ -73,19 +72,19 @@ public ActionResult<ReleaseProfileResource> Update([FromBody] ReleaseProfileReso
{
var model = resource.ToModel();
_profileService.Update(model);
_releaseProfileService.Update(model);
return Accepted(model.Id);
}
protected override ReleaseProfileResource GetResourceById(int id)
{
return _profileService.Get(id).ToResource();
return _releaseProfileService.Get(id).ToResource();
}
[HttpGet]
public List<ReleaseProfileResource> GetAll()
{
return _profileService.All().ToResource();
return _releaseProfileService.All().ToResource();
}
}

View file

@ -9,7 +9,7 @@ public class ReleaseProfileResource : RestResource
public bool Enabled { get; set; }
public List<string> Required { get; set; } = [];
public List<string> Ignored { get; set; } = [];
public int IndexerId { get; set; }
public List<int> IndexerIds { get; set; } = [];
public HashSet<int> Tags { get; set; } = [];
public HashSet<int> ExcludedTags { get; set; } = [];
}
@ -25,7 +25,7 @@ public static ReleaseProfileResource ToResource(this ReleaseProfile model)
Enabled = model.Enabled,
Required = model.Required ?? [],
Ignored = model.Ignored ?? [],
IndexerId = model.IndexerId,
IndexerIds = model.IndexerIds ?? [],
Tags = model.Tags ?? [],
ExcludedTags = model.ExcludedTags ?? [],
};
@ -40,7 +40,7 @@ public static ReleaseProfile ToModel(this ReleaseProfileResource resource)
Enabled = resource.Enabled,
Required = resource.Required,
Ignored = resource.Ignored,
IndexerId = resource.IndexerId,
IndexerIds = resource.IndexerIds,
Tags = resource.Tags,
ExcludedTags = resource.ExcludedTags
};