From 7029409792b0527c455ddf024ce02ec827ac80fb Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Mon, 23 Jan 2023 14:54:24 -0700 Subject: [PATCH 1/5] Upgrade AAXClean.Codecs to 0.5.10 and fix #459 --- Source/AaxDecrypter/AaxDecrypter.csproj | 2 +- .../AaxDecrypter/AaxcDownloadConvertBase.cs | 7 ++-- .../AaxcDownloadMultiConverter.cs | 21 +++++----- .../AaxcDownloadSingleConverter.cs | 23 +++++++---- Source/FileLiberator/ConvertToMp3.cs | 38 ++++++++++--------- Source/FileManager/FileUtility.cs | 4 +- Source/LibationFileManager/Templates.cs | 6 +-- .../TemplatesTests.cs | 1 + 8 files changed, 57 insertions(+), 45 deletions(-) diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index aea11238..83492352 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -13,7 +13,7 @@ - + diff --git a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs index c42fc4d4..994c07ae 100644 --- a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs +++ b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs @@ -10,6 +10,7 @@ namespace AaxDecrypter public event EventHandler RetrievedMetadata; protected AaxFile AaxFile; + protected Mp4Operation aaxConversion; protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) : base(outFileName, cacheDirectory, dlOptions) { } @@ -101,9 +102,9 @@ namespace AaxDecrypter public override async Task CancelAsync() { IsCanceled = true; - if (AaxFile != null) - await AaxFile.CancelAsync(); - AaxFile?.Dispose(); + if (aaxConversion != null) + await aaxConversion.CancelAsync(); + AaxFile?.Close(); CloseInputFileStream(); } } diff --git a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs index a1fdb7bd..325bdd8c 100644 --- a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs @@ -133,32 +133,33 @@ That naming may not be desirable for everyone, but it's an easy change to instea try { - ConversionResult result; - - AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; if (DownloadOptions.OutputFormat == OutputFormat.M4b) - result = await ConvertToMultiMp4a(splitChapters); + aaxConversion = ConvertToMultiMp4a(splitChapters); else - result = await ConvertToMultiMp3(splitChapters); + aaxConversion = ConvertToMultiMp3(splitChapters); - return result == ConversionResult.NoErrorsDetected; + aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; + await aaxConversion; + return aaxConversion.IsCompletedSuccessfully; } catch(Exception ex) { Serilog.Log.Error(ex, "AAXClean Error"); workingFileStream?.Close(); - FileUtility.SaferDelete(workingFileStream.Name); + if (workingFileStream?.Name is not null) + FileUtility.SaferDelete(workingFileStream.Name); return false; } finally { - AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate; + if (aaxConversion is not null) + aaxConversion.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate; Step_DownloadAudiobook_End(zeroProgress); } } - private Task ConvertToMultiMp4a(ChapterInfo splitChapters) + private Mp4Operation ConvertToMultiMp4a(ChapterInfo splitChapters) { var chapterCount = 0; return AaxFile.ConvertToMultiMp4aAsync @@ -169,7 +170,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea ); } - private Task ConvertToMultiMp3(ChapterInfo splitChapters) + private Mp4Operation ConvertToMultiMp3(ChapterInfo splitChapters) { var chapterCount = 0; return AaxFile.ConvertToMultiMp3Async diff --git a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs index 288c06af..e41389a6 100644 --- a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using AAXClean; using AAXClean.Codecs; using FileManager; +using Mpeg4Lib.Util; namespace AaxDecrypter { @@ -90,16 +91,20 @@ namespace AaxDecrypter var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); OnFileCreated(OutputFileName); - AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; try { - ConversionResult decryptionResult = await decryptAsync(outputFile); - var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled; - if (success) - base.OnFileCreated(OutputFileName); + aaxConversion = decryptAsync(outputFile); + aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; + await aaxConversion; - return success; + if (aaxConversion.IsCompletedSuccessfully) + { + outputFile.Close(); + base.OnFileCreated(OutputFileName); + } + + return aaxConversion.IsCompletedSuccessfully; } catch(Exception ex) { @@ -110,13 +115,15 @@ namespace AaxDecrypter finally { outputFile.Close(); - AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate; + + if (aaxConversion is not null) + aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; Step_DownloadAudiobook_End(zeroProgress); } } - private Task decryptAsync(Stream outputFile) + private Mp4Operation decryptAsync(Stream outputFile) => DownloadOptions.OutputFormat == OutputFormat.Mp3 ? AaxFile.ConvertToMp3Async ( diff --git a/Source/FileLiberator/ConvertToMp3.cs b/Source/FileLiberator/ConvertToMp3.cs index 5ec07045..1296037a 100644 --- a/Source/FileLiberator/ConvertToMp3.cs +++ b/Source/FileLiberator/ConvertToMp3.cs @@ -15,12 +15,12 @@ namespace FileLiberator public class ConvertToMp3 : AudioDecodable { public override string Name => "Convert to Mp3"; - private Mp4File m4bBook; - + private Mp4Operation Mp4Operation; + private TimeSpan bookDuration; private long fileSize; private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3"); - public override Task CancelAsync() => m4bBook?.CancelAsync() ?? Task.CompletedTask; + public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask; public static bool ValidateMp3(LibraryBook libraryBook) { @@ -43,9 +43,9 @@ namespace FileLiberator var proposedMp3Path = Mp3FileName(m4bPath); if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue; - m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read)); - m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate; + var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read)); + bookDuration = m4bBook.Duration; fileSize = m4bBook.InputStream.Length; OnTitleDiscovered(m4bBook.AppleTags.Title); @@ -66,20 +66,20 @@ namespace FileLiberator using var mp3File = File.OpenWrite(Path.GetTempFileName()); try { - var result = await m4bBook.ConvertToMp3Async(mp3File, lameConfig); + Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig); + Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate; + await Mp4Operation; - var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters); - OnFileCreated(libraryBook, realMp3Path); - - if (result == ConversionResult.Failed) - { - FileUtility.SaferDelete(mp3File.Name); - } - else if (result == ConversionResult.Cancelled) + if (Mp4Operation.IsCanceled) { FileUtility.SaferDelete(mp3File.Name); return new StatusHandler { "Cancelled" }; } + else + { + var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters, "mp3"); + OnFileCreated(libraryBook, realMp3Path); + } } catch (Exception ex) { @@ -88,6 +88,9 @@ namespace FileLiberator } finally { + if (Mp4Operation is not null) + Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate; + m4bBook.InputStream.Close(); mp3File.Close(); } @@ -102,14 +105,13 @@ namespace FileLiberator private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e) { - var duration = m4bBook.Duration; - var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds; + var remainingSecsToProcess = (bookDuration - e.ProcessPosition).TotalSeconds; var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed; - + if (double.IsNormal(estTimeRemaining)) OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining)); - double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds; + double progressPercent = 100 * e.ProcessPosition.TotalSeconds / bookDuration.TotalSeconds; OnStreamingProgressChanged( new DownloadProgress diff --git a/Source/FileManager/FileUtility.cs b/Source/FileManager/FileUtility.cs index a3f46de3..0b83866b 100644 --- a/Source/FileManager/FileUtility.cs +++ b/Source/FileManager/FileUtility.cs @@ -151,9 +151,9 @@ namespace FileManager ///
- Perform ///
- Return valid path /// - public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements) + public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements, string extension = null) { - var extension = Path.GetExtension(source); + extension = extension ?? Path.GetExtension(source); destination = GetValidFilename(destination, replacements, extension); SaferMove(source, destination); return destination; diff --git a/Source/LibationFileManager/Templates.cs b/Source/LibationFileManager/Templates.cs index 8880efd1..e1d33236 100644 --- a/Source/LibationFileManager/Templates.cs +++ b/Source/LibationFileManager/Templates.cs @@ -107,9 +107,9 @@ namespace LibationFileManager .GetFilePath(fileExtension).PathWithoutPrefix; public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; - private static Regex fileDateTagRegex { get; } = new Regex(@"", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static Regex dateAddedTagRegex { get; } = new Regex(@"", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static Regex datePublishedTagRegex { get; } = new Regex(@"", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static Regex fileDateTagRegex { get; } = new Regex(@"", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static Regex dateAddedTagRegex { get; } = new Regex(@"", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static Regex datePublishedTagRegex { get; } = new Regex(@"", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static Regex ifSeriesRegex { get; } = new Regex("(.*?)<-if series>", RegexOptions.Compiled | RegexOptions.IgnoreCase); internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension, ReplacementCharacters replacements) diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 400565d3..da1760c6 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -153,6 +153,7 @@ namespace TemplatesTests [DataRow("", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate[h]>.m4b")] [DataRow("< filedate[yyyy]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\< filedate[yyyy]>.m4b")] [DataRow("", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate[yyyy][]>.m4b")] + [DataRow("", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate[[yyyy]]>.m4b")] [DataRow("", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate[yyyy[]]>.m4b")] [DataRow("", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate yyyy]>.m4b")] [DataRow("", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate ]yyyy]>.m4b")] From 630cfdeab323aea6637cb673cba5c01ea62dc458 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Mon, 23 Jan 2023 17:39:08 -0700 Subject: [PATCH 2/5] Upgrade AAXClean.Codecs to 0.5.11 --- Source/AaxDecrypter/AaxDecrypter.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index 83492352..5498c67f 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -13,7 +13,7 @@ - + From f1b4e2a17d32da985ff55a01c20f0b0fb0c82ae7 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Mon, 23 Jan 2023 17:39:08 -0700 Subject: [PATCH 3/5] Upgrade AAXClean.Codecs to 0.5.11 --- Source/AaxDecrypter/AaxDecrypter.csproj | 2 +- Source/AaxDecrypter/AaxcDownloadSingleConverter.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index 83492352..5498c67f 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -13,7 +13,7 @@ - + diff --git a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs index e41389a6..1baf9a9a 100644 --- a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs @@ -117,7 +117,7 @@ namespace AaxDecrypter outputFile.Close(); if (aaxConversion is not null) - aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; + aaxConversion.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate; Step_DownloadAudiobook_End(zeroProgress); } From 8dc912c11d4b8840bf42c9dc4e6b1c461ff8ba67 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Mon, 23 Jan 2023 20:11:00 -0700 Subject: [PATCH 4/5] Add option to move the moov atom to the beginning of the file. --- Source/AaxDecrypter/AaxDecrypter.csproj | 2 +- .../AaxcDownloadMultiConverter.cs | 18 + .../AaxcDownloadSingleConverter.cs | 17 +- Source/AaxDecrypter/IDownloadOptions.cs | 1 + Source/FileLiberator/DownloadDecryptBook.cs | 1 + Source/FileLiberator/DownloadOptions.cs | 2 + .../Dialogs/SettingsDialog.axaml | 738 +++++++++--------- .../Dialogs/SettingsDialog.axaml.cs | 9 +- .../Configuration.PersistentSettings.cs | 3 + .../Dialogs/SettingsDialog.AudioSettings.cs | 4 + .../Dialogs/SettingsDialog.Designer.cs | 21 +- 11 files changed, 440 insertions(+), 376 deletions(-) diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index 5498c67f..3e8af26e 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -13,7 +13,7 @@ - + diff --git a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs index 325bdd8c..8b198665 100644 --- a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs @@ -140,6 +140,10 @@ That naming may not be desirable for everyone, but it's an easy change to instea aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; await aaxConversion; + + if (aaxConversion.IsCompletedSuccessfully) + moveMoovToBeginning(workingFileStream?.Name); + return aaxConversion.IsCompletedSuccessfully; } catch(Exception ex) @@ -195,12 +199,26 @@ That naming may not be desirable for everyone, but it's an easy change to instea PartsTotal = splitChapters.Count, Title = newSplitCallback?.Chapter?.Title, }; + + moveMoovToBeginning(workingFileStream?.Name); + newSplitCallback.OutputFile = createOutputFileStream(props); newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitleName(props); newSplitCallback.TrackNumber = currentChapter; newSplitCallback.TrackCount = splitChapters.Count; } + private void moveMoovToBeginning(string filename) + { + if (DownloadOptions.OutputFormat is OutputFormat.M4b + && DownloadOptions.MoveMoovToBeginning + && filename is not null + && File.Exists(filename)) + { + Mp4File.RelocateMoovAsync(filename).GetAwaiter().GetResult(); + } + } + private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties) { var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties); diff --git a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs index e41389a6..b0348f7d 100644 --- a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs @@ -98,12 +98,21 @@ namespace AaxDecrypter aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; await aaxConversion; - if (aaxConversion.IsCompletedSuccessfully) + outputFile.Close(); + + if (aaxConversion.IsCompletedSuccessfully + && DownloadOptions.OutputFormat is OutputFormat.M4b + && DownloadOptions.MoveMoovToBeginning) { - outputFile.Close(); - base.OnFileCreated(OutputFileName); + aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; + aaxConversion = Mp4File.RelocateMoovAsync(OutputFileName); + aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; + await aaxConversion; } + if (aaxConversion.IsCompletedSuccessfully) + base.OnFileCreated(OutputFileName); + return aaxConversion.IsCompletedSuccessfully; } catch(Exception ex) @@ -117,7 +126,7 @@ namespace AaxDecrypter outputFile.Close(); if (aaxConversion is not null) - aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate; + aaxConversion.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate; Step_DownloadAudiobook_End(zeroProgress); } diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs index 6972f067..f67e2040 100644 --- a/Source/AaxDecrypter/IDownloadOptions.cs +++ b/Source/AaxDecrypter/IDownloadOptions.cs @@ -24,6 +24,7 @@ namespace AaxDecrypter NAudio.Lame.LameConfig LameConfig { get; } bool Downsample { get; } bool MatchSourceBitrate { get; } + bool MoveMoovToBeginning { get; } string GetMultipartFileName(MultiConvertFileProperties props); string GetMultipartTitleName(MultiConvertFileProperties props); Task SaveClipsAndBookmarks(string fileName); diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index ea430c5d..d0010170 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -160,6 +160,7 @@ namespace FileLiberator AudibleKey = contentLic?.Voucher?.Key, AudibleIV = contentLic?.Voucher?.Iv, OutputFormat = outputFormat, + MoveMoovToBeginning = config.MoveMoovToBeginning, TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio, RetainEncryptedFile = config.RetainAaxFile && encrypted, StripUnabridged = config.AllowLibationFixup && config.StripUnabridged, diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 7520ed15..c43d1bcd 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -34,6 +34,8 @@ namespace FileLiberator public bool MatchSourceBitrate { get; init; } public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters; + public bool MoveMoovToBeginning { get; init; } + public string GetMultipartFileName(MultiConvertFileProperties props) => Templates.ChapterFile.GetFilename(LibraryBookDto, props); diff --git a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml index ba5d1d62..b1e973ff 100644 --- a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml @@ -2,8 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="850" d:DesignHeight="620" - MinWidth="800" MinHeight="620" + mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="680" + MinWidth="900" MinHeight="680" x:Class="LibationAvalonia.Dialogs.SettingsDialog" xmlns:controls="clr-namespace:LibationAvalonia.Controls" Title="Edit Settings" @@ -34,6 +34,378 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +