Episode import uses specs and moves before import now

This commit is contained in:
Mark McDowall 2013-07-06 14:47:49 -07:00
commit aeb8ee06f6
22 changed files with 942 additions and 499 deletions

View file

@ -6,6 +6,7 @@ using NLog;
using NzbDrone.Common;
using NzbDrone.Common.Messaging;
using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.MediaFiles.EpisodeImport;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Providers;
using NzbDrone.Core.Tv;
@ -15,7 +16,6 @@ namespace NzbDrone.Core.MediaFiles
{
public interface IDiskScanService
{
EpisodeFile ImportFile(Series series, string filePath);
string[] GetVideoFiles(string path, bool allDirectories = true);
}
@ -25,19 +25,20 @@ namespace NzbDrone.Core.MediaFiles
private static readonly string[] MediaExtensions = new[] { ".mkv", ".avi", ".wmv", ".mp4", ".mpg", ".mpeg", ".xvid", ".flv", ".mov", ".rm", ".rmvb", ".divx", ".dvr-ms", ".ts", ".ogm", ".m4v", ".strm" };
private readonly IDiskProvider _diskProvider;
private readonly ISeriesService _seriesService;
private readonly IMediaFileService _mediaFileService;
private readonly IVideoFileInfoReader _videoFileInfoReader;
private readonly IParsingService _parsingService;
private readonly IMakeImportDecision _importDecisionMaker;
private readonly IImportApprovedEpisodes _importApprovedEpisodes;
private readonly IMessageAggregator _messageAggregator;
public DiskScanService(IDiskProvider diskProvider, ISeriesService seriesService, IMediaFileService mediaFileService, IVideoFileInfoReader videoFileInfoReader,
IParsingService parsingService, IMessageAggregator messageAggregator)
public DiskScanService(IDiskProvider diskProvider,
ISeriesService seriesService,
IMakeImportDecision importDecisionMaker,
IImportApprovedEpisodes importApprovedEpisodes,
IMessageAggregator messageAggregator)
{
_diskProvider = diskProvider;
_seriesService = seriesService;
_mediaFileService = mediaFileService;
_videoFileInfoReader = videoFileInfoReader;
_parsingService = parsingService;
_importDecisionMaker = importDecisionMaker;
_importApprovedEpisodes = importApprovedEpisodes;
_messageAggregator = messageAggregator;
}
@ -53,71 +54,8 @@ namespace NzbDrone.Core.MediaFiles
var mediaFileList = GetVideoFiles(series.Path);
foreach (var filePath in mediaFileList)
{
try
{
ImportFile(series, filePath);
}
catch (Exception e)
{
Logger.ErrorException("Couldn't import file " + filePath, e);
}
}
//Todo: Find the "best" episode file for all found episodes and import that one
//Todo: Move the episode linking to here, instead of import (or rename import)
}
public EpisodeFile ImportFile(Series series, string filePath)
{
Logger.Trace("Importing file to database [{0}]", filePath);
if (_mediaFileService.Exists(filePath))
{
Logger.Trace("[{0}] already exists in the database. skipping.", filePath);
return null;
}
var parsedEpisode = _parsingService.GetEpisodes(filePath, series);
if (parsedEpisode == null || !parsedEpisode.Episodes.Any())
{
return null;
}
var size = _diskProvider.GetFileSize(filePath);
if (series.SeriesType == SeriesTypes.Daily || parsedEpisode.SeasonNumber > 0)
{
var runTime = _videoFileInfoReader.GetRunTime(filePath);
if (size < Constants.IgnoreFileSize && runTime.TotalMinutes < 3)
{
Logger.Trace("[{0}] appears to be a sample. skipping.", filePath);
return null;
}
}
if (parsedEpisode.Episodes.Any(e => e.EpisodeFileId != 0 && e.EpisodeFile.Value.Quality > parsedEpisode.Quality))
{
Logger.Trace("This file isn't an upgrade for all episodes. Skipping {0}", filePath);
return null;
}
var episodeFile = new EpisodeFile();
episodeFile.DateAdded = DateTime.UtcNow;
episodeFile.SeriesId = series.Id;
episodeFile.Path = filePath.CleanPath();
episodeFile.Size = size;
episodeFile.Quality = parsedEpisode.Quality;
episodeFile.SeasonNumber = parsedEpisode.SeasonNumber;
episodeFile.SceneName = Path.GetFileNameWithoutExtension(filePath.CleanPath());
episodeFile.Episodes = parsedEpisode.Episodes;
//Todo: We shouldn't actually import the file until we confirm its the only one we want.
//Todo: Separate episodeFile creation from importing (pass file to import to import)
_mediaFileService.Add(episodeFile);
return episodeFile;
var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, series);
_importApprovedEpisodes.Import(decisions);
}
public string[] GetVideoFiles(string path, bool allDirectories = true)

