using System; using System.Collections.Generic; using System.IO; using System.Linq; using NLog; using NLog.Fluent; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.History; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Download { public interface ICompletedDownloadService { void Check(TrackedDownload trackedDownload); void Import(TrackedDownload trackedDownload); bool VerifyImport(TrackedDownload trackedDownload, List importResults); } public class CompletedDownloadService : ICompletedDownloadService { private readonly IEventAggregator _eventAggregator; private readonly IHistoryService _historyService; private readonly IProvideImportItemService _provideImportItemService; private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; private readonly IParsingService _parsingService; private readonly ISeriesService _seriesService; private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported; private readonly Logger _logger; public CompletedDownloadService(IEventAggregator eventAggregator, IHistoryService historyService, IProvideImportItemService provideImportItemService, IDownloadedEpisodesImportService downloadedEpisodesImportService, IParsingService parsingService, ISeriesService seriesService, ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported, Logger logger) { _eventAggregator = eventAggregator; _historyService = historyService; _provideImportItemService = provideImportItemService; _downloadedEpisodesImportService = downloadedEpisodesImportService; _parsingService = parsingService; _seriesService = seriesService; _trackedDownloadAlreadyImported = trackedDownloadAlreadyImported; _logger = logger; } public void Check(TrackedDownload trackedDownload) { if (trackedDownload.DownloadItem.Status != DownloadItemStatus.Completed) { return; } SetImportItem(trackedDownload); // Only process tracked downloads that are still downloading if (trackedDownload.State != TrackedDownloadState.Downloading) { return; } var grabbedHistories = _historyService.FindByDownloadId(trackedDownload.DownloadItem.DownloadId).Where(h => h.EventType == EpisodeHistoryEventType.Grabbed).ToList(); var historyItem = grabbedHistories.MaxBy(h => h.Date); if (historyItem == null && trackedDownload.DownloadItem.Category.IsNullOrWhiteSpace()) { trackedDownload.Warn("Download wasn't grabbed by Sonarr and not in a category, Skipping."); return; } if (!ValidatePath(trackedDownload)) { return; } var series = _parsingService.GetSeries(trackedDownload.DownloadItem.Title); if (series == null) { if (historyItem != null) { series = _seriesService.GetSeries(historyItem.SeriesId); } if (series == null) { trackedDownload.Warn("Series title mismatch; automatic import is not possible. Check the download troubleshooting entry on the wiki for common causes."); if (!trackedDownload.HasNotifiedManualInteractionRequired) { trackedDownload.HasNotifiedManualInteractionRequired = true; var releaseInfo = new GrabbedReleaseInfo(grabbedHistories); var manualInteractionEvent = new ManualInteractionRequiredEvent(trackedDownload, releaseInfo); _eventAggregator.PublishEvent(manualInteractionEvent); } return; } Enum.TryParse(historyItem.Data.GetValueOrDefault(EpisodeHistory.SERIES_MATCH_TYPE, SeriesMatchType.Unknown.ToString()), out SeriesMatchType seriesMatchType); Enum.TryParse(historyItem.Data.GetValueOrDefault(EpisodeHistory.RELEASE_SOURCE, ReleaseSourceType.Unknown.ToString()), out ReleaseSourceType releaseSource); // Show a warning if the release was matched by ID and the source is not interactive search if (seriesMatchType == SeriesMatchType.Id && releaseSource != ReleaseSourceType.InteractiveSearch) { trackedDownload.Warn("Found matching series via grab history, but release was matched to series by ID. Automatic import is not possible. See the FAQ for details."); if (!trackedDownload.HasNotifiedManualInteractionRequired) { trackedDownload.HasNotifiedManualInteractionRequired = true; var releaseInfo = new GrabbedReleaseInfo(grabbedHistories); var manualInteractionEvent = new ManualInteractionRequiredEvent(trackedDownload, releaseInfo); _eventAggregator.PublishEvent(manualInteractionEvent); } return; } } trackedDownload.State = TrackedDownloadState.ImportPending; } public void Import(TrackedDownload trackedDownload) { SetImportItem(trackedDownload); if (!ValidatePath(trackedDownload)) { return; } var grabbedHistories = _historyService.FindByDownloadId(trackedDownload.DownloadItem.DownloadId).Where(h => h.EventType == EpisodeHistoryEventType.Grabbed).ToList(); if (grabbedHistories.Count == 0) { grabbedHistories = new List { new EpisodeHistory() }; } if (trackedDownload.RemoteEpisode == null) { trackedDownload.Warn("Unable to parse download, automatic import is not possible."); if (!trackedDownload.HasNotifiedManualInteractionRequired) { trackedDownload.HasNotifiedManualInteractionRequired = true; var releaseInfo = new GrabbedReleaseInfo(grabbedHistories); var manualInteractionEvent = new ManualInteractionRequiredEvent(trackedDownload, releaseInfo); _eventAggregator.PublishEvent(manualInteractionEvent); } return; } trackedDownload.State = TrackedDownloadState.Importing; var outputPath = trackedDownload.ImportItem.OutputPath.FullPath; var importResults = _downloadedEpisodesImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteEpisode.Series, trackedDownload.ImportItem); if (VerifyImport(trackedDownload, importResults)) { return; } trackedDownload.State = TrackedDownloadState.ImportPending; if (importResults.Empty()) { trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); return; } if (importResults.Count == 1) { var firstResult = importResults.First(); if (firstResult.Result == ImportResultType.Rejected && firstResult.ImportDecision.LocalEpisode == null) { trackedDownload.Warn(new TrackedDownloadStatusMessage(firstResult.Errors.First(), new List())); return; } } var statusMessages = new List { new TrackedDownloadStatusMessage("One or more episodes expected in this release were not imported or missing from the release", new List()) }; if (importResults.Any(c => c.Result != ImportResultType.Imported)) { statusMessages.AddRange( importResults .Where(v => v.Result != ImportResultType.Imported && v.ImportDecision.LocalEpisode != null) .OrderBy(v => v.ImportDecision.LocalEpisode.Path) .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.LocalEpisode.Path), v.Errors))); } if (statusMessages.Any()) { trackedDownload.Warn(statusMessages.ToArray()); if (!trackedDownload.HasNotifiedManualInteractionRequired) { trackedDownload.HasNotifiedManualInteractionRequired = true; var releaseInfo = new GrabbedReleaseInfo(grabbedHistories); var manualInteractionEvent = new ManualInteractionRequiredEvent(trackedDownload, releaseInfo); _eventAggregator.PublishEvent(manualInteractionEvent); } } } public bool VerifyImport(TrackedDownload trackedDownload, List importResults) { var allEpisodesImported = importResults.Where(c => c.Result == ImportResultType.Imported) .SelectMany(c => c.ImportDecision.LocalEpisode.Episodes) .Count() >= Math.Max(1, trackedDownload.RemoteEpisode.Episodes.Count); if (allEpisodesImported) { _logger.Debug("All episodes were imported for {0}", trackedDownload.DownloadItem.Title); trackedDownload.State = TrackedDownloadState.Imported; _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload, trackedDownload.RemoteEpisode.Series.Id)); return true; } // Double check if all episodes were imported by checking the history if at least one // file was imported. This will allow the decision engine to reject already imported // episode files and still mark the download complete when all files are imported. // EDGE CASE: This process relies on EpisodeIds being consistent between executions, if a series is updated // and an episode is removed, but later comes back with a different ID then Sonarr will treat it as incomplete. // Since imports should be relatively fast and these types of data changes are infrequent this should be quite // safe, but commenting for future benefit. var atLeastOneEpisodeImported = importResults.Any(c => c.Result == ImportResultType.Imported); var historyItems = _historyService.FindByDownloadId(trackedDownload.DownloadItem.DownloadId) .OrderByDescending(h => h.Date) .ToList(); var allEpisodesImportedInHistory = _trackedDownloadAlreadyImported.IsImported(trackedDownload, historyItems); if (allEpisodesImportedInHistory) { // Log different error messages depending on the circumstances, but treat both as fully imported, because that's the reality. // The second message shouldn't be logged in most cases, but continued reporting would indicate an ongoing issue. if (atLeastOneEpisodeImported) { _logger.Debug("All episodes were imported in history for {0}", trackedDownload.DownloadItem.Title); } else { _logger.Debug() .Message("No Episodes were just imported, but all episodes were previously imported, possible issue with download history.") .Property("SeriesId", trackedDownload.RemoteEpisode.Series.Id) .Property("DownloadId", trackedDownload.DownloadItem.DownloadId) .Property("Title", trackedDownload.DownloadItem.Title) .Property("Path", trackedDownload.ImportItem.OutputPath.ToString()) .WriteSentryWarn("DownloadHistoryIncomplete") .Write(); } trackedDownload.State = TrackedDownloadState.Imported; _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload, trackedDownload.RemoteEpisode.Series.Id)); return true; } _logger.Debug("Not all episodes have been imported for the release '{0}'", trackedDownload.DownloadItem.Title); return false; } private void SetImportItem(TrackedDownload trackedDownload) { trackedDownload.ImportItem = _provideImportItemService.ProvideImportItem(trackedDownload.DownloadItem, trackedDownload.ImportItem); } private bool ValidatePath(TrackedDownload trackedDownload) { var downloadItemOutputPath = trackedDownload.ImportItem.OutputPath; if (downloadItemOutputPath.IsEmpty) { trackedDownload.Warn("Download doesn't contain intermediate path, Skipping."); return false; } if ((OsInfo.IsWindows && !downloadItemOutputPath.IsWindowsPath) || (OsInfo.IsNotWindows && !downloadItemOutputPath.IsUnixPath)) { trackedDownload.Warn("[{0}] is not a valid local path. You may need a Remote Path Mapping. Check the download troubleshooting entry on the wiki for details.", downloadItemOutputPath); return false; } return true; } } }