From 64eabaee298afe8379886ea966790e6c4c55063e Mon Sep 17 00:00:00 2001 From: Scrub <72096833+ScrubN@users.noreply.github.com> Date: Sun, 12 Nov 2023 03:27:59 -0500 Subject: [PATCH] Prerequisites for CLI filename template support (#886) * Move FilenameService to Core * Reduce filename generator memory footprint * Fix game name not being sanitized * string.IsNullOrWhiteSpace -> string.IsNullOrEmpty * Support escaping with quote marks in both ReadOnlySpanExtensions.TryReplaceNonEscaped and TimeSpanHFormat * Rename TimeSpanExtensions to UrlTimeCode --- TwitchDownloaderCore/Chat/ChatJson.cs | 2 +- .../Extensions/ReadOnlySpanExtensions.cs | 77 ++++++++-- .../Extensions/StringExtensions.cs | 43 ++++++ TwitchDownloaderCore/Tools/FilenameService.cs | 90 +++++++++++ TwitchDownloaderCore/Tools/TimeSpanHFormat.cs | 88 ++++++++--- .../UrlTimeCode.cs} | 6 +- TwitchDownloaderWPF/PageChatDownload.xaml.cs | 2 +- TwitchDownloaderWPF/PageChatUpdate.xaml.cs | 1 + TwitchDownloaderWPF/PageVodDownload.xaml.cs | 2 +- .../Services/FilenameService.cs | 141 ------------------ .../WindowQueueOptions.xaml.cs | 1 + 11 files changed, 270 insertions(+), 183 deletions(-) create mode 100644 TwitchDownloaderCore/Extensions/StringExtensions.cs create mode 100644 TwitchDownloaderCore/Tools/FilenameService.cs rename TwitchDownloaderCore/{Extensions/TimeSpanExtensions.cs => Tools/UrlTimeCode.cs} (91%) delete mode 100644 TwitchDownloaderWPF/Services/FilenameService.cs diff --git a/TwitchDownloaderCore/Chat/ChatJson.cs b/TwitchDownloaderCore/Chat/ChatJson.cs index b156a184..bdfe1bc4 100644 --- a/TwitchDownloaderCore/Chat/ChatJson.cs +++ b/TwitchDownloaderCore/Chat/ChatJson.cs @@ -207,7 +207,7 @@ private static async Task UpgradeChatJson(ChatRoot chatRoot) if (chatRoot.video.duration is not null) { - chatRoot.video.length = TimeSpanExtensions.ParseTimeCode(chatRoot.video.duration).TotalSeconds; + chatRoot.video.length = UrlTimeCode.Parse(chatRoot.video.duration).TotalSeconds; chatRoot.video.end = chatRoot.video.length; chatRoot.video.duration = null; } diff --git a/TwitchDownloaderCore/Extensions/ReadOnlySpanExtensions.cs b/TwitchDownloaderCore/Extensions/ReadOnlySpanExtensions.cs index 5a3fcaf3..433553bd 100644 --- a/TwitchDownloaderCore/Extensions/ReadOnlySpanExtensions.cs +++ b/TwitchDownloaderCore/Extensions/ReadOnlySpanExtensions.cs @@ -4,44 +4,91 @@ namespace TwitchDownloaderCore.Extensions { public static class ReadOnlySpanExtensions { - /// Replaces all occurrences of not prepended by a backslash with . - public static bool TryReplaceNonEscaped(this ReadOnlySpan str, Span destination, out int charsWritten, char oldChar, char newChar) + /// Replaces all occurrences of not prepended by a backslash or contained within quotation marks with . + public static bool TryReplaceNonEscaped(this ReadOnlySpan str, Span destination, char oldChar, char newChar) { + const string ESCAPE_CHARS = @"\'"""; + if (destination.Length < str.Length) - { - charsWritten = 0; return false; - } str.CopyTo(destination); - charsWritten = str.Length; var firstIndex = destination.IndexOf(oldChar); - if (firstIndex == -1) - { return true; - } - firstIndex = Math.Min(firstIndex, destination.IndexOf('\\')); + var firstEscapeIndex = destination.IndexOfAny(ESCAPE_CHARS); + if (firstEscapeIndex != -1 && firstEscapeIndex < firstIndex) + firstIndex = firstEscapeIndex; - for (var i = firstIndex; i < str.Length; i++) + var lastIndex = destination.LastIndexOf(oldChar); + var lastEscapeIndex = destination.LastIndexOfAny(ESCAPE_CHARS); + if (lastEscapeIndex != -1 && lastEscapeIndex > lastIndex) + lastIndex = lastEscapeIndex; + + lastIndex++; + for (var i = firstIndex; i < lastIndex; i++) { var readChar = destination[i]; - if (readChar == '\\' && i + 1 < str.Length) + switch (readChar) + { + case '\\': + i++; + break; + case '\'': + case '\"': + { + i = FindCloseQuoteMark(destination, i, lastIndex, readChar); + + if (i == -1) + { + destination.Clear(); + return false; + } + + break; + } + default: + { + if (readChar == oldChar) + { + destination[i] = newChar; + } + + break; + } + } + } + + return true; + } + + private static int FindCloseQuoteMark(ReadOnlySpan destination, int openQuoteIndex, int endIndex, char readChar) + { + var i = openQuoteIndex + 1; + var quoteFound = false; + while (i < endIndex) + { + var readCharQuote = destination[i]; + i++; + + if (readCharQuote == '\\') { i++; continue; } - if (readChar == oldChar) + if (readCharQuote == readChar) { - destination[i] = newChar; + i--; + quoteFound = true; + break; } } - return true; + return quoteFound ? i : -1; } } } \ No newline at end of file diff --git a/TwitchDownloaderCore/Extensions/StringExtensions.cs b/TwitchDownloaderCore/Extensions/StringExtensions.cs new file mode 100644 index 00000000..cb0fa60d --- /dev/null +++ b/TwitchDownloaderCore/Extensions/StringExtensions.cs @@ -0,0 +1,43 @@ +using System; + +namespace TwitchDownloaderCore.Extensions +{ + public static class StringExtensions + { + public static string ReplaceAny(this string str, ReadOnlySpan oldChars, char newChar) + { + if (string.IsNullOrEmpty(str)) + { + return str; + } + + var index = str.AsSpan().IndexOfAny(oldChars); + if (index == -1) + { + return str; + } + + const ushort MAX_STACK_SIZE = 512; + var span = str.Length <= MAX_STACK_SIZE + ? stackalloc char[str.Length] + : str.ToCharArray(); + + // Unfortunately this cannot be inlined with the previous statement because a ternary is required for the stackalloc to compile + if (str.Length <= MAX_STACK_SIZE) + str.CopyTo(span); + + var tempSpan = span; + do + { + tempSpan[index] = newChar; + tempSpan = tempSpan[(index + 1)..]; + + index = tempSpan.IndexOfAny(oldChars); + if (index == -1) + break; + } while (true); + + return span.ToString(); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Tools/FilenameService.cs b/TwitchDownloaderCore/Tools/FilenameService.cs new file mode 100644 index 00000000..408116a1 --- /dev/null +++ b/TwitchDownloaderCore/Tools/FilenameService.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using TwitchDownloaderCore.Extensions; + +namespace TwitchDownloaderCore.Tools +{ + public static class FilenameService + { + private static string[] GetTemplateSubfolders(ref string fullPath) + { + var returnString = fullPath.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + fullPath = returnString[^1]; + Array.Resize(ref returnString, returnString.Length - 1); + + for (var i = 0; i < returnString.Length; i++) + { + returnString[i] = RemoveInvalidFilenameChars(returnString[i]); + } + + return returnString; + } + + public static string GetFilename(string template, string title, string id, DateTime date, string channel, TimeSpan cropStart, TimeSpan cropEnd, string viewCount, string game) + { + var videoLength = cropEnd - cropStart; + + var stringBuilder = new StringBuilder(template) + .Replace("{title}", RemoveInvalidFilenameChars(title)) + .Replace("{id}", id) + .Replace("{channel}", RemoveInvalidFilenameChars(channel)) + .Replace("{date}", date.ToString("Mdyy")) + .Replace("{random_string}", Path.GetRandomFileName().Replace(".", "")) + .Replace("{crop_start}", TimeSpanHFormat.ReusableInstance.Format(@"HH\-mm\-ss", cropStart)) + .Replace("{crop_end}", TimeSpanHFormat.ReusableInstance.Format(@"HH\-mm\-ss", cropEnd)) + .Replace("{length}", TimeSpanHFormat.ReusableInstance.Format(@"HH\-mm\-ss", videoLength)) + .Replace("{views}", viewCount) + .Replace("{game}", RemoveInvalidFilenameChars(game)); + + if (template.Contains("{date_custom=")) + { + var dateRegex = new Regex("{date_custom=\"(.*)\"}"); + ReplaceCustomWithFormattable(stringBuilder, dateRegex, date); + } + + if (template.Contains("{crop_start_custom=")) + { + var cropStartRegex = new Regex("{crop_start_custom=\"(.*)\"}"); + ReplaceCustomWithFormattable(stringBuilder, cropStartRegex, cropStart); + } + + if (template.Contains("{crop_end_custom=")) + { + var cropEndRegex = new Regex("{crop_end_custom=\"(.*)\"}"); + ReplaceCustomWithFormattable(stringBuilder, cropEndRegex, cropEnd); + } + + if (template.Contains("{length_custom=")) + { + var lengthRegex = new Regex("{length_custom=\"(.*)\"}"); + ReplaceCustomWithFormattable(stringBuilder, lengthRegex, videoLength); + } + + var fileName = stringBuilder.ToString(); + var additionalSubfolders = GetTemplateSubfolders(ref fileName); + return Path.Combine(Path.Combine(additionalSubfolders), RemoveInvalidFilenameChars(fileName)); + } + + private static void ReplaceCustomWithFormattable(StringBuilder sb, Regex regex, IFormattable formattable, IFormatProvider formatProvider = null) + { + do + { + // There's probably a better way to do this that doesn't require calling ToString() + // However we need .NET7+ for span support in the regex matcher. + var match = regex.Match(sb.ToString()); + if (!match.Success) + break; + + var formatString = match.Groups[1].Value; + sb.Remove(match.Groups[0].Index, match.Groups[0].Length); + sb.Insert(match.Groups[0].Index, RemoveInvalidFilenameChars(formattable.ToString(formatString, formatProvider))); + } while (true); + } + + private static readonly char[] FilenameInvalidChars = Path.GetInvalidFileNameChars(); + + private static string RemoveInvalidFilenameChars(string filename) => filename.ReplaceAny(FilenameInvalidChars, '_'); + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Tools/TimeSpanHFormat.cs b/TwitchDownloaderCore/Tools/TimeSpanHFormat.cs index f9fbb120..531f04cb 100644 --- a/TwitchDownloaderCore/Tools/TimeSpanHFormat.cs +++ b/TwitchDownloaderCore/Tools/TimeSpanHFormat.cs @@ -49,76 +49,121 @@ public string Format(string format, TimeSpan timeSpan, IFormatProvider formatPro if (timeSpan.Days == 0) { var newFormat = format.Length <= 256 ? stackalloc char[format.Length] : new char[format.Length]; - if (!format.AsSpan().TryReplaceNonEscaped(newFormat, out var charsWritten, 'H', 'h')) + if (!format.AsSpan().TryReplaceNonEscaped(newFormat, 'H', 'h')) { - throw new Exception("Failed to generate ToString() compatible format. This should not have been possible."); + throw new FormatException($"Invalid character escaping in the format string: {format}"); } // If the format contains more than 2 sequential unescaped h's, it will throw a format exception. If so, we can fallback to our parser. if (newFormat.IndexOf("hhh") == -1) { - return HandleOtherFormats(newFormat[..charsWritten].ToString(), timeSpan, formatProvider); + return HandleOtherFormats(newFormat.ToString(), timeSpan, formatProvider); } } - var sb = new StringBuilder(format.Length); + return HandleBigHFormat(format.AsSpan(), timeSpan); + } + + private static string HandleBigHFormat(ReadOnlySpan format, TimeSpan timeSpan) + { + var formatLength = format.Length; + var sb = new StringBuilder(formatLength); var regularFormatCharStart = -1; var bigHStart = -1; - var formatSpan = format.AsSpan(); - for (var i = 0; i < formatSpan.Length; i++) + for (var i = 0; i < formatLength; i++) { - var readChar = formatSpan[i]; + var readChar = format[i]; if (readChar == 'H') { if (bigHStart == -1) - { bigHStart = i; - } if (regularFormatCharStart != -1) { - AppendRegularFormat(sb, timeSpan, format, regularFormatCharStart, i - regularFormatCharStart); + var formatEnd = i - regularFormatCharStart; + AppendRegularFormat(sb, timeSpan, format.Slice(regularFormatCharStart, formatEnd)); regularFormatCharStart = -1; } } else { if (regularFormatCharStart == -1) - { regularFormatCharStart = i; - } if (bigHStart != -1) { - AppendBigHFormat(sb, timeSpan, i - bigHStart); + var bigHCount = i - bigHStart; + AppendBigHFormat(sb, timeSpan, bigHCount); bigHStart = -1; } - // If the current char is an escape we can skip the next char - if (readChar == '\\' && i + 1 < formatSpan.Length) + switch (readChar) { - i++; + // If the current char is an escape we can skip the next char + case '\\' when i + 1 < formatLength: + i++; + continue; + // If the current char is a quote we can skip the next quote, if it exists + case '\'' when i + 1 < formatLength: + case '\"' when i + 1 < formatLength: + { + i = FindCloseQuoteMark(format, i, formatLength, readChar); + + if (i == -1) + { + throw new FormatException($"Invalid character escaping in the format string: {format}"); + } + + continue; + } } } } if (regularFormatCharStart != -1) { - AppendRegularFormat(sb, timeSpan, format, regularFormatCharStart, formatSpan.Length - regularFormatCharStart); + var formatEnd = format.Length - regularFormatCharStart; + AppendRegularFormat(sb, timeSpan, format.Slice(regularFormatCharStart, formatEnd)); } else if (bigHStart != -1) { - AppendBigHFormat(sb, timeSpan, formatSpan.Length - bigHStart); + var bigHCount = format.Length - bigHStart; + AppendBigHFormat(sb, timeSpan, bigHCount); } return sb.ToString(); } - private static void AppendRegularFormat(StringBuilder sb, TimeSpan timeSpan, string formatString, int start, int length) + private static int FindCloseQuoteMark(ReadOnlySpan format, int openQuoteIndex, int endIndex, char readChar) + { + var i = openQuoteIndex + 1; + var quoteFound = false; + while (i < endIndex) + { + var readCharQuote = format[i]; + i++; + + if (readCharQuote == '\\') + { + i++; + continue; + } + + if (readCharQuote == readChar) + { + i--; + quoteFound = true; + break; + } + } + + return quoteFound ? i : -1; + } + + private static void AppendRegularFormat(StringBuilder sb, TimeSpan timeSpan, ReadOnlySpan format) { Span destination = stackalloc char[256]; - var format = formatString.AsSpan(start, length); if (timeSpan.TryFormat(destination, out var charsWritten, format)) { @@ -132,7 +177,8 @@ private static void AppendRegularFormat(StringBuilder sb, TimeSpan timeSpan, str private static void AppendBigHFormat(StringBuilder sb, TimeSpan timeSpan, int count) { - Span destination = stackalloc char[8]; + const int TIMESPAN_MAX_HOURS_LENGTH = 9; // The maximum integer hours a TimeSpan can hold is 256204778. + Span destination = stackalloc char[TIMESPAN_MAX_HOURS_LENGTH]; Span format = stackalloc char[count]; format.Fill('0'); diff --git a/TwitchDownloaderCore/Extensions/TimeSpanExtensions.cs b/TwitchDownloaderCore/Tools/UrlTimeCode.cs similarity index 91% rename from TwitchDownloaderCore/Extensions/TimeSpanExtensions.cs rename to TwitchDownloaderCore/Tools/UrlTimeCode.cs index 508ff4d0..57498951 100644 --- a/TwitchDownloaderCore/Extensions/TimeSpanExtensions.cs +++ b/TwitchDownloaderCore/Tools/UrlTimeCode.cs @@ -1,15 +1,15 @@ using System; -namespace TwitchDownloaderCore.Extensions +namespace TwitchDownloaderCore.Tools { - public static class TimeSpanExtensions + public static class UrlTimeCode { /// /// Converts the span representation of a time interval in the format of '2d21h11m9s' to its equivalent. /// /// A span containing the characters that represent the time interval to convert. /// The equivalent to the time interval contained in the span. - public static TimeSpan ParseTimeCode(ReadOnlySpan input) + public static TimeSpan Parse(ReadOnlySpan input) { var dayIndex = input.IndexOf('d'); var hourIndex = input.IndexOf('h'); diff --git a/TwitchDownloaderWPF/PageChatDownload.xaml.cs b/TwitchDownloaderWPF/PageChatDownload.xaml.cs index 08fa9ac7..4ed52a29 100644 --- a/TwitchDownloaderWPF/PageChatDownload.xaml.cs +++ b/TwitchDownloaderWPF/PageChatDownload.xaml.cs @@ -137,7 +137,7 @@ private async Task GetVideoInfo() var urlTimeCodeMatch = TwitchRegex.UrlTimeCode.Match(textUrl.Text); if (urlTimeCodeMatch.Success) { - var time = TimeSpanExtensions.ParseTimeCode(urlTimeCodeMatch.ValueSpan); + var time = UrlTimeCode.Parse(urlTimeCodeMatch.ValueSpan); checkCropStart.IsChecked = true; numStartHour.Value = time.Hours; numStartMinute.Value = time.Minutes; diff --git a/TwitchDownloaderWPF/PageChatUpdate.xaml.cs b/TwitchDownloaderWPF/PageChatUpdate.xaml.cs index 706ac3f3..38cb7d5d 100644 --- a/TwitchDownloaderWPF/PageChatUpdate.xaml.cs +++ b/TwitchDownloaderWPF/PageChatUpdate.xaml.cs @@ -12,6 +12,7 @@ using TwitchDownloaderCore; using TwitchDownloaderCore.Chat; using TwitchDownloaderCore.Options; +using TwitchDownloaderCore.Tools; using TwitchDownloaderCore.TwitchObjects; using TwitchDownloaderCore.TwitchObjects.Gql; using TwitchDownloaderWPF.Properties; diff --git a/TwitchDownloaderWPF/PageVodDownload.xaml.cs b/TwitchDownloaderWPF/PageVodDownload.xaml.cs index 01cd107a..98717176 100644 --- a/TwitchDownloaderWPF/PageVodDownload.xaml.cs +++ b/TwitchDownloaderWPF/PageVodDownload.xaml.cs @@ -146,7 +146,7 @@ private async Task GetVideoInfo() var urlTimeCodeMatch = TwitchRegex.UrlTimeCode.Match(textUrl.Text); if (urlTimeCodeMatch.Success) { - var time = TimeSpanExtensions.ParseTimeCode(urlTimeCodeMatch.ValueSpan); + var time = UrlTimeCode.Parse(urlTimeCodeMatch.ValueSpan); checkStart.IsChecked = true; numStartHour.Value = time.Hours; numStartMinute.Value = time.Minutes; diff --git a/TwitchDownloaderWPF/Services/FilenameService.cs b/TwitchDownloaderWPF/Services/FilenameService.cs deleted file mode 100644 index 5cb3a678..00000000 --- a/TwitchDownloaderWPF/Services/FilenameService.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using TwitchDownloaderCore.Tools; - -namespace TwitchDownloaderWPF.Services -{ - public static class FilenameService - { - private static string[] GetTemplateSubfolders(ref string fullPath) - { - var returnString = fullPath.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); - fullPath = returnString[^1]; - Array.Resize(ref returnString, returnString.Length - 1); - - for (var i = 0; i < returnString.Length; i++) - { - returnString[i] = RemoveInvalidFilenameChars(returnString[i]); - } - - return returnString; - } - - internal static string GetFilename(string template, string title, string id, DateTime date, string channel, TimeSpan cropStart, TimeSpan cropEnd, string viewCount, string game) - { - var videoLength = cropEnd - cropStart; - - var stringBuilder = new StringBuilder(template) - .Replace("{title}", RemoveInvalidFilenameChars(title)) - .Replace("{id}", id) - .Replace("{channel}", RemoveInvalidFilenameChars(channel)) - .Replace("{date}", date.ToString("Mdyy")) - .Replace("{random_string}", Path.GetRandomFileName().Replace(".", "")) - .Replace("{crop_start}", TimeSpanHFormat.ReusableInstance.Format(@"HH\-mm\-ss", cropStart)) - .Replace("{crop_end}", TimeSpanHFormat.ReusableInstance.Format(@"HH\-mm\-ss", cropEnd)) - .Replace("{length}", TimeSpanHFormat.ReusableInstance.Format(@"HH\-mm\-ss", videoLength)) - .Replace("{views}", viewCount) - .Replace("{game}", game); - - if (template.Contains("{date_custom=")) - { - var dateRegex = new Regex("{date_custom=\"(.*)\"}"); - var dateDone = false; - while (!dateDone) - { - var dateMatch = dateRegex.Match(stringBuilder.ToString()); - if (dateMatch.Success) - { - var formatString = dateMatch.Groups[1].Value; - stringBuilder.Remove(dateMatch.Groups[0].Index, dateMatch.Groups[0].Length); - stringBuilder.Insert(dateMatch.Groups[0].Index, RemoveInvalidFilenameChars(date.ToString(formatString))); - } - else - { - dateDone = true; - } - } - } - - if (template.Contains("{crop_start_custom=")) - { - var cropStartRegex = new Regex("{crop_start_custom=\"(.*)\"}"); - var cropStartDone = false; - while (!cropStartDone) - { - var cropStartMatch = cropStartRegex.Match(stringBuilder.ToString()); - if (cropStartMatch.Success) - { - var formatString = cropStartMatch.Groups[1].Value; - stringBuilder.Remove(cropStartMatch.Groups[0].Index, cropStartMatch.Groups[0].Length); - stringBuilder.Insert(cropStartMatch.Groups[0].Index, RemoveInvalidFilenameChars(cropStart.ToString(formatString))); - } - else - { - cropStartDone = true; - } - } - } - - if (template.Contains("{crop_end_custom=")) - { - var cropEndRegex = new Regex("{crop_end_custom=\"(.*)\"}"); - var cropEndDone = false; - while (!cropEndDone) - { - var cropEndMatch = cropEndRegex.Match(stringBuilder.ToString()); - if (cropEndMatch.Success) - { - var formatString = cropEndMatch.Groups[1].Value; - stringBuilder.Remove(cropEndMatch.Groups[0].Index, cropEndMatch.Groups[0].Length); - stringBuilder.Insert(cropEndMatch.Groups[0].Index, RemoveInvalidFilenameChars(cropEnd.ToString(formatString))); - } - else - { - cropEndDone = true; - } - } - } - - if (template.Contains("{length_custom=")) - { - var lengthRegex = new Regex("{length_custom=\"(.*)\"}"); - var lengthDone = false; - while (!lengthDone) - { - var lengthMatch = lengthRegex.Match(stringBuilder.ToString()); - if (lengthMatch.Success) - { - var formatString = lengthMatch.Groups[1].Value; - stringBuilder.Remove(lengthMatch.Groups[0].Index, lengthMatch.Groups[0].Length); - stringBuilder.Insert(lengthMatch.Groups[0].Index, RemoveInvalidFilenameChars(videoLength.ToString(formatString))); - } - else - { - lengthDone = true; - } - } - } - - var fileName = stringBuilder.ToString(); - var additionalSubfolders = GetTemplateSubfolders(ref fileName); - return Path.Combine(Path.Combine(additionalSubfolders), RemoveInvalidFilenameChars(fileName)); - } - - private static string RemoveInvalidFilenameChars(string filename) - { - if (string.IsNullOrWhiteSpace(filename)) - { - return filename; - } - - if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) == -1) - { - return filename; - } - - return string.Join('_', filename.Split(Path.GetInvalidFileNameChars())); - } - } -} \ No newline at end of file diff --git a/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs b/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs index db63129b..95db422f 100644 --- a/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs +++ b/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Media; using TwitchDownloaderCore.Chat; using TwitchDownloaderCore.Options; +using TwitchDownloaderCore.Tools; using TwitchDownloaderWPF.Properties; using TwitchDownloaderWPF.Services; using TwitchDownloaderWPF.TwitchTasks;