View file

@ -1,11 +1,12 @@
using System;
using System.Collections.Generic;
using System.IO;
using NLog;
using NzbDrone.Common;
using NzbDrone.Common.Messaging;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.MediaFiles.EpisodeImport;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Tv;
@ -19,7 +20,8 @@ namespace NzbDrone.Core.MediaFiles
private readonly IMoveEpisodeFiles _episodeFileMover;
private readonly IParsingService _parsingService;
private readonly IConfigService _configService;
private readonly IMessageAggregator _messageAggregator;
private readonly IMakeImportDecision _importDecisionMaker;
private readonly IImportApprovedEpisodes _importApprovedEpisodes;
private readonly Logger _logger;
public DownloadedEpisodesImportService(IDiskProvider diskProvider,
@ -28,7 +30,8 @@ namespace NzbDrone.Core.MediaFiles
IMoveEpisodeFiles episodeFileMover,
IParsingService parsingService,
IConfigService configService,
IMessageAggregator messageAggregator,
IMakeImportDecision importDecisionMaker,
IImportApprovedEpisodes importApprovedEpisodes,
Logger logger)
{
_diskProvider = diskProvider;
@ -37,7 +40,8 @@ namespace NzbDrone.Core.MediaFiles
_episodeFileMover = episodeFileMover;
_parsingService = parsingService;
_configService = configService;
_messageAggregator = messageAggregator;
_importDecisionMaker = importDecisionMaker;
_importApprovedEpisodes = importApprovedEpisodes;
_logger = logger;
}
@ -92,7 +96,7 @@ namespace NzbDrone.Core.MediaFiles
}
}
public void ProcessSubFolder(DirectoryInfo subfolderInfo)
private void ProcessSubFolder(DirectoryInfo subfolderInfo)
{
var series = _parsingService.GetSeries(subfolderInfo.Name);
@ -102,12 +106,9 @@ namespace NzbDrone.Core.MediaFiles
return;
}
var files = _diskScanService.GetVideoFiles(subfolderInfo.FullName);
var videoFiles = _diskScanService.GetVideoFiles(subfolderInfo.FullName);
foreach (var file in files)
{
ProcessVideoFile(file, series);
}
ProcessFiles(videoFiles, series);
}
private void ProcessVideoFile(string videoFile, Series series)
@ -118,13 +119,13 @@ namespace NzbDrone.Core.MediaFiles
return;
}
var episodeFile = _diskScanService.ImportFile(series, videoFile);
ProcessFiles(new [] { videoFile }, series);
}
if (episodeFile != null)
{
_episodeFileMover.MoveEpisodeFile(episodeFile, true);
_messageAggregator.PublishEvent(new EpisodeImportedEvent(episodeFile));
}
private void ProcessFiles(IEnumerable<string> videoFiles, Series series)
{
var decisions = _importDecisionMaker.GetImportDecisions(videoFiles, series);
_importApprovedEpisodes.Import(decisions, true);
}
public void Execute(DownloadedEpisodesScanCommand message)

View file

@ -6,13 +6,15 @@ using NzbDrone.Common;
using NzbDrone.Common.Messaging;
using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.Organizer;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.MediaFiles
{
public interface IMoveEpisodeFiles
{
EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, bool newDownload = false);
EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile);
EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode);
}
public class MoveEpisodeFiles : IMoveEpisodeFiles
@ -36,56 +38,69 @@ namespace NzbDrone.Core.MediaFiles
_logger = logger;
}
public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, bool newDownload = false)
public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile)
{
if (episodeFile == null)
throw new ArgumentNullException("episodeFile");
var series = _seriesRepository.Get(episodeFile.SeriesId);
var episodes = _episodeService.GetEpisodesByFileId(episodeFile.Id);
string newFileName = _buildFileNames.BuildFilename(episodes, series, episodeFile);
var newFile = _buildFileNames.BuildFilePath(series, episodes.First().SeasonNumber, newFileName, Path.GetExtension(episodeFile.Path));
var newFileName = _buildFileNames.BuildFilename(episodes, series, episodeFile);
var destinationFilename = _buildFileNames.BuildFilePath(series, episodes.First().SeasonNumber, newFileName, Path.GetExtension(episodeFile.Path));
//Only rename if existing and new filenames don't match
if (DiskProvider.PathEquals(episodeFile.Path, newFile))
{
_logger.Debug("Skipping file rename, source and destination are the same: {0}", episodeFile.Path);
return null;
}
episodeFile = MoveFile(episodeFile, destinationFilename);
_mediaFileService.Update(episodeFile);
return episodeFile;
}
public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode)
{
var newFileName = _buildFileNames.BuildFilename(localEpisode.Episodes, localEpisode.Series, episodeFile);
var destinationFilename = _buildFileNames.BuildFilePath(localEpisode.Series, localEpisode.SeasonNumber, newFileName, Path.GetExtension(episodeFile.Path));
episodeFile = MoveFile(episodeFile, destinationFilename);
//TODO: This just re-parses the source path (which is how we got localEpisode to begin with)
var parsedEpisodeInfo = Parser.Parser.ParsePath(localEpisode.Path);
_messageAggregator.PublishEvent(new EpisodeDownloadedEvent(parsedEpisodeInfo, localEpisode.Series));
return episodeFile;
}
private EpisodeFile MoveFile(EpisodeFile episodeFile, string destinationFilename)
{
if (!_diskProvider.FileExists(episodeFile.Path))
{
_logger.Error("Episode file path does not exist, {0}", episodeFile.Path);
return null;
}
_diskProvider.CreateFolder(new FileInfo(newFile).DirectoryName);
//Only rename if existing and new filenames don't match
if (DiskProvider.PathEquals(episodeFile.Path, destinationFilename))
{
_logger.Debug("Skipping file rename, source and destination are the same: {0}", episodeFile.Path);
return null;
}
_logger.Debug("Moving [{0}] > [{1}]", episodeFile.Path, newFile);
_diskProvider.MoveFile(episodeFile.Path, newFile);
_diskProvider.CreateFolder(new FileInfo(destinationFilename).DirectoryName);
_logger.Debug("Moving [{0}] > [{1}]", episodeFile.Path, destinationFilename);
_diskProvider.MoveFile(episodeFile.Path, destinationFilename);
//Wrapped in Try/Catch to prevent this from causing issues with remote NAS boxes, the move worked, which is more important.
try
{
_diskProvider.InheritFolderPermissions(newFile);
_diskProvider.InheritFolderPermissions(destinationFilename);
}
catch (UnauthorizedAccessException ex)
{
_logger.Debug("Unable to apply folder permissions to: ", newFile);
_logger.Debug("Unable to apply folder permissions to: ", destinationFilename);
_logger.TraceException(ex.Message, ex);
}
episodeFile.Path = newFile;
_mediaFileService.Update(episodeFile);
var parsedEpisodeInfo = Parser.Parser.ParsePath(episodeFile.Path);
parsedEpisodeInfo.Quality = episodeFile.Quality;
if (newDownload)
{
_messageAggregator.PublishEvent(new EpisodeDownloadedEvent(parsedEpisodeInfo, series));
}
episodeFile.Path = destinationFilename;
return episodeFile;
}
}

View file

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.MediaFiles.EpisodeImport
{
public interface IImportDecisionEngineSpecification : IRejectWithReason
{
bool IsSatisfiedBy(LocalEpisode localEpisode);
}
}

View file

