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();