1
0
mirror of https://github.com/Sonarr/Sonarr synced 2025-10-05 23:52:45 +02:00

New: Excluded Tags on Release Profile

This commit is contained in:
Tro95
2025-09-27 23:41:33 +01:00
committed by GitHub
parent cf6b21aef6
commit 6440151053
18 changed files with 219 additions and 21 deletions

View File

@@ -17,6 +17,7 @@ export interface TagDetail extends ModelBase {
indexerIds: number[];
notificationIds: number[];
restrictionIds: number[];
excludedReleaseProfileIds: number[];
seriesIds: number[];
}

View File

@@ -5,14 +5,18 @@ import { addTag } from 'Store/Actions/tagActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { InputChanged } from 'typings/inputs';
import sortByProp from 'Utilities/Array/sortByProp';
import TagInput, { TagBase } from './TagInput';
import TagInput, { TagBase, TagInputProps } from './TagInput';
interface SeriesTag extends TagBase {
id: number;
name: string;
}
export interface SeriesTagInputProps<V> {
export interface SeriesTagInputProps<V>
extends Omit<
TagInputProps<SeriesTag>,
'tags' | 'tagList' | 'onTagAdd' | 'onTagDelete' | 'onChange'
> {
name: string;
value: V;
onChange: (change: InputChanged<V>) => void;
@@ -63,6 +67,7 @@ export default function SeriesTagInput<V extends number | number[]>({
name,
value,
onChange,
...otherProps
}: SeriesTagInputProps<V>) {
const dispatch = useDispatch();
const isArray = Array.isArray(value);
@@ -135,6 +140,7 @@ export default function SeriesTagInput<V extends number | number[]>({
return (
<TagInput
{...otherProps}
name={name}
tags={tags}
tagList={tagList}

View File

@@ -1,16 +1,22 @@
import React from 'react';
import { Tag } from 'App/State/TagsAppState';
import { kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import sortByProp from 'Utilities/Array/sortByProp';
import Label from './Label';
import Label, { LabelProps } from './Label';
import styles from './TagList.css';
interface TagListProps {
tags: number[];
tagList: Tag[];
kind?: Extract<Kind, LabelProps['kind']>;
}
function TagList({ tags, tagList }: TagListProps) {
export default function TagList({
tags,
tagList,
kind = kinds.INFO,
}: TagListProps) {
const sortedTags = tags
.map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag)
@@ -20,7 +26,7 @@ function TagList({ tags, tagList }: TagListProps) {
<div className={styles.tags}>
{sortedTags.map((tag) => {
return (
<Label key={tag.id} kind={kinds.INFO}>
<Label key={tag.id} kind={kind}>
{tag.label}
</Label>
);
@@ -28,5 +34,3 @@ function TagList({ tags, tagList }: TagListProps) {
</div>
);
}
export default TagList;

View File

@@ -33,6 +33,7 @@ const newReleaseProfile: ReleaseProfile = {
required: [],
ignored: [],
tags: [],
excludedTags: [],
indexerId: 0,
};
@@ -76,7 +77,8 @@ function EditReleaseProfileModalContent({
const { item, isFetching, isSaving, error, saveError, ...otherProps } =
useSelector(createReleaseProfileSelector(id));
const { name, enabled, required, ignored, tags, indexerId } = item;
const { name, enabled, required, ignored, tags, excludedTags, indexerId } =
item;
const dispatch = useDispatch();
const previousIsSaving = usePrevious(isSaving);
@@ -202,6 +204,19 @@ function EditReleaseProfileModalContent({
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ExcludedTags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="excludedTags"
helpText={translate('ReleaseProfileExcludedTagSeriesHelpText')}
kind={kinds.DANGER}
{...excludedTags}
onChange={handleInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>

View File

@@ -28,6 +28,7 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
required = [],
ignored = [],
tags,
excludedTags,
indexerId = 0,
tagList,
indexerList,
@@ -92,6 +93,8 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
<TagList tags={tags} tagList={tagList} />
<TagList tags={excludedTags} tagList={tagList} kind={kinds.DANGER} />
<div>
{enabled ? null : (
<Label kind={kinds.DISABLED} outline={true}>

View File

@@ -61,7 +61,7 @@ export interface TagDetailsModalContentProps {
delayProfileIds: number[];
importListIds: number[];
notificationIds: number[];
restrictionIds: number[];
releaseProfileIds: number[];
indexerIds: number[];
downloadClientIds: number[];
autoTagIds: number[];
@@ -76,7 +76,7 @@ function TagDetailsModalContent({
delayProfileIds = [],
importListIds = [],
notificationIds = [],
restrictionIds = [],
releaseProfileIds = [],
indexerIds = [],
downloadClientIds = [],
autoTagIds = [],
@@ -109,7 +109,7 @@ function TagDetailsModalContent({
const releaseProfiles = useSelector(
createMatchingItemSelector(
restrictionIds,
releaseProfileIds,
(state: AppState) => state.settings.releaseProfiles.items
)
);

View File

@@ -22,6 +22,7 @@ function Tag({ id, label }: TagProps) {
importListIds = [],
notificationIds = [],
restrictionIds = [],
excludedReleaseProfileIds = [],
indexerIds = [],
downloadClientIds = [],
autoTagIds = [],
@@ -35,12 +36,17 @@ function Tag({ id, label }: TagProps) {
importListIds.length ||
notificationIds.length ||
restrictionIds.length ||
excludedReleaseProfileIds.length ||
indexerIds.length ||
downloadClientIds.length ||
autoTagIds.length ||
seriesIds.length
);
const mergedReleaseProfileIds = Array.from(
new Set([...restrictionIds, ...excludedReleaseProfileIds]).values()
);
const handleShowDetailsPress = useCallback(() => {
setIsDetailsModalOpen(true);
}, []);
@@ -95,7 +101,7 @@ function Tag({ id, label }: TagProps) {
<TagInUse
label={translate('ReleaseProfile')}
labelPlural={translate('ReleaseProfiles')}
count={restrictionIds.length}
count={mergedReleaseProfileIds.length}
/>
<TagInUse
@@ -126,7 +132,7 @@ function Tag({ id, label }: TagProps) {
delayProfileIds={delayProfileIds}
importListIds={importListIds}
notificationIds={notificationIds}
restrictionIds={restrictionIds}
releaseProfileIds={mergedReleaseProfileIds}
indexerIds={indexerIds}
downloadClientIds={downloadClientIds}
autoTagIds={autoTagIds}

View File

@@ -7,6 +7,7 @@ interface ReleaseProfile extends ModelBase {
ignored: string[];
indexerId: number;
tags: number[];
excludedTags: number[];
}
export default ReleaseProfile;

View File

@@ -0,0 +1,116 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Profiles.Releases;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Profiles
{
[TestFixture]
public class ReleaseProfileServiceFixture : CoreTest<ReleaseProfileService>
{
private List<ReleaseProfile> _releaseProfiles;
private ReleaseProfile _defaultReleaseProfile;
private ReleaseProfile _includedReleaseProfile;
private ReleaseProfile _excludedReleaseProfile;
private ReleaseProfile _includedAndExcludedReleaseProfile;
private int _providedTag;
private int _providedTagToExclude;
private int _notUsedTag;
private List<ReleaseProfile> _releaseProfilesWithoutTags;
private List<ReleaseProfile> _releaseProfilesWithProvidedTag;
private List<ReleaseProfile> _releaseProfilesWithProvidedTagOrWithoutTags;
[SetUp]
public void Setup()
{
_providedTag = 1;
_providedTagToExclude = 2;
_notUsedTag = 3;
_releaseProfiles = Builder<ReleaseProfile>.CreateListOfSize(5)
.TheFirst(1)
.With(r => r.Required = ["required_one"])
.TheNext(1)
.With(r => r.Required = ["required_two"])
.With(r => r.Tags = [_providedTag])
.TheNext(1)
.With(r => r.Required = ["required_three"])
.With(r => r.ExcludedTags = [_providedTagToExclude])
.TheNext(1)
.With(r => r.Required = ["required_four"])
.With(r => r.Tags = [_providedTag])
.With(r => r.ExcludedTags = [_providedTagToExclude])
.TheNext(1)
.With(r => r.Required = ["required_five"])
.With(r => r.Tags = [_notUsedTag])
.Build()
.ToList();
_defaultReleaseProfile = _releaseProfiles[0];
_includedReleaseProfile = _releaseProfiles[1];
_excludedReleaseProfile = _releaseProfiles[2];
_includedAndExcludedReleaseProfile = _releaseProfiles[3];
_releaseProfilesWithoutTags = [_defaultReleaseProfile, _excludedReleaseProfile];
_releaseProfilesWithProvidedTag = [_includedReleaseProfile, _includedAndExcludedReleaseProfile];
_releaseProfilesWithProvidedTagOrWithoutTags = [_defaultReleaseProfile, _includedReleaseProfile, _excludedReleaseProfile, _includedAndExcludedReleaseProfile];
Mocker.GetMock<IRestrictionRepository>()
.Setup(s => s.All())
.Returns(_releaseProfiles);
}
[Test]
public void all_for_tags_should_return_release_profiles_without_tags_by_default()
{
var releaseProfiles = Subject.AllForTags([]);
releaseProfiles.Should().Equal(_releaseProfilesWithoutTags);
}
[Test]
public void all_for_tags_should_return_release_profiles_with_provided_tag_or_without_tags()
{
var releaseProfiles = Subject.AllForTags([_providedTag]);
releaseProfiles.Should().Equal(_releaseProfilesWithProvidedTagOrWithoutTags);
}
[Test]
public void all_for_tags_should_not_return_release_profiles_with_provided_tag_excluded()
{
var releaseProfiles = Subject.AllForTags([_providedTagToExclude]);
releaseProfiles.Should().NotContain(_excludedReleaseProfile);
releaseProfiles.Should().NotContain(_includedAndExcludedReleaseProfile);
}
[Test]
public void all_for_tag_should_return_release_profiles_with_provided_tag()
{
var releaseProfiles = Subject.AllForTag(_providedTag);
releaseProfiles.Should().Equal(_releaseProfilesWithProvidedTag);
}
[Test]
public void all_should_return_all_release_profiles()
{
var releaseProfiles = Subject.All();
releaseProfiles.Should().Equal(_releaseProfiles);
}
[Test]
public void all_for_tags_should_not_return_release_profiles_with_a_provided_tag_both_included_and_excluded()
{
var releaseProfiles = Subject.AllForTags([_providedTag, _providedTagToExclude]);
releaseProfiles.Should().Equal([_defaultReleaseProfile, _includedReleaseProfile]);
}
[Test]
public void all_for_tags_should_return_matching_tags_that_are_not_excluded_tags()
{
var releaseProfiles = Subject.AllForTags([_providedTag]);
releaseProfiles.Should().Equal([_defaultReleaseProfile, _includedReleaseProfile, _excludedReleaseProfile, _includedAndExcludedReleaseProfile]);
}
}
}

View File

@@ -0,0 +1,14 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(221)]
public class add_exclusion_tags_to_release_profiles : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("ReleaseProfiles").AddColumn("ExcludedTags").AsString().NotNullable().WithDefaultValue("[]");
}
}
}

View File

@@ -690,6 +690,9 @@
"Events": "Events",
"Example": "Example",
"Exception": "Exception",
"ExcludedTags": "Excluded Tags",
"ExcludedReleaseProfile": "Excluded Release Profile",
"ExcludedReleaseProfiles": "Excluded Release Profiles",
"Existing": "Existing",
"ExistingSeries": "Existing Series",
"ExistingTag": "Existing tag",
@@ -1699,6 +1702,7 @@
"ReleaseGroups": "Release Groups",
"ReleaseHash": "Release Hash",
"ReleaseProfile": "Release Profile",
"ReleaseProfileExcludedTagSeriesHelpText": "Release profiles will not apply to series with at least one matching tag.",
"ReleaseProfileIndexerHelpText": "Specify what indexer the profile applies to",
"ReleaseProfileIndexerHelpTextWarning": "Setting a specific indexer on a release profile will cause this profile to only apply to releases from that indexer.",
"ReleaseProfileTagSeriesHelpText": "Release profiles will apply to series with at least one matching tag. Leave blank to apply to all series",

View File

@@ -11,6 +11,7 @@ namespace NzbDrone.Core.Profiles.Releases
public List<string> Ignored { get; set; }
public int IndexerId { get; set; }
public HashSet<int> Tags { get; set; }
public HashSet<int> ExcludedTags { get; set; }
public ReleaseProfile()
{
@@ -18,6 +19,7 @@ namespace NzbDrone.Core.Profiles.Releases
Required = new List<string>();
Ignored = new List<string>();
Tags = new HashSet<int>();
ExcludedTags = new HashSet<int>();
IndexerId = 0;
}
}

View File

@@ -8,6 +8,7 @@ namespace NzbDrone.Core.Profiles.Releases
public interface IReleaseProfileService
{
List<ReleaseProfile> All();
List<ReleaseProfile> AllExcludedForTag(int tagId);
List<ReleaseProfile> AllForTag(int tagId);
List<ReleaseProfile> AllForTags(HashSet<int> tagIds);
List<ReleaseProfile> EnabledForTags(HashSet<int> tagIds, int indexerId);
@@ -35,6 +36,11 @@ namespace NzbDrone.Core.Profiles.Releases
return all;
}
public List<ReleaseProfile> AllExcludedForTag(int tagId)
{
return _repo.All().Where(r => r.ExcludedTags.Contains(tagId)).ToList();
}
public List<ReleaseProfile> AllForTag(int tagId)
{
return _repo.All().Where(r => r.Tags.Contains(tagId)).ToList();
@@ -42,7 +48,7 @@ namespace NzbDrone.Core.Profiles.Releases
public List<ReleaseProfile> AllForTags(HashSet<int> tagIds)
{
return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList();
return _repo.All().Where(r => (r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()) && !r.ExcludedTags.Intersect(tagIds).Any()).ToList();
}
public List<ReleaseProfile> EnabledForTags(HashSet<int> tagIds, int indexerId)

View File

@@ -10,6 +10,7 @@ namespace NzbDrone.Core.Tags
public List<int> SeriesIds { get; set; }
public List<int> NotificationIds { get; set; }
public List<int> RestrictionIds { get; set; }
public List<int> ExcludedReleaseProfileIds { get; set; }
public List<int> DelayProfileIds { get; set; }
public List<int> ImportListIds { get; set; }
public List<int> IndexerIds { get; set; }
@@ -19,6 +20,7 @@ namespace NzbDrone.Core.Tags
public bool InUse => SeriesIds.Any() ||
NotificationIds.Any() ||
RestrictionIds.Any() ||
ExcludedReleaseProfileIds.Any() ||
DelayProfileIds.Any() ||
ImportListIds.Any() ||
IndexerIds.Any() ||

View File

@@ -91,7 +91,8 @@ namespace NzbDrone.Core.Tags
var delayProfiles = _delayProfileService.AllForTag(tagId);
var importLists = _importListFactory.AllForTag(tagId);
var notifications = _notificationFactory.AllForTag(tagId);
var restrictions = _releaseProfileService.AllForTag(tagId);
var releaseProfiles = _releaseProfileService.AllForTag(tagId);
var excludedReleaseProfiles = _releaseProfileService.AllExcludedForTag(tagId);
var series = _seriesService.AllForTag(tagId);
var indexers = _indexerService.AllForTag(tagId);
var autoTags = _autoTaggingService.AllForTag(tagId);
@@ -104,7 +105,8 @@ namespace NzbDrone.Core.Tags
DelayProfileIds = delayProfiles.Select(c => c.Id).ToList(),
ImportListIds = importLists.Select(c => c.Id).ToList(),
NotificationIds = notifications.Select(c => c.Id).ToList(),
RestrictionIds = restrictions.Select(c => c.Id).ToList(),
RestrictionIds = releaseProfiles.Select(c => c.Id).ToList(),
ExcludedReleaseProfileIds = excludedReleaseProfiles.Select(c => c.Id).ToList(),
SeriesIds = series.Select(c => c.Id).ToList(),
IndexerIds = indexers.Select(c => c.Id).ToList(),
AutoTagIds = autoTags.Select(c => c.Id).ToList(),
@@ -118,7 +120,8 @@ namespace NzbDrone.Core.Tags
var delayProfiles = _delayProfileService.All();
var importLists = _importListFactory.All();
var notifications = _notificationFactory.All();
var restrictions = _releaseProfileService.All();
var releaseProfiles = _releaseProfileService.All();
var excludedReleaseProfiles = _releaseProfileService.All();
var series = _seriesService.GetAllSeriesTags();
var indexers = _indexerService.All();
var autoTags = _autoTaggingService.All();
@@ -135,7 +138,8 @@ namespace NzbDrone.Core.Tags
DelayProfileIds = delayProfiles.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
ImportListIds = importLists.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
RestrictionIds = releaseProfiles.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
ExcludedReleaseProfileIds = excludedReleaseProfiles.Where(c => c.ExcludedTags.Contains(tag.Id)).Select(c => c.Id).ToList(),
SeriesIds = series.Where(c => c.Value.Contains(tag.Id)).Select(c => c.Key).ToList(),
IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
AutoTagIds = GetAutoTagIds(tag, autoTags),

View File

@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Profiles.Releases;
using NzbDrone.Core.Tags;
using Sonarr.Http;
using Sonarr.Http.REST;
using Sonarr.Http.REST.Attributes;
@@ -16,11 +17,13 @@ namespace Sonarr.Api.V3.Profiles.Release
{
private readonly IReleaseProfileService _profileService;
private readonly IIndexerFactory _indexerFactory;
private readonly ITagService _tagService;
public ReleaseProfileController(IReleaseProfileService profileService, IIndexerFactory indexerFactory)
public ReleaseProfileController(IReleaseProfileService profileService, IIndexerFactory indexerFactory, ITagService tagService)
{
_profileService = profileService;
_indexerFactory = indexerFactory;
_tagService = tagService;
SharedValidator.RuleFor(d => d).Custom((restriction, context) =>
{
@@ -44,6 +47,11 @@ namespace Sonarr.Api.V3.Profiles.Release
context.AddFailure(nameof(ReleaseProfile.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'");
}
[RestPostById]

View File

@@ -17,10 +17,12 @@ namespace Sonarr.Api.V3.Profiles.Release
public object Ignored { get; set; }
public int IndexerId { get; set; }
public HashSet<int> Tags { get; set; }
public HashSet<int> ExcludedTags { get; set; }
public ReleaseProfileResource()
{
Tags = new HashSet<int>();
ExcludedTags = new HashSet<int>();
}
}
@@ -41,7 +43,8 @@ namespace Sonarr.Api.V3.Profiles.Release
Required = model.Required ?? new List<string>(),
Ignored = model.Ignored ?? new List<string>(),
IndexerId = model.IndexerId,
Tags = new HashSet<int>(model.Tags)
Tags = new HashSet<int>(model.Tags),
ExcludedTags = new HashSet<int>(model.ExcludedTags)
};
}
@@ -60,7 +63,8 @@ namespace Sonarr.Api.V3.Profiles.Release
Required = resource.MapRequired(),
Ignored = resource.MapIgnored(),
IndexerId = resource.IndexerId,
Tags = new HashSet<int>(resource.Tags)
Tags = new HashSet<int>(resource.Tags),
ExcludedTags = new HashSet<int>(resource.ExcludedTags)
};
}

View File

@@ -12,6 +12,7 @@ namespace Sonarr.Api.V3.Tags
public List<int> ImportListIds { get; set; }
public List<int> NotificationIds { get; set; }
public List<int> RestrictionIds { get; set; }
public List<int> ExcludedReleaseProfileIds { get; set; }
public List<int> IndexerIds { get; set; }
public List<int> DownloadClientIds { get; set; }
public List<int> AutoTagIds { get; set; }
@@ -35,6 +36,7 @@ namespace Sonarr.Api.V3.Tags
ImportListIds = model.ImportListIds,
NotificationIds = model.NotificationIds,
RestrictionIds = model.RestrictionIds,
ExcludedReleaseProfileIds = model.ExcludedReleaseProfileIds,
IndexerIds = model.IndexerIds,
DownloadClientIds = model.DownloadClientIds,
AutoTagIds = model.AutoTagIds,