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,