diff --git a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs index 25e0cec9..572d8831 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs @@ -30,7 +30,7 @@ public class DateRangeSpecs ChannelIds = [ChannelIds.DateRangeTestCases], ExportFormat = ExportFormat.Json, OutputPath = file.Path, - After = Snowflake.FromDate(after), + After = Snowflake.FromDate(after, true), }.ExecuteAsync(new FakeConsole()); // Assert @@ -118,7 +118,7 @@ public class DateRangeSpecs ExportFormat = ExportFormat.Json, OutputPath = file.Path, Before = Snowflake.FromDate(before), - After = Snowflake.FromDate(after), + After = Snowflake.FromDate(after, true), }.ExecuteAsync(new FakeConsole()); // Assert diff --git a/DiscordChatExporter.Core/Discord/Snowflake.cs b/DiscordChatExporter.Core/Discord/Snowflake.cs index e5321047..2773c194 100644 --- a/DiscordChatExporter.Core/Discord/Snowflake.cs +++ b/DiscordChatExporter.Core/Discord/Snowflake.cs @@ -19,8 +19,24 @@ public partial record struct Snowflake { public static Snowflake Zero { get; } = new(0); - public static Snowflake FromDate(DateTimeOffset instant) => - new(((ulong)instant.ToUnixTimeMilliseconds() - 1420070400000UL) << 22); + /// + /// Creates and returns a Snowflake representing the given timestamp. + /// + /// + /// The timestamp that should be returned as a Snowflake. + /// + /// + /// Whether the Snowflake will be used to determine the messages starting at the given timestamp. + /// If true, the Snowflake doesn't precisely represent the given timestamp. Instead, it then is the latest possible + /// Snowflake just before that timestamp. + /// This is necessary to prevent the first Discord message in that specific millisecond from being excluded. + /// + /// A Snowflake representing the given timestamp. + public static Snowflake FromDate(DateTimeOffset timestamp, bool startTimestamp = false) => + new( + (((ulong)timestamp.ToUnixTimeMilliseconds() - 1420070400000UL) << 22) + - (startTimestamp ? 1UL : 0UL) + ); public static Snowflake? TryParse(string? value, IFormatProvider? formatProvider = null) { diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index 68ca75b4..b5ef7bc0 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Threading; using System.Threading.Tasks; using DiscordChatExporter.Core.Discord; @@ -27,13 +28,59 @@ public class ChannelExporter(DiscordClient discord) ); } + var currentPartitionIndex = 0; + // TODO: Maybe add a way to search for old files after a username change + if (File.Exists(request.OutputFilePath)) + { + // TODO: Add a way for the user to choose the setting + var choice = FileExistsHandling.Abort; + + switch (choice) + { + case FileExistsHandling.Abort: + Console.WriteLine("Channel aborted"); + return; + case FileExistsHandling.Overwrite: + Console.WriteLine("Removing old files"); + MessageExporter.RemoveExistingFiles(request.OutputFilePath); + break; + case FileExistsHandling.Append: + var lastMessageSnowflake = MessageExporter.GetLastMessageSnowflake( + request.OutputFilePath, + request.Format + ); + if (lastMessageSnowflake != null) + { + request.LastPriorMessage = lastMessageSnowflake.Value; + + if (!request.Channel.MayHaveMessagesAfter(request.LastPriorMessage.Value)) + { + Console.WriteLine("Download already up to date"); + return; + } + + Console.WriteLine( + "Downloading data after " + lastMessageSnowflake.Value.ToDate() + ); + currentPartitionIndex = MessageExporter.GetPartitionCount( + request.OutputFilePath + ); + } + break; + default: + throw new InvalidOperationException( + $"Unknown FileExistsHandling value '{choice}'." + ); + } + } + // Build context var context = new ExportContext(discord, request); await context.PopulateChannelsAndRolesAsync(cancellationToken); // Initialize the exporter before further checks to ensure the file is created even if // an exception is thrown after this point. - await using var messageExporter = new MessageExporter(context); + await using var messageExporter = new MessageExporter(context, currentPartitionIndex); // Check if the channel is empty if (request.Channel.IsEmpty) @@ -67,7 +114,7 @@ public class ChannelExporter(DiscordClient discord) await foreach ( var message in discord.GetMessagesAsync( request.Channel.Id, - request.After, + request.LastPriorMessage ?? request.After, request.Before, progress, cancellationToken diff --git a/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs b/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs index f6911ce3..9df2bba3 100644 --- a/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Utils.Extensions; @@ -12,6 +15,8 @@ namespace DiscordChatExporter.Core.Exporting; internal partial class CsvMessageWriter(Stream stream, ExportContext context) : MessageWriter(stream, context) { + private const int HeaderSize = 1; + private readonly TextWriter _writer = new StreamWriter(stream); private async ValueTask FormatMarkdownAsync( @@ -117,6 +122,51 @@ internal partial class CsvMessageWriter(Stream stream, ExportContext context) await _writer.DisposeAsync(); await base.DisposeAsync(); } + + /// + /// Retrieves and returns the timestamp of the last written message in the Discord channel that has been exported + /// with the CsvMessageWriter to the given file path as a Snowflake. + /// This timestamp has millisecond-level precision. + /// + /// + /// The path of the Discord channel CSV export whose last message's timestamp should be returned. + /// + /// + /// The timestamp of the last written message in the Discord channel CSV export under the given path as a Snowflake. + /// Null, if the Discord channel CSV export doesn't include any message. + /// + /// + /// Thrown if the file at the given path isn't a correctly formatted Discord channel CSV export. + /// + public static Snowflake? GetLastMessageDate(string filePath) + { + try + { + var fileLines = File.ReadAllLines(filePath) + .SkipWhile(string.IsNullOrWhiteSpace) + .ToArray(); + if (fileLines.Length <= HeaderSize) + return null; + + const string columnPattern = "(?:[^\"]?(?:\"\")?)*"; + var messageDatePattern = string.Format( + "^\"{0}\",\"{0}\",\"({0})\",\"{0}\",\"{0}\",\"{0}\"$", + columnPattern + ); + var messageDateRegex = new Regex(messageDatePattern); + + var timestampMatch = messageDateRegex.Match(fileLines[^1]); + var timestampString = timestampMatch.Groups[1].Value; + var timestamp = DateTimeOffset.Parse(timestampString); + return Snowflake.FromDate(timestamp, true); + } + catch (Exception ex) when (ex is IndexOutOfRangeException or FormatException) + { + throw new FormatException( + "The CSV file is not correctly formatted; the last message timestamp could not be retrieved." + ); + } + } } internal partial class CsvMessageWriter diff --git a/DiscordChatExporter.Core/Exporting/ExportRequest.cs b/DiscordChatExporter.Core/Exporting/ExportRequest.cs index 2804e6c2..7ba9b4e2 100644 --- a/DiscordChatExporter.Core/Exporting/ExportRequest.cs +++ b/DiscordChatExporter.Core/Exporting/ExportRequest.cs @@ -30,6 +30,8 @@ public partial class ExportRequest public Snowflake? Before { get; } + public Snowflake? LastPriorMessage { get; set; } + public PartitionLimit PartitionLimit { get; } public MessageFilter MessageFilter { get; } diff --git a/DiscordChatExporter.Core/Exporting/FileExistsHandling.cs b/DiscordChatExporter.Core/Exporting/FileExistsHandling.cs new file mode 100644 index 00000000..d6cfd41c --- /dev/null +++ b/DiscordChatExporter.Core/Exporting/FileExistsHandling.cs @@ -0,0 +1,23 @@ +namespace DiscordChatExporter.Core.Exporting; + +/// +/// Represents the setting on how to handle the export of a channel that has already been exported. +/// +public enum FileExistsHandling +{ + /// + /// If a channel had previously been exported, its export will be aborted. + /// + Abort, + + /// + /// If a channel had previously been exported, the existing export will be removed, and it will be exported again. + /// + Overwrite, + + /// + /// If a channel had previously been exported, the existing export will be appended, which means that only messages + /// after the last export will be exported. + /// + Append, +} diff --git a/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs b/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs index 05b1bba2..e6916fd2 100644 --- a/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs @@ -2,14 +2,16 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using WebMarkupMin.Core; namespace DiscordChatExporter.Core.Exporting; -internal class HtmlMessageWriter(Stream stream, ExportContext context, string themeName) +internal partial class HtmlMessageWriter(Stream stream, ExportContext context, string themeName) : MessageWriter(stream, context) { private readonly TextWriter _writer = new StreamWriter(stream); @@ -142,4 +144,50 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th await _writer.DisposeAsync(); await base.DisposeAsync(); } + + /// + /// Returns the statically created regex that detects and captures the timestamp of a message in a channel HTML + /// export. + /// + /// + /// The regex that detects and captures the timestamp of a message in a Discord channel HTML export. + /// + [GeneratedRegex("")] + private static partial Regex MessageDateRegex(); + + /// + /// Retrieves and returns the approximate timestamp of the last written message in the Discord channel that has + /// been exported with the HtmlMessageWriter to the given file path as a Snowflake. + /// This timestamp only has minute-level precision. + /// + /// + /// The path of the Discord channel HTML export whose last message's timestamp should be returned. + /// + /// + /// The approximate timestamp of the last written message in the Discord channel HTML export under the given path + /// as a Snowflake. + /// Null, if the Discord channel HTML export doesn't include any message. + /// + /// + /// Thrown if the file at the given path isn't a correctly formatted Discord channel HTML export. + /// + public static Snowflake? GetLastMessageDate(string filePath) + { + try + { + var fileContent = File.ReadAllText(filePath); + var messageDateRegex = MessageDateRegex(); + + var timestampMatches = messageDateRegex.Matches(fileContent); + var timestampString = timestampMatches[^1].Groups[1].Value; + var timestamp = DateTimeOffset.Parse(timestampString); + return Snowflake.FromDate(timestamp, true); + } + catch (Exception ex) when (ex is IndexOutOfRangeException or FormatException) + { + throw new FormatException( + "The HTML file is not correctly formatted; the last message timestamp could not be retrieved." + ); + } + } } diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index 5b1dfe48..a1dec60d 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -6,6 +6,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data.Embeds; using DiscordChatExporter.Core.Markdown.Parsing; @@ -579,4 +580,53 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) await _writer.DisposeAsync(); await base.DisposeAsync(); } + + /// + /// Retrieves and returns the Snowflake of the last written message in the Discord channel that has been + /// exported with the JsonMessageWriter to the given file path. + /// This Snowflake contains the message timestamp with millisecond-level precision and the message index. + /// + /// + /// The path of the Discord channel JSON export whose last message's timestamp should be returned. + /// + /// + /// The Snowflake of the last written message in the Discord channel JSON export under the given path. + /// Null, if the Discord channel JSON export doesn't include any message. + /// + /// + /// Thrown if the file at the given path isn't a correctly formatted Discord channel JSON export. + /// + public static Snowflake? GetLastMessageSnowflake(string filePath) + { + try + { + var fileContent = File.ReadAllText(filePath); + using var fileJson = JsonDocument.Parse(fileContent); + var messagesJson = fileJson + .RootElement.GetProperty("messages") + .EnumerateArray() + .ToArray(); + + if (messagesJson.Length == 0) + return null; + + var lastMessage = messagesJson[^1]; + var snowflakeInt = ulong.Parse(lastMessage.GetProperty("id").GetString()!); + var snowflake = new Snowflake(snowflakeInt); + return snowflake; + } + catch (Exception ex) + when (ex + is JsonException + or KeyNotFoundException + or InvalidOperationException + or FormatException + or NullReferenceException + ) + { + throw new FormatException( + "The JSON file is not correctly formatted; the last message timestamp could not be retrieved." + ); + } + } } diff --git a/DiscordChatExporter.Core/Exporting/MessageExporter.cs b/DiscordChatExporter.Core/Exporting/MessageExporter.cs index 1f0f7ca4..fe23b7b8 100644 --- a/DiscordChatExporter.Core/Exporting/MessageExporter.cs +++ b/DiscordChatExporter.Core/Exporting/MessageExporter.cs @@ -2,13 +2,15 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; namespace DiscordChatExporter.Core.Exporting; -internal partial class MessageExporter(ExportContext context) : IAsyncDisposable +internal partial class MessageExporter(ExportContext context, int initialPartitionIndex = 0) + : IAsyncDisposable { - private int _partitionIndex; + private int _partitionIndex = initialPartitionIndex; private MessageWriter? _writer; public long MessagesExported { get; private set; } @@ -101,21 +103,148 @@ internal partial class MessageExporter string filePath, ExportFormat format, ExportContext context - ) => - format switch + ) + { + // Don't accidentally overwrite anything + if (File.Exists(filePath)) { - ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context), - ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context), - ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"), - ExportFormat.HtmlLight => new HtmlMessageWriter( - File.Create(filePath), - context, - "Light" - ), - ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context), + throw new InvalidOperationException( + "Error: An exported file already exists. This should never happen." + ); + } + + var file = File.Create(filePath); + return format switch + { + ExportFormat.PlainText => new PlainTextMessageWriter(file, context), + ExportFormat.Csv => new CsvMessageWriter(file, context), + ExportFormat.HtmlDark => new HtmlMessageWriter(file, context, "Dark"), + ExportFormat.HtmlLight => new HtmlMessageWriter(file, context, "Light"), + ExportFormat.Json => new JsonMessageWriter(file, context), _ => throw new ArgumentOutOfRangeException( nameof(format), $"Unknown export format '{format}'." ), }; + } + + /// + /// Retrieves and returns the (approximate) Snowflake of the last written message in the Discord channel that has + /// previously been exported to the given file path in the given format. + /// + /// If the used format is JSON, this returns its exact Snowflake (which contains the precise message timestamp and + /// index and can therefore correctly determine which messages succeed it). + /// Otherwise, it returns an approximate Snowflake (which only contains the (possibly approximate) message + /// timestamp and may therefore label slightly earlier messages as succeeding). + /// + /// This automatically determines the number of partitions the export has been split up into and uses the last one. + /// + /// + /// The path of the first partition of the Discord channel export whose last message's timestamp should be returned. + /// + /// + /// The format of the Discord channel export whose last message's timestamp should be returned. + /// + /// + /// The (approximate) Snowflake of the last written message in the Discord channel that has been previously + /// exported to the given file path in the given format. + /// Null, if the Discord channel hasn't previously been exported or if the export doesn't include any message. + /// + /// + /// Thrown if the last Discord channel export partition file isn't correctly formatted according to the given + /// format. + /// + public static Snowflake? GetLastMessageSnowflake(string baseFilePath, ExportFormat format) + { + var partitionAmounts = GetPartitionCount(baseFilePath); + var lastPartitionFile = GetPartitionFilePath(baseFilePath, partitionAmounts - 1); + + var fileInfo = new FileInfo(lastPartitionFile); + if (fileInfo.Length == 0) + return null; + + return format switch + { + ExportFormat.PlainText => PlainTextMessageWriter.GetLastMessageDate(lastPartitionFile), + ExportFormat.Csv => CsvMessageWriter.GetLastMessageDate(lastPartitionFile), + ExportFormat.HtmlDark or ExportFormat.HtmlLight => HtmlMessageWriter.GetLastMessageDate( + lastPartitionFile + ), + ExportFormat.Json => JsonMessageWriter.GetLastMessageSnowflake(lastPartitionFile), + _ => throw new ArgumentOutOfRangeException( + nameof(format), + $"Unknown export format '{format}'." + ), + }; + } + + /// + /// Removes all partitions of the previously exported Discord channel with the given base file path. + /// + /// + /// The path of the first partition of the Discord channel export that should be removed. + /// + public static void RemoveExistingFiles(string baseFilePath) + { + var currentPartition = 0; + while (true) + { + var currentFilePath = GetPartitionFilePath(baseFilePath, currentPartition); + if (File.Exists(currentFilePath)) + { + File.Delete(currentFilePath); + } + else + { + return; + } + currentPartition++; + } + } + + /// + /// Determines and returns the number of partitions of the previously exported Discord channel with the given base + /// file path. + /// + /// + /// The path of the first partition of the Discord channel export whose number of partitions should be returned. + /// + /// + /// The number of partitions of the previously exported Discord channel with the given base file path. + /// + public static int GetPartitionCount(string baseFilePath) + { + // Use linear search to quickly determine the number of partitions + var currentPartition = 1; + while (true) + { + var currentFilePath = GetPartitionFilePath(baseFilePath, currentPartition - 1); + if (File.Exists(currentFilePath)) + { + currentPartition *= 2; + } + else + { + break; + } + } + + var leftBorder = currentPartition / 2; + var rightBorder = currentPartition; + while (rightBorder - 1 > leftBorder) + { + var middle = (leftBorder + rightBorder) / 2; + var currentFilePath = GetPartitionFilePath(baseFilePath, middle - 1); + if (File.Exists(currentFilePath)) + { + leftBorder = middle; + } + else + { + rightBorder = middle; + } + } + + return leftBorder; + } } diff --git a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs index 85b46a64..f96248ad 100644 --- a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs @@ -1,17 +1,22 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data.Embeds; using DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Exporting; -internal class PlainTextMessageWriter(Stream stream, ExportContext context) +internal partial class PlainTextMessageWriter(Stream stream, ExportContext context) : MessageWriter(stream, context) { + private const int HeaderSize = 4; + private readonly TextWriter _writer = new StreamWriter(stream); private async ValueTask FormatMarkdownAsync( @@ -271,4 +276,60 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context) await _writer.DisposeAsync(); await base.DisposeAsync(); } + + /// + /// Returns the statically created regex that detects and captures the timestamp of a message in a channel TXT + /// export. + /// + /// + /// The regex that detects and captures the timestamp of a message in a Discord channel TXT export. + /// + [GeneratedRegex(@"^\[(.*)\] .*")] + private static partial Regex MessageDateRegex(); + + /// + /// Retrieves and returns the approximate timestamp of the last written message in the Discord channel that has + /// been exported with the PlainTextMessageWriter to the given file path as a Snowflake. + /// This timestamp only has minute-level precision. + /// + /// + /// The path of the Discord channel TXT export whose last message's timestamp should be returned. + /// + /// + /// The approximate timestamp of the last written message in the Discord channel TXT export under the given path + /// as a Snowflake. + /// Null, if the Discord channel TXT export doesn't include any message. + /// + /// + /// Thrown if the file at the given path isn't a correctly formatted Discord channel TXT export. + /// + public static Snowflake? GetLastMessageDate(string filePath) + { + var fileLines = File.ReadAllLines(filePath); + if (fileLines.SkipWhile(string.IsNullOrWhiteSpace).ToArray().Length <= HeaderSize) + return null; + + var messageDateRegex = MessageDateRegex(); + + // Find the last line with a message timestamp + for (var i = fileLines.Length - 1; i >= HeaderSize; i--) + { + var timestampMatch = messageDateRegex.Match(fileLines[i]); + if (!timestampMatch.Success) + continue; + + var timestampString = timestampMatch.Groups[1].Value; + if ( + DateTimeOffset.TryParse(timestampString, out var timestamp) + && fileLines[i - 1] == "" + ) + { + return Snowflake.FromDate(timestamp, true); + } + } + + throw new FormatException( + "The TXT file is not correctly formatted; the last message timestamp could not be retrieved." + ); + } } diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index fd754e70..a03d62d8 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -268,8 +268,8 @@ public partial class DashboardViewModel : ViewModelBase dialog.OutputPath!, dialog.AssetsDirPath, dialog.SelectedFormat, - dialog.After?.Pipe(Snowflake.FromDate), - dialog.Before?.Pipe(Snowflake.FromDate), + dialog.After?.Pipe(timestamp => Snowflake.FromDate(timestamp, true)), + dialog.Before?.Pipe(timestamp => Snowflake.FromDate(timestamp)), dialog.PartitionLimit, dialog.MessageFilter, dialog.ShouldFormatMarkdown, diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs index 4574f6ec..e2f8d658 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs @@ -128,8 +128,8 @@ public partial class ExportSetupViewModel( Guild!, Channels!.Single(), SelectedFormat, - After?.Pipe(Snowflake.FromDate), - Before?.Pipe(Snowflake.FromDate) + After?.Pipe(timestamp => Snowflake.FromDate(timestamp, true)), + Before?.Pipe(timestamp => Snowflake.FromDate(timestamp)) ); var extension = SelectedFormat.GetFileExtension();