// -------------------------------------------------------------------------------------------------------------------- // // This file is part of the HandBrake source code - It may be used under the terms of the GNU General Public License. // // // The Chapters View Model // // -------------------------------------------------------------------------------------------------------------------- namespace HandBrakeWPF.ViewModels { using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Windows; using System.Windows.Forms; using Caliburn.Micro; using HandBrakeWPF.EventArgs; using HandBrakeWPF.Properties; using HandBrakeWPF.Services.Interfaces; using HandBrakeWPF.Services.Presets.Model; using HandBrakeWPF.Services.Scan.Model; using HandBrakeWPF.Utilities.Input; using HandBrakeWPF.Utilities.Output; using HandBrakeWPF.ViewModels.Interfaces; using ChapterMarker = HandBrakeWPF.Services.Encode.Model.Models.ChapterMarker; using EncodeTask = HandBrakeWPF.Services.Encode.Model.EncodeTask; using GeneralApplicationException = HandBrakeWPF.Exceptions.GeneralApplicationException; /// /// The Chapters View Model /// public class ChaptersViewModel : ViewModelBase, IChaptersViewModel { #region Constants and Fields private readonly IErrorService errorService; /// /// The source chapters backing field /// private List sourceChaptersList; #endregion #region Constructors and Destructors /// /// Initializes a new instance of the class. /// /// /// The window manager. /// /// /// The user Setting Service. /// /// /// The Error Service /// public ChaptersViewModel(IWindowManager windowManager, IUserSettingService userSettingService, IErrorService errorService) { this.Task = new EncodeTask(); this.errorService = errorService; } public event EventHandler TabStatusChanged; #endregion #region Public Properties /// /// Gets or sets Task. /// public EncodeTask Task { get; set; } /// /// Gets or sets a value indicating whether chapter markers are enabled. /// public bool IncludeChapterMarkers { get { return this.Task.IncludeChapterMarkers; } set { this.Task.IncludeChapterMarkers = value; this.NotifyOfPropertyChange(() => this.IncludeChapterMarkers); this.OnTabStatusChanged(null); } } public ObservableCollection Chapters { get { return this.Task.ChapterNames; } set { this.Task.ChapterNames = value; this.NotifyOfPropertyChange(() => this.Chapters); } } #endregion #region Properties /// /// Gets or sets SourceChapterList. /// private ObservableCollection SourceChapterList { get; set; } #endregion #region Public Methods /// /// Export the Chapter Markers to a CSV file /// /// /// Thrown if exporting fails. /// public void Export() { string fileName = null; using (var saveFileDialog = new SaveFileDialog() { Filter = "Csv File|*.csv", DefaultExt = "csv", CheckPathExists = true, OverwritePrompt = true }) { var dialogResult = saveFileDialog.ShowDialog(); fileName = saveFileDialog.FileName; // Exit early if the user cancelled or the filename is invalid if (dialogResult != DialogResult.OK || string.IsNullOrWhiteSpace(fileName)) return; } try { using (var csv = new StreamWriter(fileName)) { foreach (ChapterMarker row in this.Chapters) { csv.Write("{0},{1}{2}", row.ChapterNumber, CsvHelper.Escape(row.ChapterName), Environment.NewLine); } } } catch (Exception exc) { throw new GeneralApplicationException( Resources.ChaptersViewModel_UnableToExportChaptersWarning, Resources.ChaptersViewModel_UnableToExportChaptersMsg, exc); } } /// /// Imports a Chapter marker file /// /// /// Thrown if importing fails. /// public void Import() { string filename = null; string fileExtension = null; using (var dialog = new OpenFileDialog() { Filter = string.Join("|", "All Supported Formats (*.csv;*.tsv,*.xml,*.txt)|*.csv;*.tsv;*.xml;*.txt", ChapterImporterCsv.FileFilter, ChapterImporterXml.FileFilter, ChapterImporterTxt.FileFilter), FilterIndex = 1, // 1 based, the index value of the first filter entry is 1 CheckFileExists = true }) { var dialogResult = dialog.ShowDialog(); filename = dialog.FileName; // Exit if the user didn't press the OK button or the file name is invalid if (dialogResult != DialogResult.OK || string.IsNullOrWhiteSpace(filename)) return; // Retrieve the file extension after we've confirmed that the user selected something to open fileExtension = Path.GetExtension(filename)?.ToLowerInvariant(); } var importedChapters = new Dictionary>(); // Execute the importer based on the file extension switch (fileExtension) { case ".csv": // comma separated file case ".tsv": // tab separated file ChapterImporterCsv.Import(filename, ref importedChapters); break; case ".xml": ChapterImporterXml.Import(filename, ref importedChapters); break; case ".txt": ChapterImporterTxt.Import(filename, ref importedChapters); break; default: throw new GeneralApplicationException( Resources.ChaptersViewModel_UnsupportedFileFormatWarning, string.Format(Resources.ChaptersViewModel_UnsupportedFileFormatMsg, fileExtension)); } // Exit early if no chapter information was extracted if (importedChapters == null || importedChapters.Count <= 0) return; // Validate the chaptermap against the current chapter list extracted from the source bool hasTimestamps = importedChapters.Select(importedChapter => importedChapter.Value.Item2).Any(t => t != TimeSpan.Zero); string validationErrorMessage; if (!this.ValidateImportedChapters(importedChapters, out validationErrorMessage, hasTimestamps)) { if (!string.IsNullOrEmpty(validationErrorMessage)) { throw new GeneralApplicationException( Resources.ChaptersViewModel_ValidationFailedWarning, validationErrorMessage); } // The user has cancelled the import, so exit return; } // Now iterate over each chatper we have, and set it's name foreach (ChapterMarker item in this.Chapters) { // If we don't have a chapter name for this chapter then // fallback to the value that is already set for the chapter string chapterName = item.ChapterName; // Attempt to retrieve the imported chapter name Tuple chapterInfo; if (importedChapters.TryGetValue(item.ChapterNumber, out chapterInfo)) chapterName = chapterInfo.Item1; // Assign the chapter name unless the name is not set or only whitespace characters item.ChapterName = !string.IsNullOrWhiteSpace(chapterName) ? chapterName : string.Empty; } } /// /// Setup this window for a new source /// /// /// The source. /// /// /// The title. /// /// /// The preset. /// /// /// The task. /// public void SetSource(Source source, Title title, Preset preset, EncodeTask task) { this.Task = task; if (preset != null) { this.IncludeChapterMarkers = preset.Task.IncludeChapterMarkers; } this.sourceChaptersList = title.Chapters; this.SetSourceChapters(title.Chapters); } /// /// Setup this tab for the specified preset. /// /// /// The preset. /// /// /// The task. /// public void SetPreset(Preset preset, EncodeTask task) { this.Task = task; this.IncludeChapterMarkers = preset.Task.IncludeChapterMarkers; this.NotifyOfPropertyChange(() => this.Chapters); } /// /// Update all the UI controls based on the encode task passed in. /// /// /// The task. /// public void UpdateTask(EncodeTask task) { this.Task = task; this.NotifyOfPropertyChange(() => this.IncludeChapterMarkers); this.NotifyOfPropertyChange(() => this.Chapters); } public bool MatchesPreset(Preset preset) { if (preset.Task.IncludeChapterMarkers != this.IncludeChapterMarkers) { return false; } return true; } /// /// Reset Chapter Names /// public void Reset() { if (this.sourceChaptersList != null) { this.SetSourceChapters(this.sourceChaptersList); } } /// /// Set the Source Chapters List /// /// /// The source chapters. /// public void SetSourceChapters(IEnumerable sourceChapters) { // Cache the chapters in this screen this.SourceChapterList = new ObservableCollection(sourceChapters); this.Chapters.Clear(); // Then Add new Chapter Markers. int counter = 1; foreach (Chapter chapter in this.SourceChapterList) { string chapterName = string.IsNullOrEmpty(chapter.ChapterName) ? string.Format(Resources.ChapterViewModel_Chapter, counter) : chapter.ChapterName; var marker = new ChapterMarker(chapter.ChapterNumber, chapterName, chapter.Duration); this.Chapters.Add(marker); counter += 1; } } #endregion #region Private Methods protected virtual void OnTabStatusChanged(TabStatusEventArgs e) { this.TabStatusChanged?.Invoke(this, e); } /// /// Validates any imported chapter information against the currently detected chapter information in the /// source media. If validation fails then an error message is returned via the out parameter /// /// The list of imported chapter information /// In case of a validation error this variable will hold /// a detailed message that can be presented to the user /// True if there are no errors with imported chapters, false otherwise private bool ValidateImportedChapters(Dictionary> importedChapters, out string validationErrorMessage, bool hasTimestamps) { validationErrorMessage = null; // If the number of chapters don't match, prompt for confirmation if (importedChapters.Count != this.Chapters.Count) { if (this.errorService.ShowMessageBox( string.Format(Resources.ChaptersViewModel_ValidateImportedChapters_ChapterCountMismatchMsg, this.Chapters.Count, importedChapters.Count), Resources.ChaptersViewModel_ValidateImportedChapters_ChapterCountMismatchWarning, MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes) { return false; } } // If the average discrepancy in timings between chapters is either: // a) more than 15 sec for more than 2 chapters // (I chose 15sec based on empirical evidence from testing a few DVDs and comparing to chapter-marker files I downloaded) // => This check will not be performed for the first and last chapter as they're very likely to differ significantly due to language and region // differences (e.g. longer title sequences and different distributor credits) if (hasTimestamps) { List diffs = new List(); foreach (KeyValuePair> import in importedChapters) { ChapterMarker sourceMarker = this.Chapters[import.Key - 1]; TimeSpan source = sourceMarker.Duration; TimeSpan diff = source - import.Value.Item2; diffs.Add(diff); } // var diffs = importedChapters.Zip(this.Chapters, (import, source) => source.Duration - import.Value.Item2); if (diffs.Count(diff => Math.Abs(diff.TotalSeconds) > 15) > 2) { if (this.errorService.ShowMessageBox( Resources.ChaptersViewModel_ValidateImportedChapters_ChapterDurationMismatchMsg, Resources.ChaptersViewModel_ValidateImportedChapters_ChapterDurationMismatchWarning, MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes) { return false; } } } // All is well, we should import chapters return true; } #endregion } }