Skip to content

Commit

Permalink
Replace CLI seconds with FFmpeg-style time duration parsing (#1032)
Browse files Browse the repository at this point in the history
* Create Time struct for parsing various time strings
This enables the use of "12", "12s", "12000ms", "0:00:12", "0.2m" to get a value of 12 seconds

* Create tests

* Implement Time in CLI

* I hate working with CommandLineParser

* Update documentation

* Use decimal instead of double to avoid rounding errors

* Rename Time -> TimeDuration

* Add more tests
  • Loading branch information
ScrubN authored Apr 7, 2024
1 parent 6d97a51 commit a3e1338
Show file tree
Hide file tree
Showing 14 changed files with 241 additions and 41 deletions.
1 change: 1 addition & 0 deletions TwitchDownloaderCLI.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Xunit;
42 changes: 42 additions & 0 deletions TwitchDownloaderCLI.Tests/ModelTests/TimeDurationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using TwitchDownloaderCLI.Models;
using static System.TimeSpan;

namespace TwitchDownloaderCLI.Tests.ModelTests
{
public class TimeDurationTests
{
[Theory]
[InlineData("200ms", 200 * TicksPerMillisecond)]
[InlineData("55", 55 * TicksPerSecond)]
[InlineData("0.2", 2 * TicksPerSecond / 10)]
[InlineData("23.189", 23189 * TicksPerSecond / 1000)]
[InlineData("55s", 55 * TicksPerSecond)]
[InlineData("17m", 17 * TicksPerMinute)]
[InlineData("31h", 31 * TicksPerHour)]
[InlineData("0:09:27", 9 * TicksPerMinute + 27 * TicksPerSecond)]
[InlineData("11:30", 11 * TicksPerHour + 30 * TicksPerMinute)]
[InlineData("12:03:45", 12 * TicksPerHour + 3 * TicksPerMinute + 45 * TicksPerSecond)]
public void CorrectlyParsesTimeStrings(string input, long expectedTicks)
{
var expected = new TimeDuration(expectedTicks);

var actual = new TimeDuration(input);

Assert.Equal(expected, actual);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("100 s")]
[InlineData("123d")]
[InlineData("0:12345")]
public void ThrowsOnBadFormat(string input)
{
Assert.ThrowsAny<Exception>(() =>
{
_ = TimeDuration.Parse(input);
});
}
}
}
27 changes: 27 additions & 0 deletions TwitchDownloaderCLI.Tests/TwitchDownloaderCLI.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0"/>
<PackageReference Include="xunit" Version="2.5.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3"/>
</ItemGroup>

<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\TwitchDownloaderCLI\TwitchDownloaderCLI.csproj" />
</ItemGroup>

</Project>
91 changes: 91 additions & 0 deletions TwitchDownloaderCLI/Models/TimeDuration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;

namespace TwitchDownloaderCLI.Models
{
[DebuggerDisplay("{_timeSpan}")]
public readonly record struct TimeDuration
{
private readonly TimeSpan _timeSpan;

/// <summary>
/// Constructor used by CommandLineParser
/// </summary>
public TimeDuration(string str)
{
this = Parse(str);
}

public TimeDuration(TimeSpan timeSpan)
{
_timeSpan = timeSpan;
}

public TimeDuration(long ticks)
{
_timeSpan = TimeSpan.FromTicks(ticks);
}

public static TimeDuration Parse(string str)
{
if (string.IsNullOrWhiteSpace(str))
{
throw new FormatException();
}

if (str.Contains(':'))
{
var timeSpan = TimeSpan.Parse(str);
return new TimeDuration(timeSpan);
}

var multiplier = GetMultiplier(str, out var span);
if (decimal.TryParse(span, NumberStyles.Number, null, out var result))
{
var ticks = (long)(result * multiplier);
return new TimeDuration(ticks);
}

throw new FormatException();
}

private static long GetMultiplier(string input, out ReadOnlySpan<char> trimmedInput)
{
if (char.IsDigit(input[^1]))
{
trimmedInput = input.AsSpan();
return TimeSpan.TicksPerSecond;
}

if (Regex.IsMatch(input, @"\dms$", RegexOptions.RightToLeft))
{
trimmedInput = input.AsSpan()[..^2];
return TimeSpan.TicksPerMillisecond;
}

if (Regex.IsMatch(input, @"\ds$", RegexOptions.RightToLeft))
{
trimmedInput = input.AsSpan()[..^1];
return TimeSpan.TicksPerSecond;
}

if (Regex.IsMatch(input, @"\dm$", RegexOptions.RightToLeft))
{
trimmedInput = input.AsSpan()[..^1];
return TimeSpan.TicksPerMinute;
}

if (Regex.IsMatch(input, @"\dh$", RegexOptions.RightToLeft))
{
trimmedInput = input.AsSpan()[..^1];
return TimeSpan.TicksPerHour;
}

throw new FormatException();
}

public static implicit operator TimeSpan(TimeDuration timeDuration) => timeDuration._timeSpan;
}
}
9 changes: 5 additions & 4 deletions TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CommandLine;
using TwitchDownloaderCLI.Models;
using TwitchDownloaderCore.Tools;

namespace TwitchDownloaderCLI.Modes.Arguments
Expand All @@ -15,11 +16,11 @@ internal sealed class ChatDownloadArgs : TwitchDownloaderArgs
[Option("compression", Default = ChatCompression.None, HelpText = "Compresses an output json chat file using a specified compression, usually resulting in 40-90% size reductions. Valid values are: None, Gzip.")]
public ChatCompression Compression { get; set; }

[Option('b', "beginning", HelpText = "Time in seconds to crop beginning.")]
public double CropBeginningTime { get; set; }
[Option('b', "beginning", HelpText = "Time to crop beginning. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")]
public TimeDuration CropBeginningTime { get; set; }

[Option('e', "ending", HelpText = "Time in seconds to crop ending.")]
public double CropEndingTime { get; set; }
[Option('e', "ending", HelpText = "Time to crop ending. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")]
public TimeDuration CropEndingTime { get; set; }

[Option('E', "embed-images", Default = false, HelpText = "Embed first party emotes, badges, and cheermotes into the chat download for offline rendering.")]
public bool EmbedData { get; set; }
Expand Down
9 changes: 5 additions & 4 deletions TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CommandLine;
using TwitchDownloaderCLI.Models;

namespace TwitchDownloaderCLI.Modes.Arguments
{
Expand Down Expand Up @@ -26,11 +27,11 @@ internal sealed class ChatRenderArgs : TwitchDownloaderArgs
[Option('h', "chat-height", Default = 600, HelpText = "Height of chat render.")]
public int ChatHeight { get; set; }

[Option('b', "beginning", Default = -1, HelpText = "Time in seconds to crop beginning of the render.")]
public int CropBeginningTime { get; set; }
[Option('b', "beginning", HelpText = "Time to crop beginning of the render. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")]
public TimeDuration CropBeginningTime { get; set; } = new(-1);

[Option('e', "ending", Default = -1, HelpText = "Time in seconds to crop ending of the render.")]
public int CropEndingTime { get; set; }
[Option('e', "ending", HelpText = "Time to crop ending of the render. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")]
public TimeDuration CropEndingTime { get; set; } = new(-1);

[Option("bttv", Default = true, HelpText = "Enable BTTV emotes.")]
public bool? BttvEmotes { get; set; }
Expand Down
9 changes: 5 additions & 4 deletions TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CommandLine;
using TwitchDownloaderCLI.Models;
using TwitchDownloaderCore.Tools;

namespace TwitchDownloaderCLI.Modes.Arguments
Expand All @@ -21,11 +22,11 @@ internal sealed class ChatUpdateArgs : TwitchDownloaderArgs
[Option('R', "replace-embeds", Default = false, HelpText = "Replace all embedded emotes, badges, and cheermotes in the file. All embedded images will be overwritten!")]
public bool ReplaceEmbeds { get; set; }

[Option('b', "beginning", Default = -1, HelpText = "New time in seconds for chat beginning. Comments may be added but not removed. -1 = No crop.")]
public int CropBeginningTime { get; set; }
[Option('b', "beginning", HelpText = "New time for chat beginning. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##). Comments may be added but not removed. -1 = No crop.")]
public TimeDuration CropBeginningTime { get; set; } = new(-1);

[Option('e', "ending", Default = -1, HelpText = "New time in seconds for chat ending. Comments may be added but not removed. -1 = No crop.")]
public int CropEndingTime { get; set; }
[Option('e', "ending", HelpText = "New time for chat ending. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##). Comments may be added but not removed. -1 = No crop.")]
public TimeDuration CropEndingTime { get; set; } = new(-1);

[Option("bttv", Default = true, HelpText = "Enable BTTV embedding in chat download.")]
public bool? BttvEmotes { get; set; }
Expand Down
9 changes: 5 additions & 4 deletions TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CommandLine;
using TwitchDownloaderCLI.Models;

namespace TwitchDownloaderCLI.Modes.Arguments
{
Expand All @@ -14,11 +15,11 @@ internal sealed class VideoDownloadArgs : TwitchDownloaderArgs
[Option('q', "quality", HelpText = "The quality the program will attempt to download.")]
public string Quality { get; set; }

[Option('b', "beginning", HelpText = "Time in seconds to crop beginning.")]
public int CropBeginningTime { get; set; }
[Option('b', "beginning", HelpText = "Time to crop beginning. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")]
public TimeDuration CropBeginningTime { get; set; }

[Option('e', "ending", HelpText = "Time in seconds to crop ending.")]
public int CropEndingTime { get; set; }
[Option('e', "ending", HelpText = "Time to crop ending. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")]
public TimeDuration CropEndingTime { get; set; }

[Option('t', "threads", Default = 4, HelpText = "Number of download threads.")]
public int DownloadThreads { get; set; }
Expand Down
8 changes: 4 additions & 4 deletions TwitchDownloaderCLI/Modes/DownloadChat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ private static ChatDownloadOptions GetDownloadOptions(ChatDownloadArgs inputOpti
_ => throw new NotSupportedException($"{fileExtension} is not a valid chat file extension.")
},
Id = vodClipIdMatch.Value,
CropBeginning = inputOptions.CropBeginningTime > 0.0,
CropBeginningTime = inputOptions.CropBeginningTime,
CropEnding = inputOptions.CropEndingTime > 0.0,
CropEndingTime = inputOptions.CropEndingTime,
CropBeginning = inputOptions.CropBeginningTime > TimeSpan.Zero,
CropBeginningTime = ((TimeSpan)inputOptions.CropBeginningTime).TotalSeconds,
CropEnding = inputOptions.CropEndingTime > TimeSpan.Zero,
CropEndingTime = ((TimeSpan)inputOptions.CropEndingTime).TotalSeconds,
EmbedData = inputOptions.EmbedData,
Filename = inputOptions.Compression is ChatCompression.Gzip
? inputOptions.OutputFile + ".gz"
Expand Down
8 changes: 4 additions & 4 deletions TwitchDownloaderCLI/Modes/DownloadVideo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ private static VideoDownloadOptions GetDownloadOptions(VideoDownloadArgs inputOp
".m4a" => "Audio",
_ => throw new ArgumentException("Only MP4 and M4A audio files are supported.")
},
CropBeginning = inputOptions.CropBeginningTime > 0.0,
CropBeginningTime = TimeSpan.FromSeconds(inputOptions.CropBeginningTime),
CropEnding = inputOptions.CropEndingTime > 0.0,
CropEndingTime = TimeSpan.FromSeconds(inputOptions.CropEndingTime),
CropBeginning = inputOptions.CropBeginningTime > TimeSpan.Zero,
CropBeginningTime = inputOptions.CropBeginningTime,
CropEnding = inputOptions.CropEndingTime > TimeSpan.Zero,
CropEndingTime = inputOptions.CropEndingTime,
FfmpegPath = string.IsNullOrWhiteSpace(inputOptions.FfmpegPath) ? FfmpegHandler.FfmpegExecutableName : Path.GetFullPath(inputOptions.FfmpegPath),
TempFolder = inputOptions.TempFolder,
CacheCleanerCallback = directoryInfos =>
Expand Down
4 changes: 2 additions & 2 deletions TwitchDownloaderCLI/Modes/RenderChat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ private static ChatRenderOptions GetRenderOptions(ChatRenderArgs inputOptions, I
MessageColor = SKColor.Parse(inputOptions.MessageColor),
ChatHeight = inputOptions.ChatHeight,
ChatWidth = inputOptions.ChatWidth,
StartOverride = inputOptions.CropBeginningTime,
EndOverride = inputOptions.CropEndingTime,
StartOverride = (int)((TimeSpan)inputOptions.CropBeginningTime).TotalSeconds,
EndOverride = (int)((TimeSpan)inputOptions.CropEndingTime).TotalSeconds,
BttvEmotes = (bool)inputOptions.BttvEmotes!,
FfzEmotes = (bool)inputOptions.FfzEmotes!,
StvEmotes = (bool)inputOptions.StvEmotes!,
Expand Down
8 changes: 4 additions & 4 deletions TwitchDownloaderCLI/Modes/UpdateChat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ private static ChatUpdateOptions GetUpdateOptions(ChatUpdateArgs inputOptions, I
OutputFormat = outFormat,
EmbedMissing = inputOptions.EmbedMissing,
ReplaceEmbeds = inputOptions.ReplaceEmbeds,
CropBeginning = !double.IsNegative(inputOptions.CropBeginningTime),
CropBeginningTime = inputOptions.CropBeginningTime,
CropEnding = !double.IsNegative(inputOptions.CropEndingTime),
CropEndingTime = inputOptions.CropEndingTime,
CropBeginning = inputOptions.CropBeginningTime >= TimeSpan.Zero,
CropBeginningTime = ((TimeSpan)inputOptions.CropBeginningTime).TotalSeconds,
CropEnding = inputOptions.CropEndingTime >= TimeSpan.Zero,
CropEndingTime = ((TimeSpan)inputOptions.CropEndingTime).TotalSeconds,
BttvEmotes = (bool)inputOptions.BttvEmotes!,
FfzEmotes = (bool)inputOptions.FfzEmotes!,
StvEmotes = (bool)inputOptions.StvEmotes!,
Expand Down
Loading

0 comments on commit a3e1338

Please sign in to comment.