diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.css b/frontend/src/Movie/Index/Table/MovieIndexRow.css
index 6beffe0799..6b8091960c 100644
--- a/frontend/src/Movie/Index/Table/MovieIndexRow.css
+++ b/frontend/src/Movie/Index/Table/MovieIndexRow.css
@@ -43,7 +43,8 @@
.physicalRelease,
.digitalRelease,
.releaseDate,
-.genres {
+.genres,
+.keywords {
composes: cell;
flex: 0 0 180px;
diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.css.d.ts b/frontend/src/Movie/Index/Table/MovieIndexRow.css.d.ts
index 413d5226bc..441f0219d4 100644
--- a/frontend/src/Movie/Index/Table/MovieIndexRow.css.d.ts
+++ b/frontend/src/Movie/Index/Table/MovieIndexRow.css.d.ts
@@ -12,6 +12,7 @@ interface CssExports {
'genres': string;
'imdbRating': string;
'inCinemas': string;
+ 'keywords': string;
'minimumAvailability': string;
'movieStatus': string;
'originalLanguage': string;
diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.tsx b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx
index 5248526de6..d546a8c511 100644
--- a/frontend/src/Movie/Index/Table/MovieIndexRow.tsx
+++ b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx
@@ -72,6 +72,7 @@ function MovieIndexRow(props: MovieIndexRowProps) {
minimumAvailability,
path,
genres = [],
+ keywords = [],
ratings,
popularity,
certification,
@@ -339,6 +340,20 @@ function MovieIndexRow(props: MovieIndexRowProps) {
);
}
+ if (name === 'keywords') {
+ const joinedKeywords = keywords.join(', ');
+ const truncatedKeywords =
+ keywords.length > 3
+ ? `${keywords.slice(0, 3).join(', ')}...`
+ : joinedKeywords;
+
+ return (
+
+ {truncatedKeywords}
+
+ );
+ }
+
if (name === 'movieStatus') {
return (
diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css
index 3fcc249ad8..fc237d8c0c 100644
--- a/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css
+++ b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css
@@ -36,7 +36,8 @@
.physicalRelease,
.digitalRelease,
.releaseDate,
-.genres {
+.genres,
+.keywords {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 180px;
diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css.d.ts b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css.d.ts
index a182e33cd8..39890935ac 100644
--- a/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css.d.ts
+++ b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css.d.ts
@@ -9,6 +9,7 @@ interface CssExports {
'genres': string;
'imdbRating': string;
'inCinemas': string;
+ 'keywords': string;
'minimumAvailability': string;
'movieStatus': string;
'originalLanguage': string;
diff --git a/frontend/src/Movie/Movie.ts b/frontend/src/Movie/Movie.ts
index d91e104f7f..6bb9b42797 100644
--- a/frontend/src/Movie/Movie.ts
+++ b/frontend/src/Movie/Movie.ts
@@ -82,6 +82,7 @@ interface Movie extends ModelBase {
minimumAvailability: MovieAvailability;
path: string;
genres: string[];
+ keywords: string[];
ratings: Ratings;
popularity: number;
certification: string;
diff --git a/frontend/src/Store/Actions/movieIndexActions.js b/frontend/src/Store/Actions/movieIndexActions.js
index 1730ff1850..e36bee132a 100644
--- a/frontend/src/Store/Actions/movieIndexActions.js
+++ b/frontend/src/Store/Actions/movieIndexActions.js
@@ -181,6 +181,12 @@ export const defaultState = {
isSortable: false,
isVisible: false
},
+ {
+ name: 'keywords',
+ label: () => translate('Keywords'),
+ isSortable: false,
+ isVisible: false
+ },
{
name: 'movieStatus',
label: () => translate('Status'),
@@ -473,8 +479,8 @@ export const defaultState = {
label: () => translate('Genres'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function(items) {
- const genreList = items.reduce((acc, movie) => {
- movie.genres.forEach((genre) => {
+ const genreList = items.reduce((acc, { genres = [] }) => {
+ genres.forEach((genre) => {
acc.push({
id: genre,
name: genre
@@ -487,6 +493,27 @@ export const defaultState = {
return genreList.sort(sortByProp('name'));
}
},
+ {
+ name: 'keywords',
+ label: () => translate('Keywords'),
+ type: filterBuilderTypes.ARRAY,
+ optionsSelector: function(items) {
+ const keywordList = items.reduce((acc, { keywords = [] }) => {
+ keywords.forEach((keyword) => {
+ if (acc.findIndex((a) => a.id === keyword) === -1) {
+ acc.push({
+ id: keyword,
+ name: keyword
+ });
+ }
+ });
+
+ return acc;
+ }, []);
+
+ return keywordList.sort(sortByProp('name'));
+ }
+ },
{
name: 'tmdbRating',
label: () => translate('TmdbRating'),
diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs
index 77de3a541d..94ff126c91 100644
--- a/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs
+++ b/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs
@@ -20,7 +20,7 @@ public class GenreSpecification : AutoTaggingSpecificationBase
{
private static readonly GenreSpecificationValidator Validator = new ();
- public override int Order => 1;
+ public override int Order => 2;
public override string ImplementationName => "Genre";
[FieldDefinition(1, Label = "AutoTaggingSpecificationGenre", Type = FieldType.Tag)]
diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/KeywordSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/KeywordSpecification.cs
new file mode 100644
index 0000000000..1b43a5c7fe
--- /dev/null
+++ b/src/NzbDrone.Core/AutoTagging/Specifications/KeywordSpecification.cs
@@ -0,0 +1,39 @@
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Annotations;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Validation;
+
+namespace NzbDrone.Core.AutoTagging.Specifications
+{
+ public class KeywordSpecificationValidator : AbstractValidator
+ {
+ public KeywordSpecificationValidator()
+ {
+ RuleFor(c => c.Value).NotEmpty();
+ }
+ }
+
+ public class KeywordSpecification : AutoTaggingSpecificationBase
+ {
+ private static readonly KeywordSpecificationValidator Validator = new ();
+
+ public override int Order => 2;
+ public override string ImplementationName => "Keyword";
+
+ [FieldDefinition(1, Label = "AutoTaggingSpecificationKeyword", Type = FieldType.Tag)]
+ public IEnumerable Value { get; set; }
+
+ protected override bool IsSatisfiedByWithoutNegate(Movie movie)
+ {
+ return movie?.MovieMetadata?.Value?.Keywords.Any(keyword => Value.ContainsIgnoreCase(keyword)) ?? false;
+ }
+
+ public override NzbDroneValidationResult Validate()
+ {
+ return new NzbDroneValidationResult(Validator.Validate(this));
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Datastore/Migration/242_add_movie_keywords.cs b/src/NzbDrone.Core/Datastore/Migration/242_add_movie_keywords.cs
new file mode 100644
index 0000000000..d7d5704391
--- /dev/null
+++ b/src/NzbDrone.Core/Datastore/Migration/242_add_movie_keywords.cs
@@ -0,0 +1,14 @@
+using FluentMigrator;
+using NzbDrone.Core.Datastore.Migration.Framework;
+
+namespace NzbDrone.Core.Datastore.Migration
+{
+ [Migration(242)]
+ public class add_movie_keywords : NzbDroneMigrationBase
+ {
+ protected override void MainDbUpgrade()
+ {
+ Alter.Table("MovieMetadata").AddColumn("Keywords").AsString().Nullable();
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 5392603c72..95ec0dda2d 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -119,6 +119,7 @@
"AutoTaggingNegateHelpText": "If checked, the auto tagging rule will not apply if this {implementationName} condition matches.",
"AutoTaggingRequiredHelpText": "This {implementationName} condition must match for the auto tagging rule to apply. Otherwise a single {implementationName} match is sufficient.",
"AutoTaggingSpecificationGenre": "Genre(s)",
+ "AutoTaggingSpecificationKeyword": "Keyword(s)",
"AutoTaggingSpecificationMaximumRuntime": "Maximum Runtime",
"AutoTaggingSpecificationMaximumYear": "Maximum Year",
"AutoTaggingSpecificationMinimumRuntime": "Minimum Runtime",
@@ -959,6 +960,7 @@
"KeyboardShortcutsMovieIndexScrollTop": "Movie Index: Scroll Top",
"KeyboardShortcutsOpenModal": "Open This Modal",
"KeyboardShortcutsSaveSettings": "Save Settings",
+ "Keywords": "Keywords",
"Label": "Label",
"LabelIsRequired": "Label is required",
"Language": "Language",
diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MovieResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MovieResource.cs
index 1f041185b8..80973b3f42 100644
--- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MovieResource.cs
+++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MovieResource.cs
@@ -18,6 +18,7 @@ public class MovieResource
public int? Runtime { get; set; }
public List Images { get; set; }
public List Genres { get; set; }
+ public List Keywords { get; set; }
public int Year { get; set; }
public DateTime? Premier { get; set; }
diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs
index d00ff9e934..120c719659 100644
--- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs
+++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs
@@ -272,7 +272,8 @@ public MovieMetadata MapMovie(MovieResource resource)
movie.Ratings = MapRatings(resource.MovieRatings) ?? new Ratings();
movie.TmdbId = resource.TmdbId;
- movie.Genres = resource.Genres;
+ movie.Genres = resource.Genres ?? new List();
+ movie.Keywords = resource.Keywords ?? new List();
movie.Images = resource.Images.Select(MapImage).ToList();
movie.Recommendations = resource.Recommendations?.Select(r => r.TmdbId).ToList() ?? new List();
diff --git a/src/NzbDrone.Core/Movies/MovieMetadata.cs b/src/NzbDrone.Core/Movies/MovieMetadata.cs
index 6ebcc1d673..4791d773a6 100644
--- a/src/NzbDrone.Core/Movies/MovieMetadata.cs
+++ b/src/NzbDrone.Core/Movies/MovieMetadata.cs
@@ -15,6 +15,7 @@ public MovieMetadata()
Translations = new List();
Images = new List();
Genres = new List();
+ Keywords = new List();
OriginalLanguage = Language.English;
Recommendations = new List();
Ratings = new Ratings();
@@ -24,6 +25,7 @@ public MovieMetadata()
public List Images { get; set; }
public List Genres { get; set; }
+ public List Keywords { get; set; }
public DateTime? InCinemas { get; set; }
public DateTime? PhysicalRelease { get; set; }
public DateTime? DigitalRelease { get; set; }
diff --git a/src/NzbDrone.Core/Movies/RefreshMovieService.cs b/src/NzbDrone.Core/Movies/RefreshMovieService.cs
index 4787dbe27b..dea713487d 100644
--- a/src/NzbDrone.Core/Movies/RefreshMovieService.cs
+++ b/src/NzbDrone.Core/Movies/RefreshMovieService.cs
@@ -114,6 +114,7 @@ private Movie RefreshMovieInfo(int movieId)
movieMetadata.Runtime = movieInfo.Runtime;
movieMetadata.Ratings = movieInfo.Ratings;
movieMetadata.Genres = movieInfo.Genres;
+ movieMetadata.Keywords = movieInfo.Keywords;
movieMetadata.Certification = movieInfo.Certification;
movieMetadata.InCinemas = movieInfo.InCinemas;
movieMetadata.Website = movieInfo.Website;
diff --git a/src/Radarr.Api.V3/Movies/MovieResource.cs b/src/Radarr.Api.V3/Movies/MovieResource.cs
index 5ade23f883..66da6b0e8a 100644
--- a/src/Radarr.Api.V3/Movies/MovieResource.cs
+++ b/src/Radarr.Api.V3/Movies/MovieResource.cs
@@ -76,6 +76,7 @@ public MovieResource()
public string Folder { get; set; }
public string Certification { get; set; }
public List Genres { get; set; }
+ public List Keywords { get; set; }
public HashSet Tags { get; set; }
public DateTime Added { get; set; }
public AddMovieOptions AddOptions { get; set; }
@@ -153,6 +154,7 @@ public static MovieResource ToResource(this Movie model, int availDelay, MovieTr
Certification = model.MovieMetadata.Value.Certification,
Website = model.MovieMetadata.Value.Website,
Genres = model.MovieMetadata.Value.Genres,
+ Keywords = model.MovieMetadata.Value.Keywords,
Tags = model.Tags,
Added = model.Added,
AddOptions = model.AddOptions,