diff --git a/.DS_Store b/.DS_Store index 474ee832f..e7ed6672e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index d57a525c8..6e272c56c 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,9 @@ bin obj output/* +#Packages +Radarr_*/ +Radarr_*.zip #OS X metadata files ._* diff --git a/.travis.yml b/.travis.yml index 87ea22b9b..f61c413f1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,3 +7,6 @@ script: # the following commands are just examples, use whatever your build p install: - sudo apt-get install nodejs - sudo apt-get install npm +after_success: + - chmod +x package.sh + - ./package.sh diff --git a/package.sh b/package.sh new file mode 100644 index 000000000..65357f083 --- /dev/null +++ b/package.sh @@ -0,0 +1,50 @@ +if [ $# -eq 0 ]; then + if [ "$TRAVIS_PULL_REQUEST" != false ]; then + echo "Need to supply version argument" && exit; + fi +fi + +if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then + VERSION="`date +%H:%M:%S`" + YEAR="`date +%Y`" + MONTH="`date +%m`" + DAY="`date +%d`" +else + VERSION=$1 +fi +outputFolder='./_output' +outputFolderMono='./_output_mono' +outputFolderOsx='./_output_osx' +outputFolderOsxApp='./_output_osx_app' + +tr -d "\r" < $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr > $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr2 +rm $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr +chmod +x $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr2 +mv $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr2 $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr >& error.log + +cp -r $outputFolder/ Radarr_Windows_$VERSION +cp -r $outputFolderMono/ Radarr_Mono_$VERSION +cp -r $outputFolderOsxApp/ Radarr_OSX_$VERSION + +zip -r Radarr_Windows_$VERSION.zip Radarr_Windows_$VERSION >& /dev/null +zip -r Radarr_Mono_$VERSION.zip Radarr_Mono_$VERSION >& /dev/null +zip -r Radarr_OSX_$VERSION.zip Radarr_OSX_$VERSION >& /dev/null + +ftp -n ftp.leonardogalli.ch << END_SCRIPT +passive +quote USER $FTP_USER +quote PASS $FTP_PASS +mkdir builds +cd builds +mkdir $YEAR +cd $YEAR +mkdir $MONTH +cd $MONTH +mkdir $DAY +cd $DAY +binary +put Radarr_Windows_$VERSION.zip +put Radarr_Mono_$VERSION.zip +put Radarr_OSX_$VERSION.zip +quit +END_SCRIPT diff --git a/src/NzbDrone.Api/Series/MovieResource.cs b/src/NzbDrone.Api/Series/MovieResource.cs index eed694e05..a35b2d210 100644 --- a/src/NzbDrone.Api/Series/MovieResource.cs +++ b/src/NzbDrone.Api/Series/MovieResource.cs @@ -48,6 +48,7 @@ namespace NzbDrone.Api.Movie public List Genres { get; set; } public HashSet Tags { get; set; } public DateTime Added { get; set; } + public AddMovieOptions AddOptions { get; set; } public Ratings Ratings { get; set; } //TODO: Add series statistics as a property of the series (instead of individual properties) @@ -110,6 +111,7 @@ namespace NzbDrone.Api.Movie Genres = model.Genres, Tags = model.Tags, Added = model.Added, + AddOptions = model.AddOptions, Ratings = model.Ratings }; } @@ -152,6 +154,7 @@ namespace NzbDrone.Api.Movie Genres = resource.Genres, Tags = resource.Tags, Added = resource.Added, + AddOptions = resource.AddOptions, Ratings = resource.Ratings }; } @@ -167,6 +170,7 @@ namespace NzbDrone.Api.Movie movie.RootFolderPath = resource.RootFolderPath; movie.Tags = resource.Tags; + movie.AddOptions = resource.AddOptions; return movie; } diff --git a/src/NzbDrone.Automation.Test/AutomationTest.cs b/src/NzbDrone.Automation.Test/AutomationTest.cs index 9f493d824..0e0fea564 100644 --- a/src/NzbDrone.Automation.Test/AutomationTest.cs +++ b/src/NzbDrone.Automation.Test/AutomationTest.cs @@ -40,7 +40,7 @@ namespace NzbDrone.Automation.Test _runner.KillAll(); _runner.Start(); - driver.Url = "http://localhost:8989"; + driver.Url = "http://localhost:7878"; var page = new PageBase(driver); page.WaitForNoSpinner(); diff --git a/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs b/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs index 92df06ded..7d0e0442f 100644 --- a/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs +++ b/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs @@ -49,7 +49,7 @@ namespace NzbDrone.Common.Test public void GetValue_Success() { const string key = "Port"; - const string value = "8989"; + const string value = "7878"; var result = Subject.GetValue(key, value); @@ -60,7 +60,7 @@ namespace NzbDrone.Common.Test public void GetInt_Success() { const string key = "Port"; - const int value = 8989; + const int value = 7878; var result = Subject.GetValueInt(key, value); @@ -95,7 +95,7 @@ namespace NzbDrone.Common.Test [Test] public void GetPort_Success() { - const int value = 8989; + const int value = 7878; var result = Subject.Port; diff --git a/src/NzbDrone.Console/ConsoleApp.cs b/src/NzbDrone.Console/ConsoleApp.cs index 6f935887f..83040c67c 100644 --- a/src/NzbDrone.Console/ConsoleApp.cs +++ b/src/NzbDrone.Console/ConsoleApp.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Console { System.Console.WriteLine(""); System.Console.WriteLine(""); - Logger.Fatal(exception.Message + ". This can happen if another instance of Sonarr is already running another application is using the same port (default: 8989) or the user has insufficient permissions"); + Logger.Fatal(exception.Message + ". This can happen if another instance of Radarr is already running another application is using the same port (default: 7878) or the user has insufficient permissions"); System.Console.WriteLine("Press enter to exit..."); System.Console.ReadLine(); Environment.Exit(1); diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index fa6d8a914..2eeaf6463 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -133,7 +133,7 @@ namespace NzbDrone.Core.Configuration } } - public int Port => GetValueInt("Port", 8989); + public int Port => GetValueInt("Port", 7878); public int SslPort => GetValueInt("SslPort", 9898); @@ -303,12 +303,12 @@ namespace NzbDrone.Core.Configuration if (contents.IsNullOrWhiteSpace()) { - throw new InvalidConfigFileException($"{_configFile} is empty. Please delete the config file and Sonarr will recreate it."); + throw new InvalidConfigFileException($"{_configFile} is empty. Please delete the config file and Radarr will recreate it."); } if (contents.All(char.IsControl)) { - throw new InvalidConfigFileException($"{_configFile} is corrupt. Please delete the config file and Sonarr will recreate it."); + throw new InvalidConfigFileException($"{_configFile} is corrupt. Please delete the config file and Radarr will recreate it."); } return XDocument.Parse(_diskProvider.ReadAllText(_configFile)); @@ -323,7 +323,7 @@ namespace NzbDrone.Core.Configuration catch (XmlException ex) { - throw new InvalidConfigFileException($"{_configFile} is corrupt is invalid. Please delete the config file and Sonarr will recreate it.", ex); + throw new InvalidConfigFileException($"{_configFile} is corrupt is invalid. Please delete the config file and Radarr will recreate it.", ex); } } diff --git a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs index 2fc722cb4..0042de064 100644 --- a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs +++ b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs @@ -63,7 +63,8 @@ namespace NzbDrone.Core.Datastore.Migration .WithColumn("Ratings").AsString().Nullable() .WithColumn("Genres").AsString().Nullable() .WithColumn("Tags").AsString().Nullable() - .WithColumn("Certification").AsString().Nullable(); + .WithColumn("Certification").AsString().Nullable() + .WithColumn("AddOptions").AsString().Nullable(); Create.TableForModel("Seasons") diff --git a/src/NzbDrone.Core/Datastore/Migration/054_rename_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/054_rename_profiles.cs index e665c14a4..5535a1bd9 100644 --- a/src/NzbDrone.Core/Datastore/Migration/054_rename_profiles.cs +++ b/src/NzbDrone.Core/Datastore/Migration/054_rename_profiles.cs @@ -21,11 +21,12 @@ namespace NzbDrone.Core.Datastore.Migration //Add HeldReleases Create.TableForModel("PendingReleases") - .WithColumn("SeriesId").AsInt32() + .WithColumn("SeriesId").AsInt32().WithDefaultValue(0) .WithColumn("Title").AsString() .WithColumn("Added").AsDateTime() .WithColumn("ParsedEpisodeInfo").AsString() - .WithColumn("Release").AsString(); + .WithColumn("Release").AsString() + .WithColumn("MovieId").AsInt32().WithDefaultValue(0); } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/104_add_moviefiles_table.cs b/src/NzbDrone.Core/Datastore/Migration/104_add_moviefiles_table.cs new file mode 100644 index 000000000..34a455683 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/104_add_moviefiles_table.cs @@ -0,0 +1,31 @@ +using FluentMigrator; +using Marr.Data.Mapping; +using NzbDrone.Core.Datastore.Migration.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Datastore.Extensions; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(104)] + public class add_moviefiles_table : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("MovieFiles") + .WithColumn("MovieId").AsInt32() + .WithColumn("Path").AsString().Unique() + .WithColumn("Quality").AsString() + .WithColumn("Size").AsInt64() + .WithColumn("DateAdded").AsDateTime() + .WithColumn("SceneName").AsString().Nullable() + .WithColumn("MediaInfo").AsString().Nullable() + .WithColumn("ReleaseGroup").AsString().Nullable() + .WithColumn("RelativePath").AsString().Nullable(); + + Alter.Table("Movies").AddColumn("MovieFileId").AsInt32().WithDefaultValue(0); + + + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs index 793725e9f..310628715 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs @@ -60,7 +60,6 @@ namespace NzbDrone.Core.Datastore.Migration.Framework sw.Stop(); - _announcer.ElapsedTime(sw.Elapsed); } } diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 397703127..65a82948b 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -76,10 +76,7 @@ namespace NzbDrone.Core.Datastore .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) @@ -89,6 +86,21 @@ namespace NzbDrone.Core.Datastore query: (db, parent) => db.Query().Where(c => c.EpisodeFileId == parent.Id).ToList()) .HasOne(file => file.Series, file => file.SeriesId); + Mapper.Entity().RegisterModel("MovieFiles") + .Ignore(f => f.Path) + .Relationships.AutoMapICollectionOrComplexProperties() + .For("Movie") + .LazyLoad(condition: parent => parent.Id > 0, + query: (db, parent) => db.Query().Where(c => c.MovieFileId == parent.Id).ToList()) + .HasOne(file => file.Movie, file => file.MovieId); + + Mapper.Entity().RegisterModel("Movies") + .Ignore(s => s.RootFolderPath) + .Relationship() + .HasOne(s => s.Profile, s => s.ProfileId) + .HasOne(m => m.MovieFile, m => m.MovieFileId); + + Mapper.Entity().RegisterModel("Episodes") .Ignore(e => e.SeriesTitle) .Ignore(e => e.Series) diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs index 2cc13a235..8a82fe93a 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs @@ -60,7 +60,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { DownloadClient = Definition.Name, DownloadId = Definition.Name + "_" + item.DownloadId, - Category = "sonarr", + Category = "Radarr", Title = item.Title, TotalSize = item.TotalSize, diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index d8c7e825a..4d4dc47db 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -250,7 +250,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return new NzbDroneValidationFailure("TvCategory", "Category is recommended") { IsWarning = true, - DetailedDescription = "Sonarr will not attempt to import completed downloads without a category." + DetailedDescription = "Radarr will not attempt to import completed downloads without a category." }; } @@ -260,7 +260,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { return new NzbDroneValidationFailure(String.Empty, "QBittorrent is configured to remove torrents when they reach their Share Ratio Limit") { - DetailedDescription = "Sonarr will be unable to perform Completed Download Handling as configured. You can fix this in qBittorrent ('Tools -> Options...' in the menu) by changing 'Options -> BitTorrent -> Share Ratio Limiting' from 'Remove them' to 'Pause them'." + DetailedDescription = "Radarr will be unable to perform Completed Download Handling as configured. You can fix this in qBittorrent ('Tools -> Options...' in the menu) by changing 'Options -> BitTorrent -> Share Ratio Limiting' from 'Remove them' to 'Pause them'." }; } } diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index c4fbe11a2..4e2bebe59 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -27,7 +27,9 @@ namespace NzbDrone.Core.Download private readonly IEventAggregator _eventAggregator; private readonly IHistoryService _historyService; private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; + private readonly IDownloadedMovieImportService _downloadedMovieImportService; private readonly IParsingService _parsingService; + private readonly IMovieService _movieService; private readonly Logger _logger; private readonly ISeriesService _seriesService; @@ -35,15 +37,19 @@ namespace NzbDrone.Core.Download IEventAggregator eventAggregator, IHistoryService historyService, IDownloadedEpisodesImportService downloadedEpisodesImportService, + IDownloadedMovieImportService downloadedMovieImportService, IParsingService parsingService, ISeriesService seriesService, + IMovieService movieService, Logger logger) { _configService = configService; _eventAggregator = eventAggregator; _historyService = historyService; _downloadedEpisodesImportService = downloadedEpisodesImportService; + _downloadedMovieImportService = downloadedMovieImportService; _parsingService = parsingService; + _movieService = movieService; _logger = logger; _seriesService = seriesService; } @@ -61,7 +67,7 @@ namespace NzbDrone.Core.Download if (historyItem == null && trackedDownload.DownloadItem.Category.IsNullOrWhiteSpace()) { - trackedDownload.Warn("Download wasn't grabbed by Sonarr and not in a category, Skipping."); + trackedDownload.Warn("Download wasn't grabbed by Radarr and not in a category, Skipping."); return; } @@ -88,19 +94,31 @@ namespace NzbDrone.Core.Download return; } + var series = _parsingService.GetSeries(trackedDownload.DownloadItem.Title); if (series == null) { if (historyItem != null) { - series = _seriesService.GetSeries(historyItem.SeriesId); + //series = _seriesService.GetSeries(historyItem.SeriesId); } if (series == null) { - trackedDownload.Warn("Series title mismatch, automatic import is not possible."); - return; + var movie = _parsingService.GetMovie(trackedDownload.DownloadItem.Title); + + if (movie == null) + { + movie = _movieService.GetMovie(historyItem.MovieId); + + if (movie == null) + { + trackedDownload.Warn("Movie title mismatch, automatic import is not possible."); + } + } + //trackedDownload.Warn("Series title mismatch, automatic import is not possible."); + //return; } } } @@ -111,29 +129,59 @@ namespace NzbDrone.Core.Download private void Import(TrackedDownload trackedDownload) { var outputPath = trackedDownload.DownloadItem.OutputPath.FullPath; - var importResults = _downloadedEpisodesImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteEpisode.Series, trackedDownload.DownloadItem); - - if (importResults.Empty()) + if (trackedDownload.RemoteMovie.Movie != null) { - trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); - return; - } + var importResults = _downloadedMovieImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteMovie.Movie, trackedDownload.DownloadItem); - if (importResults.Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteEpisode.Episodes.Count)) - { - trackedDownload.State = TrackedDownloadStage.Imported; - _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); - return; - } + if (importResults.Empty()) + { + trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); + return; + } - if (importResults.Any(c => c.Result != ImportResultType.Imported)) + if (importResults.Count(c => c.Result == ImportResultType.Imported) >= 1) + { + trackedDownload.State = TrackedDownloadStage.Imported; + _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); + return; + } + + if (importResults.Any(c => c.Result != ImportResultType.Imported)) + { + var statusMessages = importResults + .Where(v => v.Result != ImportResultType.Imported) + .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.LocalEpisode.Path), v.Errors)) + .ToArray(); + + trackedDownload.Warn(statusMessages); + } + } + else { - var statusMessages = importResults - .Where(v => v.Result != ImportResultType.Imported) - .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.LocalEpisode.Path), v.Errors)) - .ToArray(); + var importResults = _downloadedEpisodesImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteEpisode.Series, trackedDownload.DownloadItem); + + if (importResults.Empty()) + { + trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); + return; + } - trackedDownload.Warn(statusMessages); + if (importResults.Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteEpisode.Episodes.Count)) + { + trackedDownload.State = TrackedDownloadStage.Imported; + _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); + return; + } + + if (importResults.Any(c => c.Result != ImportResultType.Imported)) + { + var statusMessages = importResults + .Where(v => v.Result != ImportResultType.Imported) + .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.LocalEpisode.Path), v.Errors)) + .ToArray(); + + trackedDownload.Warn(statusMessages); + } } } diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 0e48207ba..14f7f1b71 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -133,7 +133,7 @@ namespace NzbDrone.Core.Download { return new NzbDroneValidationFailure(propertyName, "Folder does not exist") { - DetailedDescription = string.Format("The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account '{0}', which is used to execute Sonarr.", Environment.UserName) + DetailedDescription = string.Format("The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account '{0}', which is used to execute Radarr.", Environment.UserName) }; } @@ -142,7 +142,7 @@ namespace NzbDrone.Core.Download _logger.Error("Folder '{0}' is not writable.", folder); return new NzbDroneValidationFailure(propertyName, "Unable to write to folder") { - DetailedDescription = string.Format("The folder you specified is not writable. Please verify the folder permissions for the user account '{0}', which is used to execute Sonarr.", Environment.UserName) + DetailedDescription = string.Format("The folder you specified is not writable. Please verify the folder permissions for the user account '{0}', which is used to execute Radarr.", Environment.UserName) }; } diff --git a/src/NzbDrone.Core/Download/Pending/PendingRelease.cs b/src/NzbDrone.Core/Download/Pending/PendingRelease.cs index a713fe48c..504db7e36 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingRelease.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingRelease.cs @@ -7,6 +7,7 @@ namespace NzbDrone.Core.Download.Pending public class PendingRelease : ModelBase { public int SeriesId { get; set; } + public int MovieId { get; set; } public string Title { get; set; } public DateTime Added { get; set; } public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } @@ -14,5 +15,6 @@ namespace NzbDrone.Core.Download.Pending //Not persisted public RemoteEpisode RemoteEpisode { get; set; } + public RemoteMovie RemoteMovie { get; set; } } } diff --git a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs index d5fbff7d8..5a53e2d18 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs @@ -344,7 +344,7 @@ namespace NzbDrone.Core.Download.Pending public void Handle(MovieGrabbedEvent message) { - + //RemoveGrabbed(message.Movie); } public void Handle(RssSyncCompleteEvent message) diff --git a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs index 05719587d..adadebdee 100644 --- a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs +++ b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs @@ -32,53 +32,112 @@ namespace NzbDrone.Core.Download public ProcessedDecisions ProcessDecisions(List decisions) { - var qualifiedReports = GetQualifiedReports(decisions); - var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(qualifiedReports); + //var qualifiedReports = GetQualifiedReports(decisions); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); var grabbed = new List(); var pending = new List(); foreach (var report in prioritizedDecisions) { - var remoteEpisode = report.RemoteEpisode; - var episodeIds = remoteEpisode.Episodes.Select(e => e.Id).ToList(); - - //Skip if already grabbed - if (grabbed.SelectMany(r => r.RemoteEpisode.Episodes) - .Select(e => e.Id) - .ToList() - .Intersect(episodeIds) - .Any()) + if (report.IsForMovie) { - continue; - } + var remoteMovie = report.RemoteMovie; - if (report.TemporarilyRejected) - { - _pendingReleaseService.Add(report); - pending.Add(report); - continue; - } + if (report.TemporarilyRejected) + { + _pendingReleaseService.Add(report); + pending.Add(report); + continue; + } - if (pending.SelectMany(r => r.RemoteEpisode.Episodes) - .Select(e => e.Id) - .ToList() - .Intersect(episodeIds) - .Any()) - { - continue; - } + if (remoteMovie == null || remoteMovie.Movie == null) + { + continue; + } - try - { - _downloadService.DownloadReport(remoteEpisode); - grabbed.Add(report); + List movieIds = new List { remoteMovie.Movie.Id }; + + + //Skip if already grabbed + if (grabbed.Select(r => r.RemoteMovie.Movie) + .Select(e => e.Id) + .ToList() + .Intersect(movieIds) + .Any()) + { + continue; + } + + if (pending.Select(r => r.RemoteMovie.Movie) + .Select(e => e.Id) + .ToList() + .Intersect(movieIds) + .Any()) + { + continue; + } + + try + { + _downloadService.DownloadReport(remoteMovie); + grabbed.Add(report); + } + catch (Exception e) + { + //TODO: support for store & forward + //We'll need to differentiate between a download client error and an indexer error + _logger.Warn(e, "Couldn't add report to download queue. " + remoteMovie); + } } - catch (Exception e) + else { - //TODO: support for store & forward - //We'll need to differentiate between a download client error and an indexer error - _logger.Warn(e, "Couldn't add report to download queue. " + remoteEpisode); + var remoteEpisode = report.RemoteEpisode; + + if (remoteEpisode == null || remoteEpisode.Episodes == null) + { + continue; + } + + var episodeIds = remoteEpisode.Episodes.Select(e => e.Id).ToList(); + + //Skip if already grabbed + if (grabbed.SelectMany(r => r.RemoteEpisode.Episodes) + .Select(e => e.Id) + .ToList() + .Intersect(episodeIds) + .Any()) + { + continue; + } + + if (report.TemporarilyRejected) + { + _pendingReleaseService.Add(report); + pending.Add(report); + continue; + } + + if (pending.SelectMany(r => r.RemoteEpisode.Episodes) + .Select(e => e.Id) + .ToList() + .Intersect(episodeIds) + .Any()) + { + continue; + } + + try + { + _downloadService.DownloadReport(remoteEpisode); + grabbed.Add(report); + } + catch (Exception e) + { + //TODO: support for store & forward + //We'll need to differentiate between a download client error and an indexer error + _logger.Warn(e, "Couldn't add report to download queue. " + remoteEpisode); + } } } diff --git a/src/NzbDrone.Core/Download/TorrentClientBase.cs b/src/NzbDrone.Core/Download/TorrentClientBase.cs index 83c013ad7..70681f992 100644 --- a/src/NzbDrone.Core/Download/TorrentClientBase.cs +++ b/src/NzbDrone.Core/Download/TorrentClientBase.cs @@ -271,7 +271,7 @@ namespace NzbDrone.Core.Download if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) { _logger.Debug( - "{0} did not return the expected InfoHash for '{1}', Sonarr could potentially lose track of the download in progress.", + "{0} did not return the expected InfoHash for '{1}', Radarr could potentially lose track of the download in progress.", Definition.Implementation, remoteEpisode.Release.DownloadUrl); } @@ -302,7 +302,7 @@ namespace NzbDrone.Core.Download if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) { _logger.Debug( - "{0} did not return the expected InfoHash for '{1}', Sonarr could potentially lose track of the download in progress.", + "{0} did not return the expected InfoHash for '{1}', Radarr could potentially lose track of the download in progress.", Definition.Implementation, remoteEpisode.Release.DownloadUrl); } @@ -371,7 +371,7 @@ namespace NzbDrone.Core.Download if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) { _logger.Debug( - "{0} did not return the expected InfoHash for '{1}', Sonarr could potentially lose track of the download in progress.", + "{0} did not return the expected InfoHash for '{1}', Radarr could potentially lose track of the download in progress.", Definition.Implementation, remoteEpisode.Release.DownloadUrl); } @@ -402,7 +402,7 @@ namespace NzbDrone.Core.Download if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) { _logger.Debug( - "{0} did not return the expected InfoHash for '{1}', Sonarr could potentially lose track of the download in progress.", + "{0} did not return the expected InfoHash for '{1}', Radarr could potentially lose track of the download in progress.", Definition.Implementation, remoteEpisode.Release.DownloadUrl); } diff --git a/src/NzbDrone.Core/IndexerSearch/MoviesSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/MoviesSearchCommand.cs new file mode 100644 index 000000000..da0b9a8c1 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/MoviesSearchCommand.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.IndexerSearch +{ + public class MoviesSearchCommand : Command + { + public int MovieId { get; set; } + + public override bool SendUpdatesToClient => true; + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/MoviesSearchService.cs b/src/NzbDrone.Core/IndexerSearch/MoviesSearchService.cs new file mode 100644 index 000000000..656423178 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/MoviesSearchService.cs @@ -0,0 +1,46 @@ +using System.Linq; +using NLog; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Download; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.IndexerSearch +{ + public class MovieSearchService : IExecute + { + private readonly IMovieService _seriesService; + private readonly ISearchForNzb _nzbSearchService; + private readonly IProcessDownloadDecisions _processDownloadDecisions; + private readonly Logger _logger; + + public MovieSearchService(IMovieService seriesService, + ISearchForNzb nzbSearchService, + IProcessDownloadDecisions processDownloadDecisions, + Logger logger) + { + _seriesService = seriesService; + _nzbSearchService = nzbSearchService; + _processDownloadDecisions = processDownloadDecisions; + _logger = logger; + } + + public void Execute(MoviesSearchCommand message) + { + var series = _seriesService.GetMovie(message.MovieId); + + var downloadedCount = 0; + + if (!series.Monitored) + { + _logger.Debug("Movie {0} is not monitored, skipping search", series.Title); + } + + var decisions = _nzbSearchService.MovieSearch(message.MovieId, false);//_nzbSearchService.SeasonSearch(message.MovieId, season.SeasonNumber, false, message.Trigger == CommandTrigger.Manual); + downloadedCount += _processDownloadDecisions.ProcessDecisions(decisions).Grabbed.Count; + + + _logger.ProgressInfo("Movie search completed. {0} reports downloaded.", downloadedCount); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedMovieCommandService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedMovieCommandService.cs new file mode 100644 index 000000000..fcda97d24 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/DownloadedMovieCommandService.cs @@ -0,0 +1,265 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.MediaFiles.EpisodeImport; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IDownloadedMovieImportService + { + List ProcessRootFolder(DirectoryInfo directoryInfo); + List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Movie movie = null, DownloadClientItem downloadClientItem = null); + bool ShouldDeleteFolder(DirectoryInfo directoryInfo, Movie movie); + } + + public class DownloadedMovieImportService : IDownloadedMovieImportService + { + private readonly IDiskProvider _diskProvider; + private readonly IDiskScanService _diskScanService; + private readonly IMovieService _movieService; + private readonly IParsingService _parsingService; + private readonly IMakeImportDecision _importDecisionMaker; + private readonly IImportApprovedMovie _importApprovedMovie; + private readonly IDetectSample _detectSample; + private readonly Logger _logger; + + public DownloadedMovieImportService(IDiskProvider diskProvider, + IDiskScanService diskScanService, + IMovieService movieService, + IParsingService parsingService, + IMakeImportDecision importDecisionMaker, + IImportApprovedMovie importApprovedMovie, + IDetectSample detectSample, + Logger logger) + { + _diskProvider = diskProvider; + _diskScanService = diskScanService; + _movieService = movieService; + _parsingService = parsingService; + _importDecisionMaker = importDecisionMaker; + _importApprovedMovie = importApprovedMovie; + _detectSample = detectSample; + _logger = logger; + } + + public List ProcessRootFolder(DirectoryInfo directoryInfo) + { + var results = new List(); + + foreach (var subFolder in _diskProvider.GetDirectories(directoryInfo.FullName)) + { + var folderResults = ProcessFolder(new DirectoryInfo(subFolder), ImportMode.Auto, null); + results.AddRange(folderResults); + } + + foreach (var videoFile in _diskScanService.GetVideoFiles(directoryInfo.FullName, false)) + { + var fileResults = ProcessFile(new FileInfo(videoFile), ImportMode.Auto, null); + results.AddRange(fileResults); + } + + return results; + } + + public List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Movie movie = null, DownloadClientItem downloadClientItem = null) + { + if (_diskProvider.FolderExists(path)) + { + var directoryInfo = new DirectoryInfo(path); + + if (movie == null) + { + return ProcessFolder(directoryInfo, importMode, downloadClientItem); + } + + return ProcessFolder(directoryInfo, importMode, movie, downloadClientItem); + } + + if (_diskProvider.FileExists(path)) + { + var fileInfo = new FileInfo(path); + + if (movie == null) + { + return ProcessFile(fileInfo, importMode, downloadClientItem); + } + + return ProcessFile(fileInfo, importMode, movie, downloadClientItem); + } + + _logger.Error("Import failed, path does not exist or is not accessible by Sonarr: {0}", path); + return new List(); + } + + public bool ShouldDeleteFolder(DirectoryInfo directoryInfo, Movie movie) + { + var videoFiles = _diskScanService.GetVideoFiles(directoryInfo.FullName); + var rarFiles = _diskProvider.GetFiles(directoryInfo.FullName, SearchOption.AllDirectories).Where(f => Path.GetExtension(f) == ".rar"); + + foreach (var videoFile in videoFiles) + { + var episodeParseResult = Parser.Parser.ParseTitle(Path.GetFileName(videoFile)); + + if (episodeParseResult == null) + { + _logger.Warn("Unable to parse file on import: [{0}]", videoFile); + return false; + } + + var size = _diskProvider.GetFileSize(videoFile); + var quality = QualityParser.ParseQuality(videoFile); + + if (!_detectSample.IsSample(movie, quality, videoFile, size, episodeParseResult.IsPossibleSpecialEpisode)) + { + _logger.Warn("Non-sample file detected: [{0}]", videoFile); + return false; + } + } + + if (rarFiles.Any(f => _diskProvider.GetFileSize(f) > 10.Megabytes())) + { + _logger.Warn("RAR file detected, will require manual cleanup"); + return false; + } + + return true; + } + + private List ProcessFolder(DirectoryInfo directoryInfo, ImportMode importMode, DownloadClientItem downloadClientItem) + { + var cleanedUpName = GetCleanedUpFolderName(directoryInfo.Name); + var movie = _parsingService.GetMovie(cleanedUpName); + + if (movie == null) + { + _logger.Debug("Unknown Movie {0}", cleanedUpName); + + return new List + { + UnknownMovieResult("Unknown Movie") + }; + } + + return ProcessFolder(directoryInfo, importMode, movie, downloadClientItem); + } + + private List ProcessFolder(DirectoryInfo directoryInfo, ImportMode importMode, Movie movie, DownloadClientItem downloadClientItem) + { + if (_movieService.MoviePathExists(directoryInfo.FullName)) + { + _logger.Warn("Unable to process folder that is mapped to an existing show"); + return new List(); + } + + var cleanedUpName = GetCleanedUpFolderName(directoryInfo.Name); + var folderInfo = Parser.Parser.ParseTitle(directoryInfo.Name); + + if (folderInfo != null) + { + _logger.Debug("{0} folder quality: {1}", cleanedUpName, folderInfo.Quality); + } + + var videoFiles = _diskScanService.GetVideoFiles(directoryInfo.FullName); + + if (downloadClientItem == null) + { + foreach (var videoFile in videoFiles) + { + if (_diskProvider.IsFileLocked(videoFile)) + { + return new List + { + FileIsLockedResult(videoFile) + }; + } + } + } + + var decisions = _importDecisionMaker.GetImportDecisions(videoFiles.ToList(), movie, folderInfo, true); + var importResults = _importApprovedMovie.Import(decisions, true, downloadClientItem, importMode); + + if ((downloadClientItem == null || !downloadClientItem.IsReadOnly) && + importResults.Any(i => i.Result == ImportResultType.Imported) && + ShouldDeleteFolder(directoryInfo, movie)) + { + _logger.Debug("Deleting folder after importing valid files"); + _diskProvider.DeleteFolder(directoryInfo.FullName, true); + } + + return importResults; + } + + private List ProcessFile(FileInfo fileInfo, ImportMode importMode, DownloadClientItem downloadClientItem) + { + var movie = _parsingService.GetMovie(Path.GetFileNameWithoutExtension(fileInfo.Name)); + + if (movie == null) + { + _logger.Debug("Unknown Movie for file: {0}", fileInfo.Name); + + return new List + { + UnknownMovieResult(string.Format("Unknown Movie for file: {0}", fileInfo.Name), fileInfo.FullName) + }; + } + + return ProcessFile(fileInfo, importMode, movie, downloadClientItem); + } + + private List ProcessFile(FileInfo fileInfo, ImportMode importMode, Movie movie, DownloadClientItem downloadClientItem) + { + if (Path.GetFileNameWithoutExtension(fileInfo.Name).StartsWith("._")) + { + _logger.Debug("[{0}] starts with '._', skipping", fileInfo.FullName); + + return new List + { + new ImportResult(new ImportDecision(new LocalEpisode { Path = fileInfo.FullName }, new Rejection("Invalid video file, filename starts with '._'")), "Invalid video file, filename starts with '._'") + }; + } + + if (downloadClientItem == null) + { + if (_diskProvider.IsFileLocked(fileInfo.FullName)) + { + return new List + { + FileIsLockedResult(fileInfo.FullName) + }; + } + } + + var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo.FullName }, movie, null, true); + + return _importApprovedMovie.Import(decisions, true, downloadClientItem, importMode); + } + + private string GetCleanedUpFolderName(string folder) + { + folder = folder.Replace("_UNPACK_", "") + .Replace("_FAILED_", ""); + + return folder; + } + + private ImportResult FileIsLockedResult(string videoFile) + { + _logger.Debug("[{0}] is currently locked by another process, skipping", videoFile); + return new ImportResult(new ImportDecision(new LocalEpisode { Path = videoFile }, new Rejection("Locked file, try again later")), "Locked file, try again later"); + } + + private ImportResult UnknownMovieResult(string message, string videoFile = null) + { + var localEpisode = videoFile == null ? null : new LocalEpisode { Path = videoFile }; + + return new ImportResult(new ImportDecision(localEpisode, new Rejection("Unknown Movie")), message); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs index b517cd76c..27756cf4f 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport public interface IDetectSample { bool IsSample(Series series, QualityModel quality, string path, long size, bool isSpecial); + bool IsSample(Movie movie, QualityModel quality, string path, long size, bool isSpecial); } public class DetectSample : IDetectSample @@ -79,6 +80,57 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return false; } + public bool IsSample(Movie movie, QualityModel quality, string path, long size, bool isSpecial) + { + if (isSpecial) + { + _logger.Debug("Special, skipping sample check"); + return false; + } + + var extension = Path.GetExtension(path); + + if (extension != null && extension.Equals(".flv", StringComparison.InvariantCultureIgnoreCase)) + { + _logger.Debug("Skipping sample check for .flv file"); + return false; + } + + if (extension != null && extension.Equals(".strm", StringComparison.InvariantCultureIgnoreCase)) + { + _logger.Debug("Skipping sample check for .strm file"); + return false; + } + + try + { + var runTime = _videoFileInfoReader.GetRunTime(path); + var minimumRuntime = GetMinimumAllowedRuntime(movie); + + if (runTime.TotalMinutes.Equals(0)) + { + _logger.Error("[{0}] has a runtime of 0, is it a valid video file?", path); + return true; + } + + if (runTime.TotalSeconds < minimumRuntime) + { + _logger.Debug("[{0}] appears to be a sample. Runtime: {1} seconds. Expected at least: {2} seconds", path, runTime, minimumRuntime); + return true; + } + } + + catch (DllNotFoundException) + { + _logger.Debug("Falling back to file size detection"); + + return CheckSize(size, quality); + } + + _logger.Debug("Runtime is over 90 seconds"); + return false; + } + private bool CheckSize(long size, QualityModel quality) { if (_largeSampleSizeQualities.Contains(quality.Quality)) @@ -99,6 +151,11 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return false; } + private int GetMinimumAllowedRuntime(Movie movie) + { + return 120; //2 minutes + } + private int GetMinimumAllowedRuntime(Series series) { //Webisodes - 90 seconds diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs index 86abb87b7..4dc6bcaf6 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs @@ -6,5 +6,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport public interface IImportDecisionEngineSpecification { Decision IsSatisfiedBy(LocalEpisode localEpisode); + + Decision IsSatisfiedBy(LocalMovie localMovie); } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedMovie.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedMovie.cs new file mode 100644 index 000000000..6d766a0e3 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedMovie.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Download; +using NzbDrone.Core.Extras; + + +namespace NzbDrone.Core.MediaFiles.EpisodeImport +{ + public interface IImportApprovedMovie + { + List Import(List decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto); + } + + public class ImportApprovedMovie : IImportApprovedMovie + { + private readonly IUpgradeMediaFiles _episodeFileUpgrader; + private readonly IMediaFileService _mediaFileService; + private readonly IExtraService _extraService; + private readonly IDiskProvider _diskProvider; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public ImportApprovedMovie(IUpgradeMediaFiles episodeFileUpgrader, + IMediaFileService mediaFileService, + IExtraService extraService, + IDiskProvider diskProvider, + IEventAggregator eventAggregator, + Logger logger) + { + _episodeFileUpgrader = episodeFileUpgrader; + _mediaFileService = mediaFileService; + _extraService = extraService; + _diskProvider = diskProvider; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public List Import(List decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto) + { + var qualifiedImports = decisions.Where(c => c.Approved) + .GroupBy(c => c.LocalMovie.Movie.Id, (i, s) => s + .OrderByDescending(c => c.LocalMovie.Quality, new QualityModelComparer(s.First().LocalMovie.Movie.Profile)) + .ThenByDescending(c => c.LocalMovie.Size)) + .SelectMany(c => c) + .ToList(); + + var importResults = new List(); + + foreach (var importDecision in qualifiedImports.OrderBy(e => e.LocalMovie.Size) + .ThenByDescending(e => e.LocalMovie.Size)) + { + var localMovie = importDecision.LocalMovie; + var oldFiles = new List(); + + try + { + //check if already imported + if (importResults.Select(r => r.ImportDecision.LocalMovie.Movie) + .Select(e => e.Id).Contains(localMovie.Movie.Id)) + { + importResults.Add(new ImportResult(importDecision, "Movie has already been imported")); + continue; + } + + var episodeFile = new MovieFile(); + episodeFile.DateAdded = DateTime.UtcNow; + episodeFile.MovieId = localMovie.Movie.Id; + episodeFile.Path = localMovie.Path.CleanFilePath(); + episodeFile.Size = _diskProvider.GetFileSize(localMovie.Path); + episodeFile.Quality = localMovie.Quality; + episodeFile.MediaInfo = localMovie.MediaInfo; + episodeFile.Movie = localMovie.Movie; + episodeFile.ReleaseGroup = localMovie.ParsedEpisodeInfo.ReleaseGroup; + + bool copyOnly; + switch (importMode) + { + default: + case ImportMode.Auto: + copyOnly = downloadClientItem != null && downloadClientItem.IsReadOnly; + break; + case ImportMode.Move: + copyOnly = false; + break; + case ImportMode.Copy: + copyOnly = true; + break; + } + + if (newDownload) + { + episodeFile.SceneName = GetSceneName(downloadClientItem, localMovie); + + var moveResult = _episodeFileUpgrader.UpgradeMovieFile(episodeFile, localMovie, copyOnly); + oldFiles = moveResult.OldFiles; + } + else + { + episodeFile.RelativePath = localMovie.Movie.Path.GetRelativePath(episodeFile.Path); + } + + _mediaFileService.Add(episodeFile); + importResults.Add(new ImportResult(importDecision)); + + if (newDownload) + { + //_extraService.ImportExtraFiles(localMovie, episodeFile, copyOnly); TODO update for movie + } + + if (downloadClientItem != null) + { + _eventAggregator.PublishEvent(new MovieImportedEvent(localMovie, episodeFile, newDownload, downloadClientItem.DownloadClient, downloadClientItem.DownloadId, downloadClientItem.IsReadOnly)); + } + else + { + _eventAggregator.PublishEvent(new MovieImportedEvent(localMovie, episodeFile, newDownload)); + } + + if (newDownload) + { + _eventAggregator.PublishEvent(new MovieDownloadedEvent(localMovie, episodeFile, oldFiles)); + } + } + catch (Exception e) + { + _logger.Warn(e, "Couldn't import episode " + localMovie); + importResults.Add(new ImportResult(importDecision, "Failed to import episode")); + } + } + + //Adding all the rejected decisions + importResults.AddRange(decisions.Where(c => !c.Approved) + .Select(d => new ImportResult(d, d.Rejections.Select(r => r.Reason).ToArray()))); + + return importResults; + } + + private string GetSceneName(DownloadClientItem downloadClientItem, LocalMovie localMovie) + { + if (downloadClientItem != null) + { + var title = Parser.Parser.RemoveFileExtension(downloadClientItem.Title); + + var parsedTitle = Parser.Parser.ParseTitle(title); + + if (parsedTitle != null && !parsedTitle.FullSeason) + { + return title; + } + } + + var fileName = Path.GetFileNameWithoutExtension(localMovie.Path.CleanFilePath()); + + if (SceneChecker.IsSceneTitle(fileName)) + { + return fileName; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs index 5e4e2ede2..8bb5e78ea 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport public class ImportDecision { public LocalEpisode LocalEpisode { get; private set; } + public LocalMovie LocalMovie { get; private set; } public IEnumerable Rejections { get; private set; } public bool Approved => Rejections.Empty(); @@ -18,5 +19,20 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport LocalEpisode = localEpisode; Rejections = rejections.ToList(); } + + public ImportDecision(LocalMovie localMovie, params Rejection[] rejections) + { + LocalMovie = localMovie; + Rejections = rejections.ToList(); + LocalEpisode = new LocalEpisode + { + Quality = localMovie.Quality, + ExistingFile = localMovie.ExistingFile, + MediaInfo = localMovie.MediaInfo, + ParsedEpisodeInfo = localMovie.ParsedEpisodeInfo, + Path = localMovie.Path, + Size = localMovie.Size + }; + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs index 5594ebe97..52ffdfa07 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -98,36 +98,32 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport { ImportDecision decision = null; - /*try + try { - var localEpisode = _parsingService.GetLocalEpisode(file, movie, shouldUseFolderName ? folderInfo : null, sceneSource); + var localMovie = _parsingService.GetLocalMovie(file, movie, shouldUseFolderName ? folderInfo : null, sceneSource); - if (localEpisode != null) + if (localMovie != null) { - localEpisode.Quality = GetQuality(folderInfo, localEpisode.Quality, movie); - localEpisode.Size = _diskProvider.GetFileSize(file); + localMovie.Quality = GetQuality(folderInfo, localMovie.Quality, movie); + localMovie.Size = _diskProvider.GetFileSize(file); - _logger.Debug("Size: {0}", localEpisode.Size); + _logger.Debug("Size: {0}", localMovie.Size); //TODO: make it so media info doesn't ruin the import process of a new series if (sceneSource) { - localEpisode.MediaInfo = _videoFileInfoReader.GetMediaInfo(file); - } - - if (localEpisode.Episodes.Empty()) - { - decision = new ImportDecision(localEpisode, new Rejection("Invalid season or episode")); + localMovie.MediaInfo = _videoFileInfoReader.GetMediaInfo(file); + decision = GetDecision(localMovie); } else { - decision = GetDecision(localEpisode); + decision = GetDecision(localMovie); } } else { - localEpisode = new LocalEpisode(); + var localEpisode = new LocalEpisode(); localEpisode.Path = file; decision = new ImportDecision(localEpisode, new Rejection("Unable to parse file")); @@ -139,13 +135,23 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport var localEpisode = new LocalEpisode { Path = file }; decision = new ImportDecision(localEpisode, new Rejection("Unexpected error processing file")); - }*/ + } + + //LocalMovie nullMovie = null; - decision = new ImportDecision(null, new Rejection("IMPLEMENTATION MISSING!!!")); + //decision = new ImportDecision(nullMovie, new Rejection("IMPLEMENTATION MISSING!!!")); return decision; } + private ImportDecision GetDecision(LocalMovie localMovie) + { + var reasons = _specifications.Select(c => EvaluateSpec(c, localMovie)) + .Where(c => c != null); + + return new ImportDecision(localMovie, reasons.ToArray()); + } + private ImportDecision GetDecision(string file, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource, bool shouldUseFolderName) { ImportDecision decision = null; @@ -204,6 +210,33 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return new ImportDecision(localEpisode, reasons.ToArray()); } + private Rejection EvaluateSpec(IImportDecisionEngineSpecification spec, LocalMovie localMovie) + { + try + { + var result = spec.IsSatisfiedBy(localMovie); + + if (!result.Accepted) + { + return new Rejection(result.Reason); + } + } + catch (NotImplementedException e) + { + _logger.Warn(e, "Spec " + spec.ToString() + " currently does not implement evaluation for movies."); + return null; + } + catch (Exception e) + { + //e.Data.Add("report", remoteEpisode.Report.ToJson()); + //e.Data.Add("parsed", remoteEpisode.ParsedEpisodeInfo.ToJson()); + _logger.Error(e, "Couldn't evaluate decision on " + localMovie.Path); + return new Rejection(string.Format("{0}: {1}", spec.GetType().Name, e.Message)); + } + + return null; + } + private Rejection EvaluateSpec(IImportDecisionEngineSpecification spec, LocalEpisode localEpisode) { try @@ -292,6 +325,17 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport }) == 1; } + private QualityModel GetQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Movie movie) + { + if (UseFolderQuality(folderInfo, fileQuality, movie)) + { + _logger.Debug("Using quality from folder: {0}", folderInfo.Quality); + return folderInfo.Quality; + } + + return fileQuality; + } + private QualityModel GetQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Series series) { if (UseFolderQuality(folderInfo, fileQuality, series)) @@ -303,6 +347,31 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return fileQuality; } + private bool UseFolderQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Movie movie) + { + if (folderInfo == null) + { + return false; + } + + if (folderInfo.Quality.Quality == Quality.Unknown) + { + return false; + } + + if (fileQuality.QualitySource == QualitySource.Extension) + { + return true; + } + + if (new QualityModelComparer(movie.Profile).Compare(folderInfo.Quality, fileQuality) > 0) + { + return true; + } + + return false; + } + private bool UseFolderQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Series series) { if (folderInfo == null) diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs index 158059e29..1e8432e84 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs @@ -63,5 +63,48 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + if (_configService.SkipFreeSpaceCheckWhenImporting) + { + _logger.Debug("Skipping free space check when importing"); + return Decision.Accept(); + } + + try + { + if (localMovie.ExistingFile) + { + _logger.Debug("Skipping free space check for existing episode"); + return Decision.Accept(); + } + + var path = Directory.GetParent(localMovie.Movie.Path); + var freeSpace = _diskProvider.GetAvailableSpace(path.FullName); + + if (!freeSpace.HasValue) + { + _logger.Debug("Free space check returned an invalid result for: {0}", path); + return Decision.Accept(); + } + + if (freeSpace < localMovie.Size + 100.Megabytes()) + { + _logger.Warn("Not enough free space ({0}) to import: {1} ({2})", freeSpace, localMovie, localMovie.Size); + return Decision.Reject("Not enough free space"); + } + } + catch (DirectoryNotFoundException ex) + { + _logger.Error("Unable to check free disk space while importing. " + ex.Message); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to check free disk space while importing: " + localMovie.Path); + } + + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs index 853f6b1b7..2daeda6cb 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs @@ -17,11 +17,16 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications { if (localEpisode.ParsedEpisodeInfo.FullSeason) { - //_logger.Debug("Single episode file detected as containing all episodes in the season"); //Not needed for Movies mwhahahahah - //return Decision.Reject("Single episode file contains all episodes in seasons"); + _logger.Debug("Single episode file detected as containing all episodes in the season"); //Not needed for Movies mwhahahahah + return Decision.Reject("Single episode file contains all episodes in seasons"); } return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs index 79ef96f88..55a8da073 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Linq; using NLog; using NzbDrone.Core.DecisionEngine; @@ -14,6 +15,37 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications { _logger = logger; } + + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + if (localMovie.ExistingFile) + { + return Decision.Accept(); + } + + var dirInfo = new FileInfo(localMovie.Path).Directory; + + if (dirInfo == null) + { + return Decision.Accept(); + } + + var folderInfo = Parser.Parser.ParseTitle(dirInfo.Name); + + if (folderInfo == null) + { + return Decision.Accept(); + } + + if (folderInfo.FullSeason) + { + return Decision.Accept(); + } + + return Decision.Accept(); + } + + public Decision IsSatisfiedBy(LocalEpisode localEpisode) { if (localEpisode.ExistingFile) diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs index c7b61d802..536ea093a 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs @@ -37,5 +37,27 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localEpisode) + { + if (localEpisode.ExistingFile) + { + _logger.Debug("Existing file, skipping sample check"); + return Decision.Accept(); + } + + var sample = _detectSample.IsSample(localEpisode.Movie, + localEpisode.Quality, + localEpisode.Path, + localEpisode.Size, + false); + + if (sample) + { + return Decision.Reject("Sample"); + } + + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs index 2260ed71a..a19359457 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs @@ -56,5 +56,40 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localEpisode) + { + if (localEpisode.ExistingFile) + { + _logger.Debug("{0} is in series folder, skipping unpacking check", localEpisode.Path); + return Decision.Accept(); + } + + foreach (var workingFolder in _configService.DownloadClientWorkingFolders.Split('|')) + { + DirectoryInfo parent = Directory.GetParent(localEpisode.Path); + while (parent != null) + { + if (parent.Name.StartsWith(workingFolder)) + { + if (OsInfo.IsNotWindows) + { + _logger.Debug("{0} is still being unpacked", localEpisode.Path); + return Decision.Reject("File is still being unpacked"); + } + + if (_diskProvider.FileGetLastWrite(localEpisode.Path) > DateTime.UtcNow.AddMinutes(-5)) + { + _logger.Debug("{0} appears to be unpacking still", localEpisode.Path); + return Decision.Reject("File is still being unpacked"); + } + } + + parent = parent.Parent; + } + } + + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs index ee6c02c53..c24d62aaa 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs @@ -1,3 +1,4 @@ +using System; using NLog; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Parser.Model; @@ -27,5 +28,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger.Debug("Episode file on disk contains more episodes than this file contains"); return Decision.Reject("Episode file on disk contains more episodes than this file contains"); } + + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs index ce65eb304..85becc0ba 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using NLog; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Parser.Model; @@ -13,6 +14,11 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + return Decision.Accept(); + } + public Decision IsSatisfiedBy(LocalEpisode localEpisode) { if (localEpisode.ExistingFile) diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs index 3d07306af..b2d2c2c33 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs @@ -26,5 +26,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localEpisode) + { + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieDownloadedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieDownloadedEvent.cs new file mode 100644 index 000000000..427996088 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieDownloadedEvent.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieDownloadedEvent : IEvent + { + public LocalMovie Movie { get; private set; } + public MovieFile MovieFile { get; private set; } + public List OldFiles { get; private set; } + + public MovieDownloadedEvent(LocalMovie episode, MovieFile episodeFile, List oldFiles) + { + Movie = episode; + MovieFile = episodeFile; + OldFiles = oldFiles; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieFileAddedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieFileAddedEvent.cs new file mode 100644 index 000000000..17f93dc0b --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieFileAddedEvent.cs @@ -0,0 +1,14 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieFileAddedEvent : IEvent + { + public MovieFile MovieFile { get; private set; } + + public MovieFileAddedEvent(MovieFile episodeFile) + { + MovieFile = episodeFile; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieFileDeletedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieFileDeletedEvent.cs new file mode 100644 index 000000000..232f1686f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieFileDeletedEvent.cs @@ -0,0 +1,16 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieFileDeletedEvent : IEvent + { + public MovieFile MovieFile { get; private set; } + public DeleteMediaFileReason Reason { get; private set; } + + public MovieFileDeletedEvent(MovieFile episodeFile, DeleteMediaFileReason reason) + { + MovieFile = episodeFile; + Reason = reason; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieFolderCreatedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieFolderCreatedEvent.cs new file mode 100644 index 000000000..a26031413 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieFolderCreatedEvent.cs @@ -0,0 +1,20 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieFolderCreatedEvent : IEvent + { + public Movie Movie { get; private set; } + public MovieFile MovieFile { get; private set; } + public string SeriesFolder { get; set; } + public string SeasonFolder { get; set; } + public string MovieFolder { get; set; } + + public MovieFolderCreatedEvent(Movie movie, MovieFile episodeFile) + { + Movie = movie; + MovieFile = episodeFile; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieImportedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieImportedEvent.cs new file mode 100644 index 000000000..91df27e3c --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieImportedEvent.cs @@ -0,0 +1,32 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieImportedEvent : IEvent + { + public LocalMovie MovieInfo { get; private set; } + public MovieFile ImportedMovie { get; private set; } + public bool NewDownload { get; private set; } + public string DownloadClient { get; private set; } + public string DownloadId { get; private set; } + public bool IsReadOnly { get; set; } + + public MovieImportedEvent(LocalMovie episodeInfo, MovieFile importedMovie, bool newDownload) + { + MovieInfo = episodeInfo; + ImportedMovie = importedMovie; + NewDownload = newDownload; + } + + public MovieImportedEvent(LocalMovie episodeInfo, MovieFile importedMovie, bool newDownload, string downloadClient, string downloadId, bool isReadOnly) + { + MovieInfo = episodeInfo; + ImportedMovie = importedMovie; + NewDownload = newDownload; + DownloadClient = downloadClient; + DownloadId = downloadId; + IsReadOnly = isReadOnly; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index 051e85863..e3e82daa5 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -7,16 +7,20 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Tv; using NzbDrone.Core.Tv.Events; using NzbDrone.Common; +using System; namespace NzbDrone.Core.MediaFiles { public interface IMediaFileService { + MovieFile Add(MovieFile episodeFile); + void Update(MovieFile episodeFile); + void Delete(MovieFile episodeFile, DeleteMediaFileReason reason); EpisodeFile Add(EpisodeFile episodeFile); void Update(EpisodeFile episodeFile); void Delete(EpisodeFile episodeFile, DeleteMediaFileReason reason); List GetFilesBySeries(int seriesId); - List GetFilesByMovie(int movieId); + List GetFilesByMovie(int movieId); List GetFilesBySeason(int seriesId, int seasonNumber); List GetFilesWithoutMediaInfo(); List FilterExistingFiles(List files, Series series); @@ -30,12 +34,14 @@ namespace NzbDrone.Core.MediaFiles { private readonly IEventAggregator _eventAggregator; private readonly IMediaFileRepository _mediaFileRepository; + private readonly IMovieFileRepository _movieFileRepository; private readonly Logger _logger; - public MediaFileService(IMediaFileRepository mediaFileRepository, IEventAggregator eventAggregator, Logger logger) + public MediaFileService(IMediaFileRepository mediaFileRepository, IMovieFileRepository movieFileRepository, IEventAggregator eventAggregator, Logger logger) { _mediaFileRepository = mediaFileRepository; _eventAggregator = eventAggregator; + _movieFileRepository = movieFileRepository; _logger = logger; } @@ -61,14 +67,24 @@ namespace NzbDrone.Core.MediaFiles _eventAggregator.PublishEvent(new EpisodeFileDeletedEvent(episodeFile, reason)); } + public void Delete(MovieFile episodeFile, DeleteMediaFileReason reason) + { + //Little hack so we have the episodes and series attached for the event consumers + episodeFile.Movie.LazyLoad(); + episodeFile.Path = Path.Combine(episodeFile.Movie.Value.Path, episodeFile.RelativePath); + + _movieFileRepository.Delete(episodeFile); + _eventAggregator.PublishEvent(new MovieFileDeletedEvent(episodeFile, reason)); + } + public List GetFilesBySeries(int seriesId) { return _mediaFileRepository.GetFilesBySeries(seriesId); } - public List GetFilesByMovie(int movieId) + public List GetFilesByMovie(int movieId) { - return _mediaFileRepository.GetFilesBySeries(movieId); //TODO: Update implementation for movie files. + return _movieFileRepository.GetFilesByMovie(movieId); //TODO: Update implementation for movie files. } public List GetFilesBySeason(int seriesId, int seasonNumber) @@ -114,5 +130,18 @@ namespace NzbDrone.Core.MediaFiles var files = GetFilesBySeries(message.Series.Id); _mediaFileRepository.DeleteMany(files); } + + public MovieFile Add(MovieFile episodeFile) + { + var addedFile = _movieFileRepository.Insert(episodeFile); + _eventAggregator.PublishEvent(new MovieFileAddedEvent(addedFile)); + return addedFile; + } + + public void Update(MovieFile episodeFile) + { + _movieFileRepository.Update(episodeFile); + } + } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MovieFile.cs b/src/NzbDrone.Core/MediaFiles/MovieFile.cs new file mode 100644 index 000000000..dfb753ab6 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MovieFile.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using Marr.Data; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; +using NzbDrone.Core.MediaFiles.MediaInfo; + +namespace NzbDrone.Core.MediaFiles +{ + public class MovieFile : ModelBase + { + public int MovieId { get; set; } + public string RelativePath { get; set; } + public string Path { get; set; } + public long Size { get; set; } + public DateTime DateAdded { get; set; } + public string SceneName { get; set; } + public string ReleaseGroup { get; set; } + public QualityModel Quality { get; set; } + public MediaInfoModel MediaInfo { get; set; } + public LazyLoaded Movie { get; set; } + + public override string ToString() + { + return string.Format("[{0}] {1}", Id, RelativePath); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MovieFileMoveResult.cs b/src/NzbDrone.Core/MediaFiles/MovieFileMoveResult.cs new file mode 100644 index 000000000..a52faed61 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MovieFileMoveResult.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.MediaFiles +{ + public class MovieFileMoveResult + { + public MovieFileMoveResult() + { + OldFiles = new List(); + } + + public MovieFile MovieFile { get; set; } + public List OldFiles { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MovieFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/MovieFileMovingService.cs new file mode 100644 index 000000000..cf8acd6f9 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MovieFileMovingService.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IMoveMovieFiles + { + MovieFile MoveMovieFile(MovieFile movieFile, Movie movie); + MovieFile MoveMovieFile(MovieFile movieFile, LocalMovie localMovie); + MovieFile CopyMovieFile(MovieFile movieFile, LocalMovie localMovie); + } + + public class MovieFileMovingService : IMoveMovieFiles + { + private readonly IMovieService _movieService; + private readonly IUpdateMovieFileService _updateMovieFileService; + private readonly IBuildFileNames _buildFileNames; + private readonly IDiskTransferService _diskTransferService; + private readonly IDiskProvider _diskProvider; + private readonly IMediaFileAttributeService _mediaFileAttributeService; + private readonly IEventAggregator _eventAggregator; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public MovieFileMovingService(IMovieService movieService, + IUpdateMovieFileService updateMovieFileService, + IBuildFileNames buildFileNames, + IDiskTransferService diskTransferService, + IDiskProvider diskProvider, + IMediaFileAttributeService mediaFileAttributeService, + IEventAggregator eventAggregator, + IConfigService configService, + Logger logger) + { + _movieService = movieService; + _updateMovieFileService = updateMovieFileService; + _buildFileNames = buildFileNames; + _diskTransferService = diskTransferService; + _diskProvider = diskProvider; + _mediaFileAttributeService = mediaFileAttributeService; + _eventAggregator = eventAggregator; + _configService = configService; + _logger = logger; + } + + public MovieFile MoveMovieFile(MovieFile movieFile, Movie movie) + { + var newFileName = _buildFileNames.BuildFileName(movie, movieFile); + var filePath = _buildFileNames.BuildFilePath(movie, newFileName, Path.GetExtension(movieFile.RelativePath)); + + EnsureMovieFolder(movieFile, movie, filePath); + + _logger.Debug("Renaming movie file: {0} to {1}", movieFile, filePath); + + return TransferFile(movieFile, movie, filePath, TransferMode.Move); + } + + public MovieFile MoveMovieFile(MovieFile movieFile, LocalMovie localMovie) + { + var newFileName = _buildFileNames.BuildFileName(localMovie.Movie, movieFile); + var filePath = _buildFileNames.BuildFilePath(localMovie.Movie, newFileName, Path.GetExtension(localMovie.Path)); + + EnsureMovieFolder(movieFile, localMovie, filePath); + + _logger.Debug("Moving movie file: {0} to {1}", movieFile.Path, filePath); + + return TransferFile(movieFile, localMovie.Movie, filePath, TransferMode.Move); + } + + public MovieFile CopyMovieFile(MovieFile movieFile, LocalMovie localMovie) + { + var newFileName = _buildFileNames.BuildFileName(localMovie.Movie, movieFile); + var filePath = _buildFileNames.BuildFilePath(localMovie.Movie, newFileName, Path.GetExtension(localMovie.Path)); + + EnsureMovieFolder(movieFile, localMovie, filePath); + + if (_configService.CopyUsingHardlinks) + { + _logger.Debug("Hardlinking movie file: {0} to {1}", movieFile.Path, filePath); + return TransferFile(movieFile, localMovie.Movie, filePath, TransferMode.HardLinkOrCopy); + } + + _logger.Debug("Copying movie file: {0} to {1}", movieFile.Path, filePath); + return TransferFile(movieFile, localMovie.Movie, filePath, TransferMode.Copy); + } + + private MovieFile TransferFile(MovieFile movieFile, Movie movie, string destinationFilePath, TransferMode mode) + { + Ensure.That(movieFile, () => movieFile).IsNotNull(); + Ensure.That(movie,() => movie).IsNotNull(); + Ensure.That(destinationFilePath, () => destinationFilePath).IsValidPath(); + + var movieFilePath = movieFile.Path ?? Path.Combine(movie.Path, movieFile.RelativePath); + + if (!_diskProvider.FileExists(movieFilePath)) + { + throw new FileNotFoundException("Movie file path does not exist", movieFilePath); + } + + if (movieFilePath == destinationFilePath) + { + throw new SameFilenameException("File not moved, source and destination are the same", movieFilePath); + } + + _diskTransferService.TransferFile(movieFilePath, destinationFilePath, mode); + + movieFile.RelativePath = movie.Path.GetRelativePath(destinationFilePath); + + _updateMovieFileService.ChangeFileDateForFile(movieFile, movie); + + try + { + _mediaFileAttributeService.SetFolderLastWriteTime(movie.Path, movieFile.DateAdded); + } + + catch (Exception ex) + { + _logger.Warn(ex, "Unable to set last write time"); + } + + _mediaFileAttributeService.SetFilePermissions(destinationFilePath); + + return movieFile; + } + + private void EnsureMovieFolder(MovieFile movieFile, LocalMovie localMovie, string filePath) + { + EnsureMovieFolder(movieFile, localMovie.Movie, filePath); + } + + private void EnsureMovieFolder(MovieFile movieFile, Movie movie, string filePath) + { + var movieFolder = Path.GetDirectoryName(filePath); + var rootFolder = new OsPath(movieFolder).Directory.FullPath; + + if (!_diskProvider.FolderExists(rootFolder)) + { + throw new DirectoryNotFoundException(string.Format("Root folder '{0}' was not found.", rootFolder)); + } + + var changed = false; + var newEvent = new MovieFolderCreatedEvent(movie, movieFile); + + if (!_diskProvider.FolderExists(movieFolder)) + { + CreateFolder(movieFolder); + newEvent.SeriesFolder = movieFolder; + changed = true; + } + + if (changed) + { + _eventAggregator.PublishEvent(newEvent); + } + } + + private void CreateFolder(string directoryName) + { + Ensure.That(directoryName, () => directoryName).IsNotNullOrWhiteSpace(); + + var parentFolder = new OsPath(directoryName).Directory.FullPath; + if (!_diskProvider.FolderExists(parentFolder)) + { + CreateFolder(parentFolder); + } + + try + { + _diskProvider.CreateFolder(directoryName); + } + catch (IOException ex) + { + _logger.Error(ex, "Unable to create directory: " + directoryName); + } + + _mediaFileAttributeService.SetFolderPermissions(directoryName); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MovieFileRepository.cs b/src/NzbDrone.Core/MediaFiles/MovieFileRepository.cs new file mode 100644 index 000000000..9ed89b85f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MovieFileRepository.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + + +namespace NzbDrone.Core.MediaFiles +{ + public interface IMovieFileRepository : IBasicRepository + { + List GetFilesByMovie(int movieId); + List GetFilesWithoutMediaInfo(); + } + + + public class MovieFileRepository : BasicRepository, IMovieFileRepository + { + public MovieFileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public List GetFilesByMovie(int movieId) + { + return Query.Where(c => c.MovieId == movieId).ToList(); + } + + public List GetFilesWithoutMediaInfo() + { + return Query.Where(c => c.MediaInfo == null).ToList(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/UpdateMovieFileService.cs b/src/NzbDrone.Core/MediaFiles/UpdateMovieFileService.cs new file mode 100644 index 000000000..af45d8831 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/UpdateMovieFileService.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Exceptron; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IUpdateMovieFileService + { + void ChangeFileDateForFile(MovieFile movieFile, Movie movie); + } + + public class UpdateMovieFileService : IUpdateMovieFileService, + IHandle + { + private readonly IDiskProvider _diskProvider; + private readonly IConfigService _configService; + private readonly IMovieService _movieService; + private readonly Logger _logger; + + public UpdateMovieFileService(IDiskProvider diskProvider, + IConfigService configService, + IMovieService movieService, + Logger logger) + { + _diskProvider = diskProvider; + _configService = configService; + _movieService = movieService; + _logger = logger; + } + + public void ChangeFileDateForFile(MovieFile movieFile, Movie movie) + { + ChangeFileDate(movieFile, movie); + } + + private bool ChangeFileDate(MovieFile movieFile, Movie movie) + { + var movieFilePath = Path.Combine(movie.Path, movieFile.RelativePath); + + return false; + } + + public void Handle(SeriesScannedEvent message) + { + if (_configService.FileDate == FileDateType.None) + { + return; + } + + /* var movies = _movieService.MoviesWithFiles(message.Series.Id); + + var movieFiles = new List(); + var updated = new List(); + + foreach (var group in movies.GroupBy(e => e.MovieFileId)) + { + var moviesInFile = group.Select(e => e).ToList(); + var movieFile = moviesInFile.First().MovieFile; + + movieFiles.Add(movieFile); + + if (ChangeFileDate(movieFile, message.Series, moviesInFile)) + { + updated.Add(movieFile); + } + } + + if (updated.Any()) + { + _logger.ProgressDebug("Changed file date for {0} files of {1} in {2}", updated.Count, movieFiles.Count, message.Series.Title); + } + + else + { + _logger.ProgressDebug("No file dates changed for {0}", message.Series.Title); + }*/ + } + + private bool ChangeFileDateToLocalAirDate(string filePath, string fileDate, string fileTime) + { + DateTime airDate; + + if (DateTime.TryParse(fileDate + ' ' + fileTime, out airDate)) + { + // avoiding false +ve checks and set date skewing by not using UTC (Windows) + DateTime oldDateTime = _diskProvider.FileGetLastWrite(filePath); + + if (!DateTime.Equals(airDate, oldDateTime)) + { + try + { + _diskProvider.FileSetLastWriteTime(filePath, airDate); + _logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldDateTime, airDate); + + return true; + } + + catch (Exception ex) + { + _logger.Warn(ex, "Unable to set date of file [" + filePath + "]"); + } + } + } + + else + { + _logger.Debug("Could not create valid date to change file [{0}]", filePath); + } + + return false; + } + + private bool ChangeFileDateToUtcAirDate(string filePath, DateTime airDateUtc) + { + DateTime oldLastWrite = _diskProvider.FileGetLastWrite(filePath); + + if (!DateTime.Equals(airDateUtc, oldLastWrite)) + { + try + { + _diskProvider.FileSetLastWriteTime(filePath, airDateUtc); + _logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldLastWrite, airDateUtc); + + return true; + } + + catch (Exception ex) + { + ex.ExceptronIgnoreOnMono(); + _logger.Warn(ex, "Unable to set date of file [" + filePath + "]"); + } + } + + return false; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs index 95f245e3e..b8cd9f36d 100644 --- a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.MediaFiles public interface IUpgradeMediaFiles { EpisodeFileMoveResult UpgradeEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode, bool copyOnly = false); + MovieFileMoveResult UpgradeMovieFile(MovieFile movieFile, LocalMovie localMovie, bool copyOnly = false); } public class UpgradeMediaFileService : IUpgradeMediaFiles @@ -16,22 +17,59 @@ namespace NzbDrone.Core.MediaFiles private readonly IRecycleBinProvider _recycleBinProvider; private readonly IMediaFileService _mediaFileService; private readonly IMoveEpisodeFiles _episodeFileMover; + private readonly IMoveMovieFiles _movieFileMover; private readonly IDiskProvider _diskProvider; private readonly Logger _logger; public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider, IMediaFileService mediaFileService, IMoveEpisodeFiles episodeFileMover, + IMoveMovieFiles movieFileMover, IDiskProvider diskProvider, Logger logger) { _recycleBinProvider = recycleBinProvider; _mediaFileService = mediaFileService; _episodeFileMover = episodeFileMover; + _movieFileMover = movieFileMover; _diskProvider = diskProvider; _logger = logger; } + public MovieFileMoveResult UpgradeMovieFile(MovieFile episodeFile, LocalMovie localEpisode, bool copyOnly = false) + { + var moveFileResult = new MovieFileMoveResult(); + var existingFile = localEpisode.Movie.MovieFile; + + if (existingFile.IsLoaded) + { + var file = existingFile.Value; + var episodeFilePath = Path.Combine(localEpisode.Movie.Path, file.RelativePath); + + if (_diskProvider.FileExists(episodeFilePath)) + { + _logger.Debug("Removing existing episode file: {0}", file); + _recycleBinProvider.DeleteFile(episodeFilePath); + } + + moveFileResult.OldFiles.Add(file); + _mediaFileService.Delete(file, DeleteMediaFileReason.Upgrade); + } + + + + if (copyOnly) + { + moveFileResult.MovieFile = _movieFileMover.CopyMovieFile(episodeFile, localEpisode); + } + else + { + moveFileResult.MovieFile= _movieFileMover.MoveMovieFile(episodeFile, localEpisode); + } + + return moveFileResult; + } + public EpisodeFileMoveResult UpgradeEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode, bool copyOnly = false) { var moveFileResult = new EpisodeFileMoveResult(); diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 4bc218fe3..cb3dfcba2 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -249,6 +249,7 @@ + @@ -564,6 +565,8 @@ + + @@ -698,6 +701,17 @@ + + + + + + + + + + + @@ -760,6 +774,7 @@ + @@ -873,6 +888,7 @@ + @@ -1094,6 +1110,7 @@ + diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 4d7773ad7..bac486ec1 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -17,6 +17,8 @@ namespace NzbDrone.Core.Organizer public interface IBuildFileNames { string BuildFileName(List episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig = null); + string BuildFileName(Movie movie, MovieFile movieFile, NamingConfig namingConfig = null); + string BuildFilePath(Movie movie, string fileName, string extension); string BuildFilePath(Series series, int seasonNumber, string fileName, string extension); string BuildSeasonPath(Series series, int seasonNumber); BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); @@ -137,6 +139,66 @@ namespace NzbDrone.Core.Organizer return fileName; } + public string BuildFileName(Movie movie, MovieFile movieFile, NamingConfig namingConfig = null) + { + if (namingConfig == null) + { + namingConfig = _namingConfigService.GetConfig(); + } + + if (!namingConfig.RenameEpisodes) + { + return GetOriginalTitle(movieFile); + } + + /*if (namingConfig.StandardEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Standard) + { + throw new NamingFormatException("Standard episode format cannot be empty"); + } + + if (namingConfig.DailyEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Daily) + { + throw new NamingFormatException("Daily episode format cannot be empty"); + } + + if (namingConfig.AnimeEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Anime) + { + throw new NamingFormatException("Anime episode format cannot be empty"); + }*/ + + /*var pattern = namingConfig.StandardEpisodeFormat; + var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); + + episodes = episodes.OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber).ToList(); + + if (series.SeriesType == SeriesTypes.Daily) + { + pattern = namingConfig.DailyEpisodeFormat; + } + + if (series.SeriesType == SeriesTypes.Anime && episodes.All(e => e.AbsoluteEpisodeNumber.HasValue)) + { + pattern = namingConfig.AnimeEpisodeFormat; + } + + pattern = AddSeasonEpisodeNumberingTokens(pattern, tokenHandlers, episodes, namingConfig); + pattern = AddAbsoluteNumberingTokens(pattern, tokenHandlers, series, episodes, namingConfig); + + AddSeriesTokens(tokenHandlers, series); + AddEpisodeTokens(tokenHandlers, episodes); + AddEpisodeFileTokens(tokenHandlers, episodeFile); + AddQualityTokens(tokenHandlers, series, episodeFile); + AddMediaInfoTokens(tokenHandlers, episodeFile); + + var fileName = ReplaceTokens(pattern, tokenHandlers, namingConfig).Trim(); + fileName = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString()); + fileName = TrimSeparatorsRegex.Replace(fileName, string.Empty);*/ + + //TODO: Update namingConfig for Movies! + + return GetOriginalTitle(movieFile); + } + public string BuildFilePath(Series series, int seasonNumber, string fileName, string extension) { Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace(); @@ -146,6 +208,15 @@ namespace NzbDrone.Core.Organizer return Path.Combine(path, fileName + extension); } + public string BuildFilePath(Movie movie, string fileName, string extension) + { + Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace(); + + var path = movie.Path; + + return Path.Combine(path, fileName + extension); + } + public string BuildSeasonPath(Series series, int seasonNumber) { var path = series.Path; @@ -246,7 +317,7 @@ namespace NzbDrone.Core.Organizer public string GetMovieFolder(Movie movie) { - return CleanFolderName(Parser.Parser.CleanSeriesTitle(movie.Title)); + return CleanFolderName(movie.Title); } public static string CleanTitle(string title) @@ -774,6 +845,26 @@ namespace NzbDrone.Core.Organizer return Path.GetFileNameWithoutExtension(episodeFile.RelativePath); } + + private string GetOriginalTitle(MovieFile episodeFile) + { + if (episodeFile.SceneName.IsNullOrWhiteSpace()) + { + return GetOriginalFileName(episodeFile); + } + + return episodeFile.SceneName; + } + + private string GetOriginalFileName(MovieFile episodeFile) + { + if (episodeFile.RelativePath.IsNullOrWhiteSpace()) + { + return Path.GetFileNameWithoutExtension(episodeFile.Path); + } + + return Path.GetFileNameWithoutExtension(episodeFile.RelativePath); + } } internal sealed class TokenMatch diff --git a/src/NzbDrone.Core/Parser/Model/LocalMovie.cs b/src/NzbDrone.Core/Parser/Model/LocalMovie.cs new file mode 100644 index 000000000..3f5c2344c --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/LocalMovie.cs @@ -0,0 +1,29 @@ +using System.Linq; +using System.Collections.Generic; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; +using NzbDrone.Core.MediaFiles.MediaInfo; + +namespace NzbDrone.Core.Parser.Model +{ + public class LocalMovie + { + public LocalMovie() + { + } + + public string Path { get; set; } + public long Size { get; set; } + public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } + public Movie Movie { get; set; } + public QualityModel Quality { get; set; } + public MediaInfoModel MediaInfo { get; set; } + public bool ExistingFile { get; set; } + + + public override string ToString() + { + return Path; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 937c9cf94..5466bcb5a 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -15,7 +15,10 @@ namespace NzbDrone.Core.Parser { LocalEpisode GetLocalEpisode(string filename, Series series); LocalEpisode GetLocalEpisode(string filename, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource); + LocalMovie GetLocalMovie(string filename, Movie movie); + LocalMovie GetLocalMovie(string filename, Movie movie, ParsedEpisodeInfo folderInfo, bool sceneSource); Series GetSeries(string title); + Movie GetMovie(string title); RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null); RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int seriesId, IEnumerable episodeIds); RemoteMovie Map(ParsedEpisodeInfo parsedEpisodeInfo, string imdbId, SearchCriteriaBase searchCriteria = null); @@ -98,13 +101,53 @@ namespace NzbDrone.Core.Parser }; } + public LocalMovie GetLocalMovie(string filename, Movie movie) + { + return GetLocalMovie(filename, movie, null, false); + } + + public LocalMovie GetLocalMovie(string filename, Movie movie, ParsedEpisodeInfo folderInfo, bool sceneSource) + { + ParsedEpisodeInfo parsedEpisodeInfo; + + if (folderInfo != null) + { + parsedEpisodeInfo = folderInfo.JsonClone(); + parsedEpisodeInfo.Quality = QualityParser.ParseQuality(Path.GetFileName(filename)); + } + + else + { + parsedEpisodeInfo = Parser.ParsePath(filename); + } + + if (parsedEpisodeInfo == null) + { + if (MediaFileExtensions.Extensions.Contains(Path.GetExtension(filename))) + { + _logger.Warn("Unable to parse episode info from path {0}", filename); + } + + return null; + } + + return new LocalMovie + { + Movie = movie, + Quality = parsedEpisodeInfo.Quality, + Path = filename, + ParsedEpisodeInfo = parsedEpisodeInfo, + ExistingFile = movie.Path.IsParentPath(filename) + }; + } + public Series GetSeries(string title) { var parsedEpisodeInfo = Parser.ParseTitle(title); if (parsedEpisodeInfo == null) { - return _seriesService.FindByTitle(title); //Here we have a problem since it is not possible for movies to find a scene mapping, so these releases are always rejected :( + return _seriesService.FindByTitle(title); } var series = _seriesService.FindByTitle(parsedEpisodeInfo.SeriesTitle); @@ -118,6 +161,26 @@ namespace NzbDrone.Core.Parser return series; } + public Movie GetMovie(string title) + { + var parsedEpisodeInfo = Parser.ParseTitle(title); + + if (parsedEpisodeInfo == null) + { + return _movieService.FindByTitle(title); + } + + var series = _movieService.FindByTitle(parsedEpisodeInfo.SeriesTitle); + + if (series == null) + { + series = _movieService.FindByTitle(parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear, + parsedEpisodeInfo.SeriesTitleInfo.Year); + } + + return series; + } + public RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null) { var remoteEpisode = new RemoteEpisode diff --git a/src/NzbDrone.Core/Tv/Movie.cs b/src/NzbDrone.Core/Tv/Movie.cs index ccbe93510..d5b49cda0 100644 --- a/src/NzbDrone.Core/Tv/Movie.cs +++ b/src/NzbDrone.Core/Tv/Movie.cs @@ -4,6 +4,7 @@ using Marr.Data; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; using NzbDrone.Core.Profiles; +using NzbDrone.Core.MediaFiles; namespace NzbDrone.Core.Tv { @@ -40,11 +41,18 @@ namespace NzbDrone.Core.Tv public DateTime? InCinemas { get; set; } public LazyLoaded Profile { get; set; } public HashSet Tags { get; set; } -// public AddMovieOptions AddOptions { get; set; } + public AddMovieOptions AddOptions { get; set; } + public LazyLoaded MovieFile { get; set; } + public int MovieFileId { get; set; } public override string ToString() { return string.Format("[{0}][{1}]", ImdbId, Title.NullSafe()); } } + + public class AddMovieOptions : MonitoringOptions + { + public bool SearchForMovie { get; set; } + } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/MovieRepository.cs b/src/NzbDrone.Core/Tv/MovieRepository.cs index 281152b05..f6c4b0ceb 100644 --- a/src/NzbDrone.Core/Tv/MovieRepository.cs +++ b/src/NzbDrone.Core/Tv/MovieRepository.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Linq; +using System.Collections.Generic; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; @@ -11,6 +13,8 @@ namespace NzbDrone.Core.Tv Movie FindByTitle(string cleanTitle); Movie FindByTitle(string cleanTitle, int year); Movie FindByImdbId(string imdbid); + List GetMoviesByFileId(int fileId); + void SetFileId(int fileId, int movieId); } public class MovieRepository : BasicRepository, IMovieRepository @@ -46,5 +50,16 @@ namespace NzbDrone.Core.Tv { return Query.Where(s => s.ImdbId == imdbid).SingleOrDefault(); } + + public List GetMoviesByFileId(int fileId) + { + return Query.Where(m => m.MovieFileId == fileId).ToList(); + } + + public void SetFileId(int episodeId, int fileId) + { + SetFields(new Movie { Id = episodeId, MovieFileId = fileId }, movie => movie.MovieFileId); + } + } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/MovieScannedHandler.cs b/src/NzbDrone.Core/Tv/MovieScannedHandler.cs new file mode 100644 index 000000000..151ef0559 --- /dev/null +++ b/src/NzbDrone.Core/Tv/MovieScannedHandler.cs @@ -0,0 +1,57 @@ +using NLog; +using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Tv +{ + public class MovieScannedHandler : IHandle, + IHandle + { + + private readonly IMovieService _movieService; + private readonly IManageCommandQueue _commandQueueManager; + + private readonly Logger _logger; + + public MovieScannedHandler( IMovieService movieService, + IManageCommandQueue commandQueueManager, + Logger logger) + { + _movieService = movieService; + _commandQueueManager = commandQueueManager; + _logger = logger; + } + + private void HandleScanEvents(Movie movie) + { + if (movie.AddOptions == null) + { + //_episodeAddedService.SearchForRecentlyAdded(movie.Id); + return; + } + + _logger.Info("[{0}] was recently added, performing post-add actions", movie.Title); + //_episodeMonitoredService.SetEpisodeMonitoredStatus(movie, movie.AddOptions); + + if (movie.AddOptions.SearchForMovie) + { + _commandQueueManager.Push(new MoviesSearchCommand { MovieId = movie.Id}); + } + + movie.AddOptions = null; + _movieService.RemoveAddOptions(movie); + } + + public void Handle(MovieScannedEvent message) + { + HandleScanEvents(message.Movie); + } + + public void Handle(MovieScanSkippedEvent message) + { + HandleScanEvents(message.Movie); + } + } +} diff --git a/src/NzbDrone.Core/Tv/MovieService.cs b/src/NzbDrone.Core/Tv/MovieService.cs index 546442f48..e255cb215 100644 --- a/src/NzbDrone.Core/Tv/MovieService.cs +++ b/src/NzbDrone.Core/Tv/MovieService.cs @@ -10,6 +10,8 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser; using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; namespace NzbDrone.Core.Tv { @@ -22,14 +24,17 @@ namespace NzbDrone.Core.Tv Movie FindByTitle(string title); Movie FindByTitle(string title, int year); Movie FindByTitleInexact(string title); + Movie GetMovieByFileId(int fileId); void DeleteMovie(int movieId, bool deleteFiles); List GetAllMovies(); Movie UpdateMovie(Movie movie); List UpdateMovie(List movie); bool MoviePathExists(string folder); + void RemoveAddOptions(Movie movie); } - public class MovieService : IMovieService + public class MovieService : IMovieService, IHandle, + IHandle { private readonly IMovieRepository _movieRepository; private readonly IEventAggregator _eventAggregator; @@ -190,5 +195,28 @@ namespace NzbDrone.Core.Tv return _movieRepository.MoviePathExists(folder); } + public void RemoveAddOptions(Movie movie) + { + _movieRepository.SetFields(movie, s => s.AddOptions); + } + + public void Handle(MovieFileAddedEvent message) + { + _movieRepository.SetFileId(message.MovieFile.Id, message.MovieFile.Movie.Value.Id); + _logger.Debug("Linking [{0}] > [{1}]", message.MovieFile.RelativePath, message.MovieFile.Movie.Value); + } + + public void Handle(MovieFileDeletedEvent message) + { + var movie = _movieRepository.GetMoviesByFileId(message.MovieFile.Id).First(); + movie.MovieFileId = 0; + + UpdateMovie(movie); + } + + public Movie GetMovieByFileId(int fileId) + { + return _movieRepository.GetMoviesByFileId(fileId).First(); + } } } diff --git a/src/NzbDrone.Host/SingleInstancePolicy.cs b/src/NzbDrone.Host/SingleInstancePolicy.cs index 75b8bb13e..18de28fb1 100644 --- a/src/NzbDrone.Host/SingleInstancePolicy.cs +++ b/src/NzbDrone.Host/SingleInstancePolicy.cs @@ -31,9 +31,9 @@ namespace NzbDrone.Host { if (IsAlreadyRunning()) { - _logger.Warn("Another instance of Sonarr is already running."); + _logger.Warn("Another instance of Sonarr or Radarr is already running."); _browserService.LaunchWebUI(); - throw new TerminateApplicationException("Another instance is already running"); + //throw new TerminateApplicationException("Another instance is already running"); TODO: detect only radarr } } diff --git a/src/NzbDrone.Integration.Test/IntegrationTest.cs b/src/NzbDrone.Integration.Test/IntegrationTest.cs index bd36562c8..94defd951 100644 --- a/src/NzbDrone.Integration.Test/IntegrationTest.cs +++ b/src/NzbDrone.Integration.Test/IntegrationTest.cs @@ -10,7 +10,7 @@ namespace NzbDrone.Integration.Test public override string SeriesRootFolder => GetTempDirectory("SeriesRootFolder"); - protected override string RootUrl => "http://localhost:8989/"; + protected override string RootUrl => "http://localhost:7878/"; protected override string ApiKey => _runner.ApiKey; diff --git a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs index cf6593d04..e8be666bf 100644 --- a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs +++ b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs @@ -159,7 +159,7 @@ namespace NzbDrone.Integration.Test protected void ConnectSignalR() { _signalRReceived = new List(); - _signalrConnection = new Connection("http://localhost:8989/signalr"); + _signalrConnection = new Connection("http://localhost:7878/signalr"); _signalrConnection.Start(new LongPollingTransport()).ContinueWith(task => { if (task.IsFaulted) diff --git a/src/NzbDrone.Test.Common/NzbDroneRunner.cs b/src/NzbDrone.Test.Common/NzbDroneRunner.cs index 2f1d8e3f5..87f3f49e9 100644 --- a/src/NzbDrone.Test.Common/NzbDroneRunner.cs +++ b/src/NzbDrone.Test.Common/NzbDroneRunner.cs @@ -22,10 +22,10 @@ namespace NzbDrone.Test.Common public string AppData { get; private set; } public string ApiKey { get; private set; } - public NzbDroneRunner(Logger logger, int port = 8989) + public NzbDroneRunner(Logger logger, int port = 7878) { _processProvider = new ProcessProvider(logger); - _restClient = new RestClient("http://localhost:8989/api"); + _restClient = new RestClient("http://localhost:7878/api"); } public void Start() diff --git a/src/NzbDrone/SysTray/SysTrayApp.cs b/src/NzbDrone/SysTray/SysTrayApp.cs index 6325593e1..5e3359bcb 100644 --- a/src/NzbDrone/SysTray/SysTrayApp.cs +++ b/src/NzbDrone/SysTray/SysTrayApp.cs @@ -38,7 +38,7 @@ namespace NzbDrone.SysTray _trayMenu.MenuItems.Add("-"); _trayMenu.MenuItems.Add("Exit", OnExit); - _trayIcon.Text = string.Format("Sonarr - {0}", BuildInfo.Version); + _trayIcon.Text = string.Format("Radarr - {0}", BuildInfo.Version); _trayIcon.Icon = Properties.Resources.NzbDroneIcon; _trayIcon.ContextMenu = _trayMenu; diff --git a/src/UI/AddMovies/AddMoviesView.js b/src/UI/AddMovies/AddMoviesView.js index 1694a9ffc..e83aad42f 100644 --- a/src/UI/AddMovies/AddMoviesView.js +++ b/src/UI/AddMovies/AddMoviesView.js @@ -120,16 +120,17 @@ module.exports = Marionette.Layout.extend({ }, _onMoviesAdded : function(options) { - if (this.isExisting && options.movies.get('path') === this.model.get('folder').path) { + if (this.isExisting && options.movie.get('path') === this.model.get('folder').path) { this.close(); } else if (!this.isExisting) { - this.collection.term = ''; + this.resultCollectionView.setExisting(options.movie.get('imdbId')) + /*this.collection.term = ''; this.collection.reset(); this._clearResults(); this.ui.moviesSearch.val(''); - this.ui.moviesSearch.focus(); + this.ui.moviesSearch.focus();*/ //TODO: Maybe add option wheter to clear search result. } }, @@ -143,6 +144,7 @@ module.exports = Marionette.Layout.extend({ }, _clearResults : function() { + if (!this.isExisting) { this.searchResult.show(new EmptyView()); } else { diff --git a/src/UI/AddMovies/SearchResultCollectionView.js b/src/UI/AddMovies/SearchResultCollectionView.js index e533085ac..ac1257697 100644 --- a/src/UI/AddMovies/SearchResultCollectionView.js +++ b/src/UI/AddMovies/SearchResultCollectionView.js @@ -21,9 +21,21 @@ module.exports = Marionette.CollectionView.extend({ return this.showing >= this.collection.length; }, + setExisting : function(imdbid) { + var movies = this.collection.where({ imdbId : imdbid }); + console.warn(movies) + //debugger; + if (movies.length > 0) { + this.children.findByModel(movies[0])._configureTemplateHelpers(); + //this.children.findByModel(movies[0])._configureTemplateHelpers(); + this.children.findByModel(movies[0]).render(); + //this.templateHelpers.existing = existingMovies[0].toJSON(); + } + }, + appendHtml : function(collectionView, itemView, index) { if (!this.isExisting || index < this.showing || index === 0) { collectionView.$el.append(itemView.el); } } -}); \ No newline at end of file +}); diff --git a/src/UI/AddMovies/SearchResultView.js b/src/UI/AddMovies/SearchResultView.js index 839b2d1ee..a01a41e3e 100644 --- a/src/UI/AddMovies/SearchResultView.js +++ b/src/UI/AddMovies/SearchResultView.js @@ -153,14 +153,14 @@ var view = Marionette.ItemView.extend({ }, _addWithoutSearch : function() { - this._addMovies(true); + this._addMovies(false); }, _addAndSearch : function() { this._addMovies(true); }, - _addMovies : function(searchForMissingEpisodes) { + _addMovies : function(searchForMovie) { var addButton = this.ui.addButton; var addSearchButton = this.ui.addSearchButton; @@ -171,7 +171,8 @@ var view = Marionette.ItemView.extend({ var rootFolderPath = this.ui.rootFolder.children(':selected').text(); var options = this._getAddMoviesOptions(); - options.searchForMissingEpisodes = searchForMissingEpisodes; + options.searchForMovie = searchForMovie; + console.warn(searchForMovie); this.model.set({ profileId : profile, @@ -186,7 +187,7 @@ var view = Marionette.ItemView.extend({ console.log(this.model.save); console.log(promise); - if (searchForMissingEpisodes) { + if (searchForMovie) { this.ui.addSearchButton.spinForPromise(promise); } diff --git a/src/UI/AddMovies/SearchResultViewTemplate.hbs b/src/UI/AddMovies/SearchResultViewTemplate.hbs index 41845cdee..87e1167da 100644 --- a/src/UI/AddMovies/SearchResultViewTemplate.hbs +++ b/src/UI/AddMovies/SearchResultViewTemplate.hbs @@ -2,7 +2,11 @@
@@ -74,7 +78,7 @@ -
diff --git a/src/UI/Commands/CommandController.js b/src/UI/Commands/CommandController.js index 2232d45ae..929870107 100644 --- a/src/UI/Commands/CommandController.js +++ b/src/UI/Commands/CommandController.js @@ -86,7 +86,7 @@ var singleton = function() { } } }); - + console.warn(options) options.element.startSpin(); } }; diff --git a/src/UI/Handlebars/Helpers/Series.js b/src/UI/Handlebars/Helpers/Series.js index fc96495a0..016279e6e 100644 --- a/src/UI/Handlebars/Helpers/Series.js +++ b/src/UI/Handlebars/Helpers/Series.js @@ -19,6 +19,22 @@ Handlebars.registerHelper('poster', function() { return new Handlebars.SafeString(''.format(placeholder)); }); +Handlebars.registerHelper('remotePoster', function() { + var placeholder = StatusModel.get('urlBase') + '/Content/Images/poster-dark.png'; + var poster = this.remotePoster; + + if (poster) { + if (!poster.match(/^https?:\/\//)) { + return new Handlebars.SafeString(''.format(Handlebars.helpers.defaultImg.call(null, poster, 250))); + } else { + var url = poster.replace(/^https?\:/, 'https://'); //IMDb posters need https to work, k? + return new Handlebars.SafeString(''.format(Handlebars.helpers.defaultImg.call(null, url))); + } + } + + return new Handlebars.SafeString(''.format(placeholder)); +}) + Handlebars.registerHelper('traktUrl', function() { return 'http://trakt.tv/search/tvdb/' + this.tvdbId + '?id_type=show'; }); diff --git a/src/UI/Movies/Details/MoviesDetailsLayout.js b/src/UI/Movies/Details/MoviesDetailsLayout.js index 6dd34e360..4396c22de 100644 --- a/src/UI/Movies/Details/MoviesDetailsLayout.js +++ b/src/UI/Movies/Details/MoviesDetailsLayout.js @@ -32,7 +32,7 @@ module.exports = Marionette.Layout.extend({ edit : '.x-edit', refresh : '.x-refresh', rename : '.x-rename', - search : '.x-search', + searchAuto : '.x-search', poster : '.x-movie-poster', manualSearch : '.x-manual-search', history : '.x-movie-history', @@ -86,8 +86,9 @@ module.exports = Marionette.Layout.extend({ name : 'refreshMovie' } }); + CommandController.bindToCommand({ - element : this.ui.search, + element : this.ui.searchAuto, command : { name : 'moviesSearch' }