From 5ebfac6cc87552b41e1098e7bf46130961250eb0 Mon Sep 17 00:00:00 2001 From: Leonardo Galli Date: Thu, 29 Dec 2016 16:04:01 +0100 Subject: [PATCH] First implementation of custom database table for movies.Some things are not yet working quite well (e.g. search clears when movies are added.). Also movies cannot yet be looked up! --- src/NzbDrone.Api/NzbDrone.Api.csproj | 1 + src/NzbDrone.Api/Series/MovieLookupModule.cs | 2 +- src/NzbDrone.Api/Series/MovieModule.cs | 225 ++++++++++++++++++ src/NzbDrone.Api/Series/MovieResource.cs | 3 +- .../Datastore/Migration/001_initial_setup.cs | 28 +++ src/NzbDrone.Core/Datastore/TableMapping.cs | 5 + .../MediaFiles/Events/MovieRenamedEvent.cs | 15 ++ .../MovieStats/MovieStatistics.cs | 42 ++++ .../MovieStats/MovieStatisticsRepository.cs | 86 +++++++ .../MovieStats/MovieStatisticsService.cs | 63 +++++ .../MovieStats/SeasonStatistics.cs | 41 ++++ src/NzbDrone.Core/NzbDrone.Core.csproj | 15 ++ .../Organizer/FileNameBuilder.cs | 6 + .../Tv/Events/MovieAddedEvent.cs | 14 ++ .../Tv/Events/MovieDeletedEvent.cs | 16 ++ .../Tv/Events/MovieEditedEvent.cs | 16 ++ .../Tv/Events/MovieUpdateEvent.cs | 14 ++ src/NzbDrone.Core/Tv/MovieRepository.cs | 50 ++++ src/NzbDrone.Core/Tv/MovieService.cs | 194 +++++++++++++++ src/NzbDrone.Core/Tv/MovieTitleNormalizer.cs | 22 ++ .../Paths/MovieAncestorValidator.cs | 25 ++ .../Validation/Paths/MovieExistsValidator.cs | 26 ++ .../Validation/Paths/MoviePathValidation.cs | 27 +++ src/UI/Movies/MovieModel.js | 2 +- src/UI/Movies/MoviesCollection.js | 6 +- 25 files changed, 938 insertions(+), 6 deletions(-) create mode 100644 src/NzbDrone.Api/Series/MovieModule.cs create mode 100644 src/NzbDrone.Core/MediaFiles/Events/MovieRenamedEvent.cs create mode 100644 src/NzbDrone.Core/MovieStats/MovieStatistics.cs create mode 100644 src/NzbDrone.Core/MovieStats/MovieStatisticsRepository.cs create mode 100644 src/NzbDrone.Core/MovieStats/MovieStatisticsService.cs create mode 100644 src/NzbDrone.Core/MovieStats/SeasonStatistics.cs create mode 100644 src/NzbDrone.Core/Tv/Events/MovieAddedEvent.cs create mode 100644 src/NzbDrone.Core/Tv/Events/MovieDeletedEvent.cs create mode 100644 src/NzbDrone.Core/Tv/Events/MovieEditedEvent.cs create mode 100644 src/NzbDrone.Core/Tv/Events/MovieUpdateEvent.cs create mode 100644 src/NzbDrone.Core/Tv/MovieRepository.cs create mode 100644 src/NzbDrone.Core/Tv/MovieService.cs create mode 100644 src/NzbDrone.Core/Tv/MovieTitleNormalizer.cs create mode 100644 src/NzbDrone.Core/Validation/Paths/MovieAncestorValidator.cs create mode 100644 src/NzbDrone.Core/Validation/Paths/MovieExistsValidator.cs create mode 100644 src/NzbDrone.Core/Validation/Paths/MoviePathValidation.cs diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index b87179adb..455cc845a 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -233,6 +233,7 @@ + diff --git a/src/NzbDrone.Api/Series/MovieLookupModule.cs b/src/NzbDrone.Api/Series/MovieLookupModule.cs index 0c74df808..1120b3046 100644 --- a/src/NzbDrone.Api/Series/MovieLookupModule.cs +++ b/src/NzbDrone.Api/Series/MovieLookupModule.cs @@ -5,7 +5,7 @@ using NzbDrone.Core.MetadataSource; using System.Linq; -namespace NzbDrone.Api.Series +namespace NzbDrone.Api.Movie { public class MovieLookupModule : NzbDroneRestModule { diff --git a/src/NzbDrone.Api/Series/MovieModule.cs b/src/NzbDrone.Api/Series/MovieModule.cs new file mode 100644 index 000000000..5a8e5f52f --- /dev/null +++ b/src/NzbDrone.Api/Series/MovieModule.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.MovieStats; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.Validation.Paths; +using NzbDrone.Core.DataAugmentation.Scene; +using NzbDrone.Core.Validation; +using NzbDrone.SignalR; + +namespace NzbDrone.Api.Movie +{ + public class MovieModule : NzbDroneRestModuleWithSignalR, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle + + { + private readonly IMovieService _moviesService; + private readonly IMovieStatisticsService _moviesStatisticsService; + private readonly IMapCoversToLocal _coverMapper; + + public MovieModule(IBroadcastSignalRMessage signalRBroadcaster, + IMovieService moviesService, + IMovieStatisticsService moviesStatisticsService, + ISceneMappingService sceneMappingService, + IMapCoversToLocal coverMapper, + RootFolderValidator rootFolderValidator, + MoviePathValidator moviesPathValidator, + MovieExistsValidator moviesExistsValidator, + DroneFactoryValidator droneFactoryValidator, + MovieAncestorValidator moviesAncestorValidator, + ProfileExistsValidator profileExistsValidator + ) + : base(signalRBroadcaster) + { + _moviesService = moviesService; + _moviesStatisticsService = moviesStatisticsService; + + _coverMapper = coverMapper; + + GetResourceAll = AllMovie; + GetResourceById = GetMovie; + CreateResource = AddMovie; + UpdateResource = UpdateMovie; + DeleteResource = DeleteMovie; + + Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.ProfileId)); + + SharedValidator.RuleFor(s => s.Path) + .Cascade(CascadeMode.StopOnFirstFailure) + .IsValidPath() + .SetValidator(rootFolderValidator) + .SetValidator(moviesPathValidator) + .SetValidator(droneFactoryValidator) + .SetValidator(moviesAncestorValidator) + .When(s => !s.Path.IsNullOrWhiteSpace()); + + SharedValidator.RuleFor(s => s.ProfileId).SetValidator(profileExistsValidator); + + PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace()); + PostValidator.RuleFor(s => s.RootFolderPath).IsValidPath().When(s => s.Path.IsNullOrWhiteSpace()); + PostValidator.RuleFor(s => s.Title).NotEmpty(); + PostValidator.RuleFor(s => s.ImdbId).NotNull().NotEmpty().SetValidator(moviesExistsValidator); + + PutValidator.RuleFor(s => s.Path).IsValidPath(); + } + + private MovieResource GetMovie(int id) + { + var movies = _moviesService.GetMovie(id); + return MapToResource(movies); + } + + private MovieResource MapToResource(Core.Tv.Movie movies) + { + if (movies == null) return null; + + var resource = movies.ToResource(); + MapCoversToLocal(resource); + FetchAndLinkMovieStatistics(resource); + PopulateAlternateTitles(resource); + + return resource; + } + + private List AllMovie() + { + var moviesStats = _moviesStatisticsService.MovieStatistics(); + var moviesResources = _moviesService.GetAllMovies().ToResource(); + + MapCoversToLocal(moviesResources.ToArray()); + LinkMovieStatistics(moviesResources, moviesStats); + PopulateAlternateTitles(moviesResources); + + return moviesResources; + } + + private int AddMovie(MovieResource moviesResource) + { + var model = moviesResource.ToModel(); + + return _moviesService.AddMovie(model).Id; + } + + private void UpdateMovie(MovieResource moviesResource) + { + var model = moviesResource.ToModel(_moviesService.GetMovie(moviesResource.Id)); + + _moviesService.UpdateMovie(model); + + BroadcastResourceChange(ModelAction.Updated, moviesResource); + } + + private void DeleteMovie(int id) + { + var deleteFiles = false; + var deleteFilesQuery = Request.Query.deleteFiles; + + if (deleteFilesQuery.HasValue) + { + deleteFiles = Convert.ToBoolean(deleteFilesQuery.Value); + } + + _moviesService.DeleteMovie(id, deleteFiles); + } + + private void MapCoversToLocal(params MovieResource[] movies) + { + foreach (var moviesResource in movies) + { + _coverMapper.ConvertToLocalUrls(moviesResource.Id, moviesResource.Images); + } + } + + private void FetchAndLinkMovieStatistics(MovieResource resource) + { + LinkMovieStatistics(resource, _moviesStatisticsService.MovieStatistics(resource.Id)); + } + + private void LinkMovieStatistics(List resources, List moviesStatistics) + { + var dictMovieStats = moviesStatistics.ToDictionary(v => v.MovieId); + + foreach (var movies in resources) + { + var stats = dictMovieStats.GetValueOrDefault(movies.Id); + if (stats == null) continue; + + LinkMovieStatistics(movies, stats); + } + } + + private void LinkMovieStatistics(MovieResource resource, MovieStatistics moviesStatistics) + { + resource.SizeOnDisk = moviesStatistics.SizeOnDisk; + } + + private void PopulateAlternateTitles(List resources) + { + foreach (var resource in resources) + { + PopulateAlternateTitles(resource); + } + } + + private void PopulateAlternateTitles(MovieResource resource) + { + //var mappings = null;//_sceneMappingService.FindByTvdbId(resource.TvdbId); + + //if (mappings == null) return; + + //resource.AlternateTitles = mappings.Select(v => new AlternateTitleResource { Title = v.Title, SeasonNumber = v.SeasonNumber, SceneSeasonNumber = v.SceneSeasonNumber }).ToList(); + } + + public void Handle(EpisodeImportedEvent message) + { + //BroadcastResourceChange(ModelAction.Updated, message.ImportedEpisode.MovieId); + } + + public void Handle(EpisodeFileDeletedEvent message) + { + if (message.Reason == DeleteMediaFileReason.Upgrade) return; + + //BroadcastResourceChange(ModelAction.Updated, message.EpisodeFile.MovieId); + } + + public void Handle(MovieUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.Movie.Id); + } + + public void Handle(MovieEditedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.Movie.Id); + } + + public void Handle(MovieDeletedEvent message) + { + BroadcastResourceChange(ModelAction.Deleted, message.Movie.ToResource()); + } + + public void Handle(MovieRenamedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.Movie.Id); + } + + public void Handle(MediaCoversUpdatedEvent message) + { + //BroadcastResourceChange(ModelAction.Updated, message.Movie.Id); + } + } +} diff --git a/src/NzbDrone.Api/Series/MovieResource.cs b/src/NzbDrone.Api/Series/MovieResource.cs index 1ce197751..eed694e05 100644 --- a/src/NzbDrone.Api/Series/MovieResource.cs +++ b/src/NzbDrone.Api/Series/MovieResource.cs @@ -4,8 +4,9 @@ using NzbDrone.Api.REST; using NzbDrone.Core.MediaCover; using NzbDrone.Core.Tv; +using NzbDrone.Api.Series; -namespace NzbDrone.Api.Series +namespace NzbDrone.Api.Movie { public class MovieResource : RestResource { diff --git a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs index b2792fe56..53d7a9a17 100644 --- a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs +++ b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs @@ -41,6 +41,34 @@ protected override void MainDbUpgrade() .WithColumn("FirstAired").AsDateTime().Nullable() .WithColumn("NextAiring").AsDateTime().Nullable(); + Create.TableForModel("Movies") + .WithColumn("ImdbId").AsString().Unique() + .WithColumn("Title").AsString() + .WithColumn("TitleSlug").AsString().Unique() + .WithColumn("SortTitle").AsString().Nullable() + .WithColumn("CleanTitle").AsString() + .WithColumn("Status").AsInt32() + .WithColumn("Overview").AsString().Nullable() + .WithColumn("Images").AsString() + .WithColumn("Path").AsString() + .WithColumn("Monitored").AsBoolean() + .WithColumn("QualityProfileId").AsInt32() + .WithColumn("SeasonFolder").AsBoolean() + .WithColumn("LastInfoSync").AsDateTime().Nullable() + .WithColumn("LastDiskSync").AsDateTime().Nullable() + .WithColumn("Runtime").AsInt32() + .WithColumn("BacklogSetting").AsInt32() + .WithColumn("CustomStartDate").AsDateTime().Nullable() + .WithColumn("InCinemas").AsDateTime().Nullable() + .WithColumn("Year").AsInt32().Nullable() + .WithColumn("Added").AsDateTime().Nullable() + .WithColumn("Actors").AsString().Nullable() + .WithColumn("Ratings").AsString().Nullable() + .WithColumn("Genres").AsString().Nullable() + .WithColumn("Tags").AsString().Nullable() + .WithColumn("Certification").AsString().Nullable(); + + Create.TableForModel("Seasons") .WithColumn("SeriesId").AsInt32() .WithColumn("SeasonNumber").AsInt32() diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 62f6aeb8b..397703127 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -76,6 +76,11 @@ public static void Map() .Relationship() .HasOne(s => s.Profile, s => s.ProfileId); + Mapper.Entity().RegisterModel("Movies") + .Ignore(s => s.RootFolderPath) + .Relationship() + .HasOne(s => s.Profile, s => s.ProfileId); + Mapper.Entity().RegisterModel("EpisodeFiles") .Ignore(f => f.Path) .Relationships.AutoMapICollectionOrComplexProperties() diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieRenamedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieRenamedEvent.cs new file mode 100644 index 000000000..d7e264fa3 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieRenamedEvent.cs @@ -0,0 +1,15 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieRenamedEvent : IEvent + { + public Movie Movie { get; private set; } + + public MovieRenamedEvent(Movie movie) + { + Movie = movie; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MovieStats/MovieStatistics.cs b/src/NzbDrone.Core/MovieStats/MovieStatistics.cs new file mode 100644 index 000000000..7ea4dabdb --- /dev/null +++ b/src/NzbDrone.Core/MovieStats/MovieStatistics.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.MovieStats +{ + public class MovieStatistics : ResultSet + { + public int MovieId { get; set; } + public string NextAiringString { get; set; } + public string PreviousAiringString { get; set; } + public int EpisodeFileCount { get; set; } + public int EpisodeCount { get; set; } + public int TotalEpisodeCount { get; set; } + public long SizeOnDisk { get; set; } + public List SeasonStatistics { get; set; } + + public DateTime? NextAiring + { + get + { + DateTime nextAiring; + + if (!DateTime.TryParse(NextAiringString, out nextAiring)) return null; + + return nextAiring; + } + } + + public DateTime? PreviousAiring + { + get + { + DateTime previousAiring; + + if (!DateTime.TryParse(PreviousAiringString, out previousAiring)) return null; + + return previousAiring; + } + } + } +} diff --git a/src/NzbDrone.Core/MovieStats/MovieStatisticsRepository.cs b/src/NzbDrone.Core/MovieStats/MovieStatisticsRepository.cs new file mode 100644 index 000000000..32950944d --- /dev/null +++ b/src/NzbDrone.Core/MovieStats/MovieStatisticsRepository.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Text; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.MovieStats +{ + public interface IMovieStatisticsRepository + { + List MovieStatistics(); + List MovieStatistics(int movieId); + } + + public class MovieStatisticsRepository : IMovieStatisticsRepository + { + private readonly IMainDatabase _database; + + public MovieStatisticsRepository(IMainDatabase database) + { + _database = database; + } + + public List MovieStatistics() + { + var mapper = _database.GetDataMapper(); + + mapper.AddParameter("currentDate", DateTime.UtcNow); + + var sb = new StringBuilder(); + sb.AppendLine(GetSelectClause()); + sb.AppendLine(GetEpisodeFilesJoin()); + sb.AppendLine(GetGroupByClause()); + var queryText = sb.ToString(); + + return new List(); + + return mapper.Query(queryText); + } + + public List MovieStatistics(int movieId) + { + var mapper = _database.GetDataMapper(); + + mapper.AddParameter("currentDate", DateTime.UtcNow); + mapper.AddParameter("movieId", movieId); + + var sb = new StringBuilder(); + sb.AppendLine(GetSelectClause()); + sb.AppendLine(GetEpisodeFilesJoin()); + sb.AppendLine("WHERE Episodes.MovieId = @movieId"); + sb.AppendLine(GetGroupByClause()); + var queryText = sb.ToString(); + + return new List(); + + return mapper.Query(queryText); + } + + private string GetSelectClause() + { + return @"SELECT Episodes.*, SUM(EpisodeFiles.Size) as SizeOnDisk FROM + (SELECT + Episodes.MovieId, + Episodes.SeasonNumber, + SUM(CASE WHEN AirdateUtc <= @currentDate OR EpisodeFileId > 0 THEN 1 ELSE 0 END) AS TotalEpisodeCount, + SUM(CASE WHEN (Monitored = 1 AND AirdateUtc <= @currentDate) OR EpisodeFileId > 0 THEN 1 ELSE 0 END) AS EpisodeCount, + SUM(CASE WHEN EpisodeFileId > 0 THEN 1 ELSE 0 END) AS EpisodeFileCount, + MIN(CASE WHEN AirDateUtc < @currentDate OR EpisodeFileId > 0 OR Monitored = 0 THEN NULL ELSE AirDateUtc END) AS NextAiringString, + MAX(CASE WHEN AirDateUtc >= @currentDate OR EpisodeFileId = 0 AND Monitored = 0 THEN NULL ELSE AirDateUtc END) AS PreviousAiringString + FROM Episodes + GROUP BY Episodes.MovieId, Episodes.SeasonNumber) as Episodes"; + } + + private string GetGroupByClause() + { + return "GROUP BY Episodes.MovieId, Episodes.SeasonNumber"; + } + + private string GetEpisodeFilesJoin() + { + return @"LEFT OUTER JOIN EpisodeFiles + ON EpisodeFiles.MovieId = Episodes.MovieId + AND EpisodeFiles.SeasonNumber = Episodes.SeasonNumber"; + } + } +} diff --git a/src/NzbDrone.Core/MovieStats/MovieStatisticsService.cs b/src/NzbDrone.Core/MovieStats/MovieStatisticsService.cs new file mode 100644 index 000000000..68dabd609 --- /dev/null +++ b/src/NzbDrone.Core/MovieStats/MovieStatisticsService.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.MovieStats +{ + public interface IMovieStatisticsService + { + List MovieStatistics(); + MovieStatistics MovieStatistics(int movieId); + } + + public class MovieStatisticsService : IMovieStatisticsService + { + private readonly IMovieStatisticsRepository _movieStatisticsRepository; + + public MovieStatisticsService(IMovieStatisticsRepository movieStatisticsRepository) + { + _movieStatisticsRepository = movieStatisticsRepository; + } + + public List MovieStatistics() + { + var seasonStatistics = _movieStatisticsRepository.MovieStatistics(); + + return seasonStatistics.GroupBy(s => s.MovieId).Select(s => MapMovieStatistics(s.ToList())).ToList(); + } + + public MovieStatistics MovieStatistics(int movieId) + { + var stats = _movieStatisticsRepository.MovieStatistics(movieId); + + if (stats == null || stats.Count == 0) return new MovieStatistics(); + + return MapMovieStatistics(stats); + } + + private MovieStatistics MapMovieStatistics(List seasonStatistics) + { + var movieStatistics = new MovieStatistics + { + SeasonStatistics = seasonStatistics, + MovieId = seasonStatistics.First().MovieId, + EpisodeFileCount = seasonStatistics.Sum(s => s.EpisodeFileCount), + EpisodeCount = seasonStatistics.Sum(s => s.EpisodeCount), + TotalEpisodeCount = seasonStatistics.Sum(s => s.TotalEpisodeCount), + SizeOnDisk = seasonStatistics.Sum(s => s.SizeOnDisk) + }; + + var nextAiring = seasonStatistics.Where(s => s.NextAiring != null) + .OrderBy(s => s.NextAiring) + .FirstOrDefault(); + + var previousAiring = seasonStatistics.Where(s => s.PreviousAiring != null) + .OrderBy(s => s.PreviousAiring) + .LastOrDefault(); + + movieStatistics.NextAiringString = nextAiring != null ? nextAiring.NextAiringString : null; + movieStatistics.PreviousAiringString = previousAiring != null ? previousAiring.PreviousAiringString : null; + + return movieStatistics; + } + } +} diff --git a/src/NzbDrone.Core/MovieStats/SeasonStatistics.cs b/src/NzbDrone.Core/MovieStats/SeasonStatistics.cs new file mode 100644 index 000000000..05da073db --- /dev/null +++ b/src/NzbDrone.Core/MovieStats/SeasonStatistics.cs @@ -0,0 +1,41 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.MovieStats +{ + public class SeasonStatistics : ResultSet + { + public int MovieId { get; set; } + public int SeasonNumber { get; set; } + public string NextAiringString { get; set; } + public string PreviousAiringString { get; set; } + public int EpisodeFileCount { get; set; } + public int EpisodeCount { get; set; } + public int TotalEpisodeCount { get; set; } + public long SizeOnDisk { get; set; } + + public DateTime? NextAiring + { + get + { + DateTime nextAiring; + + if (!DateTime.TryParse(NextAiringString, out nextAiring)) return null; + + return nextAiring; + } + } + + public DateTime? PreviousAiring + { + get + { + DateTime previousAiring; + + if (!DateTime.TryParse(PreviousAiringString, out previousAiring)) return null; + + return previousAiring; + } + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 2d9ac2a47..33fb52fd4 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -735,6 +735,7 @@ + @@ -1024,6 +1025,10 @@ + + + + @@ -1058,11 +1063,15 @@ + + + + @@ -1073,14 +1082,17 @@ + + Code + @@ -1109,6 +1121,9 @@ + + + diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 31cbd53ef..4d7773ad7 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -22,6 +22,7 @@ public interface IBuildFileNames BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); string GetSeriesFolder(Series series, NamingConfig namingConfig = null); string GetSeasonFolder(Series series, int seasonNumber, NamingConfig namingConfig = null); + string GetMovieFolder(Movie movie); } public class FileNameBuilder : IBuildFileNames @@ -243,6 +244,11 @@ public string GetSeasonFolder(Series series, int seasonNumber, NamingConfig nami return CleanFolderName(ReplaceTokens(namingConfig.SeasonFolderFormat, tokenHandlers, namingConfig)); } + public string GetMovieFolder(Movie movie) + { + return CleanFolderName(Parser.Parser.CleanSeriesTitle(movie.Title)); + } + public static string CleanTitle(string title) { title = title.Replace("&", "and"); diff --git a/src/NzbDrone.Core/Tv/Events/MovieAddedEvent.cs b/src/NzbDrone.Core/Tv/Events/MovieAddedEvent.cs new file mode 100644 index 000000000..1559d3716 --- /dev/null +++ b/src/NzbDrone.Core/Tv/Events/MovieAddedEvent.cs @@ -0,0 +1,14 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Tv.Events +{ + public class MovieAddedEvent : IEvent + { + public Movie Movie { get; private set; } + + public MovieAddedEvent(Movie movie) + { + Movie = movie; + } + } +} diff --git a/src/NzbDrone.Core/Tv/Events/MovieDeletedEvent.cs b/src/NzbDrone.Core/Tv/Events/MovieDeletedEvent.cs new file mode 100644 index 000000000..6c56ef1d2 --- /dev/null +++ b/src/NzbDrone.Core/Tv/Events/MovieDeletedEvent.cs @@ -0,0 +1,16 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Tv.Events +{ + public class MovieDeletedEvent : IEvent + { + public Movie Movie { get; private set; } + public bool DeleteFiles { get; private set; } + + public MovieDeletedEvent(Movie movie, bool deleteFiles) + { + Movie = movie; + DeleteFiles = deleteFiles; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Events/MovieEditedEvent.cs b/src/NzbDrone.Core/Tv/Events/MovieEditedEvent.cs new file mode 100644 index 000000000..8b4b5c5f3 --- /dev/null +++ b/src/NzbDrone.Core/Tv/Events/MovieEditedEvent.cs @@ -0,0 +1,16 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Tv.Events +{ + public class MovieEditedEvent : IEvent + { + public Movie Movie { get; private set; } + public Movie OldMovie { get; private set; } + + public MovieEditedEvent(Movie movie, Movie oldMovie) + { + Movie = movie; + OldMovie = oldMovie; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Events/MovieUpdateEvent.cs b/src/NzbDrone.Core/Tv/Events/MovieUpdateEvent.cs new file mode 100644 index 000000000..bae4d3e1d --- /dev/null +++ b/src/NzbDrone.Core/Tv/Events/MovieUpdateEvent.cs @@ -0,0 +1,14 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Tv.Events +{ + public class MovieUpdatedEvent : IEvent + { + public Movie Movie { get; private set; } + + public MovieUpdatedEvent(Movie movie) + { + Movie = movie; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/MovieRepository.cs b/src/NzbDrone.Core/Tv/MovieRepository.cs new file mode 100644 index 000000000..281152b05 --- /dev/null +++ b/src/NzbDrone.Core/Tv/MovieRepository.cs @@ -0,0 +1,50 @@ +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + + +namespace NzbDrone.Core.Tv +{ + public interface IMovieRepository : IBasicRepository + { + bool MoviePathExists(string path); + Movie FindByTitle(string cleanTitle); + Movie FindByTitle(string cleanTitle, int year); + Movie FindByImdbId(string imdbid); + } + + public class MovieRepository : BasicRepository, IMovieRepository + { + public MovieRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public bool MoviePathExists(string path) + { + return Query.Where(c => c.Path == path).Any(); + } + + public Movie FindByTitle(string cleanTitle) + { + cleanTitle = cleanTitle.ToLowerInvariant(); + + return Query.Where(s => s.CleanTitle == cleanTitle) + .SingleOrDefault(); + } + + public Movie FindByTitle(string cleanTitle, int year) + { + cleanTitle = cleanTitle.ToLowerInvariant(); + + return Query.Where(s => s.CleanTitle == cleanTitle) + .AndWhere(s => s.Year == year) + .SingleOrDefault(); + } + + public Movie FindByImdbId(string imdbid) + { + return Query.Where(s => s.ImdbId == imdbid).SingleOrDefault(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/MovieService.cs b/src/NzbDrone.Core/Tv/MovieService.cs new file mode 100644 index 000000000..546442f48 --- /dev/null +++ b/src/NzbDrone.Core/Tv/MovieService.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.DataAugmentation.Scene; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Tv.Events; + +namespace NzbDrone.Core.Tv +{ + public interface IMovieService + { + Movie GetMovie(int movieId); + List GetMovies(IEnumerable movieIds); + Movie AddMovie(Movie newMovie); + Movie FindByImdbId(string imdbid); + Movie FindByTitle(string title); + Movie FindByTitle(string title, int year); + Movie FindByTitleInexact(string title); + void DeleteMovie(int movieId, bool deleteFiles); + List GetAllMovies(); + Movie UpdateMovie(Movie movie); + List UpdateMovie(List movie); + bool MoviePathExists(string folder); + } + + public class MovieService : IMovieService + { + private readonly IMovieRepository _movieRepository; + private readonly IEventAggregator _eventAggregator; + private readonly IBuildFileNames _fileNameBuilder; + private readonly Logger _logger; + + public MovieService(IMovieRepository movieRepository, + IEventAggregator eventAggregator, + ISceneMappingService sceneMappingService, + IEpisodeService episodeService, + IBuildFileNames fileNameBuilder, + Logger logger) + { + _movieRepository = movieRepository; + _eventAggregator = eventAggregator; + _fileNameBuilder = fileNameBuilder; + _logger = logger; + } + + public Movie GetMovie(int movieId) + { + return _movieRepository.Get(movieId); + } + + public List GetMovies(IEnumerable movieIds) + { + return _movieRepository.Get(movieIds).ToList(); + } + + public Movie AddMovie(Movie newMovie) + { + Ensure.That(newMovie, () => newMovie).IsNotNull(); + + if (string.IsNullOrWhiteSpace(newMovie.Path)) + { + var folderName = _fileNameBuilder.GetMovieFolder(newMovie); + newMovie.Path = Path.Combine(newMovie.RootFolderPath, folderName); + } + + _logger.Info("Adding Movie {0} Path: [{1}]", newMovie, newMovie.Path); + + newMovie.CleanTitle = newMovie.Title.CleanSeriesTitle(); + newMovie.SortTitle = MovieTitleNormalizer.Normalize(newMovie.Title, newMovie.ImdbId); + newMovie.Added = DateTime.UtcNow; + + _movieRepository.Insert(newMovie); + _eventAggregator.PublishEvent(new MovieAddedEvent(GetMovie(newMovie.Id))); + + return newMovie; + } + + public Movie FindByTitle(string title) + { + return _movieRepository.FindByTitle(title.CleanSeriesTitle()); + } + + public Movie FindByImdbId(string imdbid) + { + return _movieRepository.FindByImdbId(imdbid); + } + + public Movie FindByTitleInexact(string title) + { + // find any movie clean title within the provided release title + string cleanTitle = title.CleanSeriesTitle(); + var list = _movieRepository.All().Where(s => cleanTitle.Contains(s.CleanTitle)).ToList(); + if (!list.Any()) + { + // no movie matched + return null; + } + if (list.Count == 1) + { + // return the first movie if there is only one + return list.Single(); + } + // build ordered list of movie by position in the search string + var query = + list.Select(movie => new + { + position = cleanTitle.IndexOf(movie.CleanTitle), + length = movie.CleanTitle.Length, + movie = movie + }) + .Where(s => (s.position>=0)) + .ToList() + .OrderBy(s => s.position) + .ThenByDescending(s => s.length) + .ToList(); + + // get the leftmost movie that is the longest + // movie are usually the first thing in release title, so we select the leftmost and longest match + var match = query.First().movie; + + _logger.Debug("Multiple movie matched {0} from title {1}", match.Title, title); + foreach (var entry in list) + { + _logger.Debug("Multiple movie match candidate: {0} cleantitle: {1}", entry.Title, entry.CleanTitle); + } + + return match; + } + + public Movie FindByTitle(string title, int year) + { + return _movieRepository.FindByTitle(title.CleanSeriesTitle(), year); + } + + public void DeleteMovie(int movieId, bool deleteFiles) + { + var movie = _movieRepository.Get(movieId); + _movieRepository.Delete(movieId); + _eventAggregator.PublishEvent(new MovieDeletedEvent(movie, deleteFiles)); + } + + public List GetAllMovies() + { + return _movieRepository.All().ToList(); + } + + public Movie UpdateMovie(Movie movie) + { + var storedMovie = GetMovie(movie.Id); + + var updatedMovie = _movieRepository.Update(movie); + _eventAggregator.PublishEvent(new MovieEditedEvent(updatedMovie, storedMovie)); + + return updatedMovie; + } + + public List UpdateMovie(List movie) + { + _logger.Debug("Updating {0} movie", movie.Count); + foreach (var s in movie) + { + _logger.Trace("Updating: {0}", s.Title); + if (!s.RootFolderPath.IsNullOrWhiteSpace()) + { + var folderName = new DirectoryInfo(s.Path).Name; + s.Path = Path.Combine(s.RootFolderPath, folderName); + _logger.Trace("Changing path for {0} to {1}", s.Title, s.Path); + } + + else + { + _logger.Trace("Not changing path for: {0}", s.Title); + } + } + + _movieRepository.UpdateMany(movie); + _logger.Debug("{0} movie updated", movie.Count); + + return movie; + } + + public bool MoviePathExists(string folder) + { + return _movieRepository.MoviePathExists(folder); + } + + } +} diff --git a/src/NzbDrone.Core/Tv/MovieTitleNormalizer.cs b/src/NzbDrone.Core/Tv/MovieTitleNormalizer.cs new file mode 100644 index 000000000..fd2f87cd1 --- /dev/null +++ b/src/NzbDrone.Core/Tv/MovieTitleNormalizer.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Tv +{ + public static class MovieTitleNormalizer + { + private readonly static Dictionary PreComputedTitles = new Dictionary + { + { "tt_109823457098", "a to z" }, + }; + + public static string Normalize(string title, string imdbid) + { + if (PreComputedTitles.ContainsKey(imdbid)) + { + return PreComputedTitles[imdbid]; + } + + return Parser.Parser.NormalizeTitle(title).ToLower(); + } + } +} diff --git a/src/NzbDrone.Core/Validation/Paths/MovieAncestorValidator.cs b/src/NzbDrone.Core/Validation/Paths/MovieAncestorValidator.cs new file mode 100644 index 000000000..d694d00b4 --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/MovieAncestorValidator.cs @@ -0,0 +1,25 @@ +using System.Linq; +using FluentValidation.Validators; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Validation.Paths +{ + public class MovieAncestorValidator : PropertyValidator + { + private readonly IMovieService _seriesService; + + public MovieAncestorValidator(IMovieService seriesService) + : base("Path is an ancestor of an existing path") + { + _seriesService = seriesService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + return !_seriesService.GetAllMovies().Any(s => context.PropertyValue.ToString().IsParentPath(s.Path)); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Validation/Paths/MovieExistsValidator.cs b/src/NzbDrone.Core/Validation/Paths/MovieExistsValidator.cs new file mode 100644 index 000000000..88519e41f --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/MovieExistsValidator.cs @@ -0,0 +1,26 @@ +using System; +using FluentValidation.Validators; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Validation.Paths +{ + public class MovieExistsValidator : PropertyValidator + { + private readonly IMovieService _seriesService; + + public MovieExistsValidator(IMovieService seriesService) + : base("This series has already been added") + { + _seriesService = seriesService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + var imdbid = context.PropertyValue.ToString(); + + return (!_seriesService.GetAllMovies().Exists(s => s.ImdbId == imdbid)); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Validation/Paths/MoviePathValidation.cs b/src/NzbDrone.Core/Validation/Paths/MoviePathValidation.cs new file mode 100644 index 000000000..690bd59f2 --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/MoviePathValidation.cs @@ -0,0 +1,27 @@ +using FluentValidation.Validators; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Validation.Paths +{ + public class MoviePathValidator : PropertyValidator + { + private readonly IMovieService _seriesService; + + public MoviePathValidator(IMovieService seriesService) + : base("Path is already configured for another series") + { + _seriesService = seriesService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + dynamic instance = context.ParentContext.InstanceToValidate; + var instanceId = (int)instance.Id; + + return (!_seriesService.GetAllMovies().Exists(s => s.Path.PathEquals(context.PropertyValue.ToString()) && s.Id != instanceId)); + } + } +} \ No newline at end of file diff --git a/src/UI/Movies/MovieModel.js b/src/UI/Movies/MovieModel.js index 49c64dea7..a3e0d5a35 100644 --- a/src/UI/Movies/MovieModel.js +++ b/src/UI/Movies/MovieModel.js @@ -2,7 +2,7 @@ var Backbone = require('backbone'); var _ = require('underscore'); module.exports = Backbone.Model.extend({ - urlRoot : window.NzbDrone.ApiRoot + '/movies', + urlRoot : window.NzbDrone.ApiRoot + '/movie', defaults : { episodeFileCount : 0, diff --git a/src/UI/Movies/MoviesCollection.js b/src/UI/Movies/MoviesCollection.js index b6f0e2edb..2df59e282 100644 --- a/src/UI/Movies/MoviesCollection.js +++ b/src/UI/Movies/MoviesCollection.js @@ -10,9 +10,9 @@ var moment = require('moment'); require('../Mixins/backbone.signalr.mixin'); var Collection = PageableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/movies', + url : window.NzbDrone.ApiRoot + '/movie', model : MovieModel, - tableName : 'movies', + tableName : 'movie', state : { sortKey : 'sortTitle', @@ -115,6 +115,6 @@ Collection = AsFilteredCollection.call(Collection); Collection = AsSortedCollection.call(Collection); Collection = AsPersistedStateCollection.call(Collection); -var data = ApiData.get('series'); +var data = ApiData.get('movie'); module.exports = new Collection(data, { full : true }).bindSignalR();