@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NLog;
using NzbDrone.Common;
using NzbDrone.Common.Messaging;
using NzbDrone.Core.MediaFiles.Events;
namespace NzbDrone.Core.MediaFiles.EpisodeImport
{
public interface IImportApprovedEpisodes
{
List<ImportDecision> Import(List<ImportDecision> decisions, bool newDownloads = false);
}
public class ImportApprovedEpisodes : IImportApprovedEpisodes
{
private readonly IMoveEpisodeFiles _episodeFileMover;
private readonly MediaFileService _mediaFileService;
private readonly DiskProvider _diskProvider;
private readonly IMessageAggregator _messageAggregator;
private readonly Logger _logger;
public ImportApprovedEpisodes(IMoveEpisodeFiles episodeFileMover,
MediaFileService mediaFileService,
DiskProvider diskProvider,
IMessageAggregator messageAggregator,
Logger logger)
{
_episodeFileMover = episodeFileMover;
_mediaFileService = mediaFileService;
_diskProvider = diskProvider;
_messageAggregator = messageAggregator;
_logger = logger;
}
public List<ImportDecision> Import(List<ImportDecision> decisions, bool newDownload = false)
{
var qualifiedReports = GetQualifiedReports(decisions);
var imported = new List<ImportDecision>();
foreach (var report in qualifiedReports)
{
var localEpisode = report.LocalEpisode;
try
{
if (imported.SelectMany(r => r.LocalEpisode.Episodes)
.Select(e => e.Id)
.ToList()
.Intersect(localEpisode.Episodes.Select(e => e.Id))
.Any())
{
continue;
}
var episodeFile = new EpisodeFile();
episodeFile.DateAdded = DateTime.UtcNow;
episodeFile.SeriesId = localEpisode.Series.Id;
episodeFile.Path = localEpisode.Path.CleanPath();
episodeFile.Size = _diskProvider.GetFileSize(localEpisode.Path);
episodeFile.Quality = localEpisode.Quality;
episodeFile.SeasonNumber = localEpisode.SeasonNumber;
episodeFile.SceneName = Path.GetFileNameWithoutExtension(localEpisode.Path.CleanPath());
episodeFile.Episodes = localEpisode.Episodes;
if (newDownload)
{
episodeFile = _episodeFileMover.MoveEpisodeFile(episodeFile, localEpisode);
}
_mediaFileService.Add(episodeFile);
_messageAggregator.PublishEvent(new EpisodeImportedEvent(episodeFile));
}
catch (Exception e)
{
_logger.WarnException("Couldn't add report to download queue. " + localEpisode, e);
}
}
return imported;
}
private List<ImportDecision> GetQualifiedReports(List<ImportDecision> decisions)
{
return decisions.Where(c => c.Approved)
.OrderByDescending(c => c.LocalEpisode.Quality)
.ToList();
}
}
}

View file

@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.MediaFiles.EpisodeImport
{
public class ImportDecision
{
public LocalEpisode LocalEpisode { get; private set; }
public IEnumerable<string> Rejections { get; private set; }
public bool Approved
{
get
{
return !Rejections.Any();
}
}
public ImportDecision(LocalEpisode localEpisode, params string[] rejections)
{
LocalEpisode = localEpisode;
Rejections = rejections.ToList();
}
}
}

View file

@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NLog;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.MediaFiles.EpisodeImport
{
public interface IMakeImportDecision
{
List<ImportDecision> GetImportDecisions(IEnumerable<String> videoFiles, Series series);
}
public class ImportDecisionMaker : IMakeImportDecision
{
private readonly IEnumerable<IRejectWithReason> _specifications;
private readonly IParsingService _parsingService;
private readonly Logger _logger;
public ImportDecisionMaker(IEnumerable<IRejectWithReason> specifications, IParsingService parsingService, Logger logger)
{
_specifications = specifications;
_parsingService = parsingService;
_logger = logger;
}
public List<ImportDecision> GetImportDecisions(IEnumerable<String> videoFiles, Series series)
{
return GetDecisions(videoFiles, series).ToList();
}
private IEnumerable<ImportDecision> GetDecisions(IEnumerable<String> videoFiles, Series series)
{
foreach (var file in videoFiles)
{
ImportDecision decision = null;
try
{
var parsedEpisode = _parsingService.GetEpisodes(file, series);
if (parsedEpisode != null)
{
decision = GetDecision(parsedEpisode);
}
else
{
parsedEpisode = new LocalEpisode();
parsedEpisode.Path = file;
decision = new ImportDecision(parsedEpisode, "Unable to parse file");
}
}
catch (Exception e)
{
_logger.ErrorException("Couldn't process report.", e);
}
if (decision != null)
{
yield return decision;
}
}
}
private ImportDecision GetDecision(LocalEpisode localEpisode)
{
var reasons = _specifications.Select(c => EvaluateSpec(c, localEpisode))
.Where(c => !string.IsNullOrWhiteSpace(c));
return new ImportDecision(localEpisode, reasons.ToArray());
}
private string EvaluateSpec(IRejectWithReason spec, LocalEpisode localEpisode)
{
try
{
if (string.IsNullOrWhiteSpace(spec.RejectionReason))
{
throw new InvalidOperationException("[Need Rejection Text]");
}
var generalSpecification = spec as IImportDecisionEngineSpecification;
if (generalSpecification != null && !generalSpecification.IsSatisfiedBy(localEpisode))
{
return spec.RejectionReason;
}
}
catch (Exception e)
{
//e.Data.Add("report", remoteEpisode.Report.ToJson());
//e.Data.Add("parsed", remoteEpisode.ParsedEpisodeInfo.ToJson());
_logger.ErrorException("Couldn't evaluate decision on " + localEpisode.Path, e);
return string.Format("{0}: {1}", spec.GetType().Name, e.Message);
}
return null;
}
}
}

View file

@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NLog;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications
{
public class NotAlreadyImportedSpecification : IImportDecisionEngineSpecification
{
private readonly IMediaFileService _mediaFileService;
private readonly Logger _logger;
public NotAlreadyImportedSpecification(IMediaFileService mediaFileService, Logger logger)
{
_mediaFileService = mediaFileService;
_logger = logger;
}
public string RejectionReason { get { return "Is Sample"; } }
public bool IsSatisfiedBy(LocalEpisode localEpisode)
{
if (_mediaFileService.Exists(localEpisode.Path))
{
_logger.Trace("[{0}] already exists in the database. skipping.", localEpisode.Path);
return false;
}
return true;
}
}
}

View file

@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NLog;
using NzbDrone.Common;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Providers;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications
{
public class NotSampleSpecification : IImportDecisionEngineSpecification
{
private readonly IDiskProvider _diskProvider;
private readonly IVideoFileInfoReader _videoFileInfoReader;
private readonly Logger _logger;
public NotSampleSpecification(IDiskProvider diskProvider, IVideoFileInfoReader videoFileInfoReader, Logger logger)
{
_diskProvider = diskProvider;
_videoFileInfoReader = videoFileInfoReader;
_logger = logger;
}
public string RejectionReason { get { return "Sample"; } }
public bool IsSatisfiedBy(LocalEpisode localEpisode)
{
if (localEpisode.Series.SeriesType == SeriesTypes.Daily)
{
_logger.Trace("Daily Series, skipping sample check");
return true;
}
if (localEpisode.SeasonNumber == 0)
{
_logger.Trace("Special, skipping sample check");
return true;
}
var size = _diskProvider.GetFileSize(localEpisode.Path);
var runTime = _videoFileInfoReader.GetRunTime(localEpisode.Path);
if (size < Constants.IgnoreFileSize && runTime.TotalMinutes < 3)
{
_logger.Trace("[{0}] appears to be a sample.", localEpisode.Path);
return false;
}
return true;
}
}
}

View file

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NLog;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications
{
public class UpgradeSpecification : IImportDecisionEngineSpecification
{
private readonly Logger _logger;
public UpgradeSpecification(Logger logger)
{
_logger = logger;
}
public string RejectionReason { get { return "Is Sample"; } }
public bool IsSatisfiedBy(LocalEpisode localEpisode)
{
if (localEpisode.Episodes.Any(e => e.EpisodeFileId != 0 && e.EpisodeFile.Value.Quality > localEpisode.Quality))
{
_logger.Trace("This file isn't an upgrade for all episodes. Skipping {0}", localEpisode.Path);
return false;
}
return true;
}
}
}