From 2d59192a9e6bd62686738efc06825d50b1578bbb Mon Sep 17 00:00:00 2001 From: Qstick Date: Tue, 9 Jun 2020 01:49:44 -0400 Subject: [PATCH] New: Movie Discovery/Recommendations Reworked --- .../Migration/176_movie_recommendations.cs | 14 ++++ .../MetadataSource/IProvideMovieInfo.cs | 2 + .../SkyHook/Resource/MovieResource.cs | 1 + .../Resource/RecommendationResource.cs | 8 ++ .../MetadataSource/SkyHook/SkyHookProxy.cs | 27 +++++++ src/NzbDrone.Core/Movies/Movie.cs | 2 + src/NzbDrone.Core/Movies/MovieService.cs | 23 ++++++ .../Movies/RefreshMovieService.cs | 1 + .../Movies/DiscoverMoviesModule.cs | 64 +++++++++++++++ .../Movies/DiscoverMoviesResource.cs | 78 +++++++++++++++++++ 10 files changed, 220 insertions(+) create mode 100644 src/NzbDrone.Core/Datastore/Migration/176_movie_recommendations.cs create mode 100644 src/NzbDrone.Core/MetadataSource/SkyHook/Resource/RecommendationResource.cs create mode 100644 src/Radarr.Api.V3/Movies/DiscoverMoviesModule.cs create mode 100644 src/Radarr.Api.V3/Movies/DiscoverMoviesResource.cs diff --git a/src/NzbDrone.Core/Datastore/Migration/176_movie_recommendations.cs b/src/NzbDrone.Core/Datastore/Migration/176_movie_recommendations.cs new file mode 100644 index 000000000..1ec9cb4f3 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/176_movie_recommendations.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(176)] + public class movie_recommendations : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Movies").AddColumn("Recommendations").AsString().WithDefaultValue("[]"); + } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/IProvideMovieInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideMovieInfo.cs index 25c109a34..923fda3a4 100644 --- a/src/NzbDrone.Core/MetadataSource/IProvideMovieInfo.cs +++ b/src/NzbDrone.Core/MetadataSource/IProvideMovieInfo.cs @@ -9,6 +9,8 @@ namespace NzbDrone.Core.MetadataSource { Movie GetMovieByImdbId(string imdbId); Tuple> GetMovieInfo(int tmdbId); + List GetBulkMovieInfo(List tmdbIds); + HashSet GetChangedMovies(DateTime startTime); } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MovieResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MovieResource.cs index ac914dbe6..3a737a9e7 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MovieResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MovieResource.cs @@ -33,5 +33,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook.Resource public CollectionResource Collection { get; set; } public string OriginalLanguage { get; set; } public string Homepage { get; set; } + public List Recommendations { get; set; } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/RecommendationResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/RecommendationResource.cs new file mode 100644 index 000000000..633f8bbb0 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/RecommendationResource.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +{ + public class RecommendationResource + { + public int TmdbId { get; set; } + public string Name { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index fc1d21df3..0f3cc9be4 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -6,6 +6,7 @@ using NLog; using NzbDrone.Common.Cloud; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; using NzbDrone.Core.Configuration; using NzbDrone.Core.Exceptions; using NzbDrone.Core.Languages; @@ -97,6 +98,31 @@ namespace NzbDrone.Core.MetadataSource.SkyHook return new Tuple>(movie, credits.ToList()); } + public List GetBulkMovieInfo(List tmdbIds) + { + var httpRequest = _radarrMetadata.Create() + .SetSegment("route", "movie/bulk") + .Build(); + + httpRequest.Headers.ContentType = "application/json"; + + httpRequest.SetContent(tmdbIds.ToJson()); + + httpRequest.AllowAutoRedirect = true; + httpRequest.SuppressHttpError = true; + + var httpResponse = _httpClient.Post>(httpRequest); + + if (httpResponse.HasHttpError || httpResponse.Resource.Count == 0) + { + throw new HttpException(httpRequest, httpResponse); + } + + var movies = httpResponse.Resource.Select(MapMovie).ToList(); + + return movies; + } + public Movie GetMovieByImdbId(string imdbId) { var httpRequest = _radarrMetadata.Create() @@ -165,6 +191,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook movie.Certification = resource.Certifications.FirstOrDefault(m => m.Country == certificationCountry)?.Certification; movie.Ratings = resource.Ratings.Select(MapRatings).FirstOrDefault() ?? new Ratings(); movie.Genres = resource.Genres; + movie.Recommendations = resource.Recommendations.Select(r => r.TmdbId).ToList(); var now = DateTime.Now; diff --git a/src/NzbDrone.Core/Movies/Movie.cs b/src/NzbDrone.Core/Movies/Movie.cs index 01d05706a..623b6507f 100644 --- a/src/NzbDrone.Core/Movies/Movie.cs +++ b/src/NzbDrone.Core/Movies/Movie.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.Movies Genres = new List(); Tags = new HashSet(); AlternativeTitles = new List(); + Recommendations = new List(); } public int TmdbId { get; set; } @@ -59,6 +60,7 @@ namespace NzbDrone.Core.Movies public int SecondaryYearSourceId { get; set; } public string YouTubeTrailerId { get; set; } public string Studio { get; set; } + public List Recommendations { get; set; } public bool IsRecentMovie { diff --git a/src/NzbDrone.Core/Movies/MovieService.cs b/src/NzbDrone.Core/Movies/MovieService.cs index 3a979851b..d83b8a5af 100644 --- a/src/NzbDrone.Core/Movies/MovieService.cs +++ b/src/NzbDrone.Core/Movies/MovieService.cs @@ -43,6 +43,7 @@ namespace NzbDrone.Core.Movies Movie UpdateMovie(Movie movie); List UpdateMovie(List movie, bool useExistingRelativeFolder); List FilterExistingMovies(List movies); + List GetRecommendedMovies(); bool MoviePathExists(string folder); void RemoveAddOptions(Movie movie); } @@ -393,6 +394,28 @@ namespace NzbDrone.Core.Movies return ret; } + public List GetRecommendedMovies() + { + // Get all recommended movies, plus all movies on enabled lists + var netImportMovies = new List(); + + var allMovies = GetAllMovies(); + + // Ensure we only return distinct ids that do not exist in DB already, first 100 that are from latest movies add first + var distinctRecommendations = allMovies.OrderByDescending(x => x.Added) + .SelectMany(m => m.Recommendations.Select(c => c)) + .Where(r => !allMovies.Any(m => m.TmdbId == r)) + .Distinct() + .Take(100); + + foreach (var recommendation in distinctRecommendations) + { + netImportMovies.Add(new Movie { TmdbId = recommendation }); + } + + return netImportMovies; + } + public void Handle(MovieFileAddedEvent message) { var movie = message.MovieFile.Movie; diff --git a/src/NzbDrone.Core/Movies/RefreshMovieService.cs b/src/NzbDrone.Core/Movies/RefreshMovieService.cs index 4f1f1d31a..a6f16da3f 100644 --- a/src/NzbDrone.Core/Movies/RefreshMovieService.cs +++ b/src/NzbDrone.Core/Movies/RefreshMovieService.cs @@ -107,6 +107,7 @@ namespace NzbDrone.Core.Movies movie.YouTubeTrailerId = movieInfo.YouTubeTrailerId; movie.Studio = movieInfo.Studio; movie.HasPreDBEntry = movieInfo.HasPreDBEntry; + movie.Recommendations = movieInfo.Recommendations; try { diff --git a/src/Radarr.Api.V3/Movies/DiscoverMoviesModule.cs b/src/Radarr.Api.V3/Movies/DiscoverMoviesModule.cs new file mode 100644 index 000000000..2390ada8c --- /dev/null +++ b/src/Radarr.Api.V3/Movies/DiscoverMoviesModule.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Movies; +using NzbDrone.Core.NetImport.ImportExclusions; +using NzbDrone.Core.Organizer; +using Radarr.Http; + +namespace NzbDrone.Api.V3.Movies +{ + public class DiscoverMoviesModule : RadarrRestModule + { + private readonly IMovieService _movieService; + private readonly IProvideMovieInfo _movieInfo; + private readonly IBuildFileNames _fileNameBuilder; + private readonly IImportExclusionsService _importExclusionService; + + public DiscoverMoviesModule(IMovieService movieService, IProvideMovieInfo movieInfo, IBuildFileNames fileNameBuilder, IImportExclusionsService importExclusionsService) + : base("/movies/discover") + { + _movieService = movieService; + _movieInfo = movieInfo; + _fileNameBuilder = fileNameBuilder; + _importExclusionService = importExclusionsService; + Get("/", x => GetDiscoverMovies()); + } + + private object GetDiscoverMovies() + { + var results = _movieService.GetRecommendedMovies(); + + var mapped = new List(); + + if (results.Count > 0) + { + mapped = _movieInfo.GetBulkMovieInfo(results.Select(m => m.TmdbId).ToList()); + } + + var realResults = new List(); + + realResults.AddRange(mapped.Where(x => x != null)); + + return MapToResource(realResults); + } + + private IEnumerable MapToResource(IEnumerable movies) + { + foreach (var currentMovie in movies) + { + var resource = currentMovie.ToResource(_movieService, _importExclusionService); + var poster = currentMovie.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster); + if (poster != null) + { + resource.RemotePoster = poster.Url; + } + + resource.Folder = _fileNameBuilder.GetMovieFolder(currentMovie); + + yield return resource; + } + } + } +} diff --git a/src/Radarr.Api.V3/Movies/DiscoverMoviesResource.cs b/src/Radarr.Api.V3/Movies/DiscoverMoviesResource.cs new file mode 100644 index 000000000..8d3c2edbc --- /dev/null +++ b/src/Radarr.Api.V3/Movies/DiscoverMoviesResource.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.Movies; +using NzbDrone.Core.NetImport.ImportExclusions; +using Radarr.Http.REST; + +namespace NzbDrone.Api.V3.Movies +{ + public class DiscoverMoviesResource : RestResource + { + //View Only + public string Title { get; set; } + public string SortTitle { get; set; } + public string TitleSlug { get; set; } + public MovieStatusType Status { get; set; } + public string Overview { get; set; } + public DateTime? InCinemas { get; set; } + public DateTime? PhysicalRelease { get; set; } + public List Images { get; set; } + public string Website { get; set; } + public string RemotePoster { get; set; } + public int Year { get; set; } + public string YouTubeTrailerId { get; set; } + public string Studio { get; set; } + + public int Runtime { get; set; } + public string ImdbId { get; set; } + public int TmdbId { get; set; } + public string Folder { get; set; } + public string Certification { get; set; } + public List Genres { get; set; } + public Ratings Ratings { get; set; } + public MovieCollection Collection { get; set; } + public bool IsExcluded { get; set; } + public bool IsExisting { get; set; } + } + + public static class DiscoverMoviesResourceMapper + { + public static DiscoverMoviesResource ToResource(this Movie model, IMovieService movieService, IImportExclusionsService importExclusionService) + { + if (model == null) + { + return null; + } + + return new DiscoverMoviesResource + { + TmdbId = model.TmdbId, + Title = model.Title, + SortTitle = model.SortTitle, + TitleSlug = model.TitleSlug, + InCinemas = model.InCinemas, + PhysicalRelease = model.PhysicalRelease, + + Status = model.Status, + Overview = model.Overview, + + Images = model.Images, + + Year = model.Year, + + Runtime = model.Runtime, + ImdbId = model.ImdbId, + Certification = model.Certification, + Website = model.Website, + Genres = model.Genres, + Ratings = model.Ratings, + YouTubeTrailerId = model.YouTubeTrailerId, + Studio = model.Studio, + Collection = model.Collection, + IsExcluded = importExclusionService.IsMovieExcluded(model.TmdbId), + IsExisting = movieService.FindByTmdbId(model.TmdbId) != null, + }; + } + } +}