Skip to content

Commit

Permalink
Prerequisites for CLI filename template support (#886)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ScrubN authored Nov 12, 2023
1 parent 6842fe5 commit 64eabae
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 183 deletions.
2 changes: 1 addition & 1 deletion TwitchDownloaderCore/Chat/ChatJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
77 changes: 62 additions & 15 deletions TwitchDownloaderCore/Extensions/ReadOnlySpanExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,91 @@ namespace TwitchDownloaderCore.Extensions
{
public static class ReadOnlySpanExtensions
{
/// <summary>Replaces all occurrences of <paramref name="oldChar"/> not prepended by a backslash with <paramref name="newChar"/>.</summary>
public static bool TryReplaceNonEscaped(this ReadOnlySpan<char> str, Span<char> destination, out int charsWritten, char oldChar, char newChar)
/// <summary>Replaces all occurrences of <paramref name="oldChar"/> not prepended by a backslash or contained within quotation marks with <paramref name="newChar"/>.</summary>
public static bool TryReplaceNonEscaped(this ReadOnlySpan<char> str, Span<char> 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<char> 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;
}
}
}
43 changes: 43 additions & 0 deletions TwitchDownloaderCore/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;

namespace TwitchDownloaderCore.Extensions
{
public static class StringExtensions
{
public static string ReplaceAny(this string str, ReadOnlySpan<char> 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();
}
}
}
90 changes: 90 additions & 0 deletions TwitchDownloaderCore/Tools/FilenameService.cs
Original file line number Diff line number Diff line change
@@ -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, '_');
}
}
Loading

0 comments on commit 64eabae

Please sign in to comment.