Skip to content

Commit

Permalink
Improve ID parsing (#1158)
Browse files Browse the repository at this point in the history
* Annotate TwitchRegex.Match* with [return: MaybeNull]

* Support URLs ending with /

* Add tests

* Move id parsing related functions to IdParse, TwitchRegexTests -> IdParseTests
  • Loading branch information
ScrubN authored Jul 20, 2024
1 parent dd3aa70 commit 631fb78
Show file tree
Hide file tree
Showing 9 changed files with 211 additions and 60 deletions.
2 changes: 1 addition & 1 deletion TwitchDownloaderCLI/Modes/DownloadChat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ private static ChatDownloadOptions GetDownloadOptions(ChatDownloadArgs inputOpti
Environment.Exit(1);
}

var vodClipIdMatch = TwitchRegex.MatchVideoOrClipId(inputOptions.Id);
var vodClipIdMatch = IdParse.MatchVideoOrClipId(inputOptions.Id);
if (vodClipIdMatch is not { Success: true })
{
logger.LogError("Unable to parse Vod/Clip ID/URL.");
Expand Down
2 changes: 1 addition & 1 deletion TwitchDownloaderCLI/Modes/DownloadClip.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ private static ClipDownloadOptions GetDownloadOptions(ClipDownloadArgs inputOpti
Environment.Exit(1);
}

var clipIdMatch = TwitchRegex.MatchClipId(inputOptions.Id);
var clipIdMatch = IdParse.MatchClipId(inputOptions.Id);
if (clipIdMatch is not { Success: true })
{
logger.LogError("Unable to parse Clip ID/URL.");
Expand Down
2 changes: 1 addition & 1 deletion TwitchDownloaderCLI/Modes/DownloadVideo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ private static VideoDownloadOptions GetDownloadOptions(VideoDownloadArgs inputOp
Environment.Exit(1);
}

var vodIdMatch = TwitchRegex.MatchVideoId(inputOptions.Id);
var vodIdMatch = IdParse.MatchVideoId(inputOptions.Id);
if (vodIdMatch is not { Success: true })
{
logger.LogError("Unable to parse Vod ID/URL.");
Expand Down
134 changes: 134 additions & 0 deletions TwitchDownloaderCore.Tests/ToolTests/IdParseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using TwitchDownloaderCore.Tools;

namespace TwitchDownloaderCore.Tests.ToolTests
{
// ReSharper disable StringLiteralTypo
public class IdParseTests
{
[Theory]
[InlineData("41546181")] // Oldest VODs - 8
[InlineData("982306410")] // Old VODs - 9
[InlineData("6834869128")] // Current VODs - 10
[InlineData("11987163407")] // Future VODs - 11
public void CorrectlyParsesVodId(string id)
{
var match = IdParse.MatchVideoId(id);

Assert.NotNull(match);
Assert.Equal(id, match.Value);
}

[Theory]
[InlineData("https://www.twitch.tv/videos/41546181", "41546181")] // Oldest VODs - 8
[InlineData("https://www.twitch.tv/videos/982306410", "982306410")] // Old VODs - 9
[InlineData("https://www.twitch.tv/videos/6834869128", "6834869128")] // Current VODs - 10
[InlineData("https://www.twitch.tv/videos/11987163407", "11987163407")] // Future VODs - 11
[InlineData("https://www.twitch.tv/kitboga/video/2865132173", "2865132173")] // Alternate highlight URL
[InlineData("https://www.twitch.tv/kitboga/v/2865132173", "2865132173")] // Alternate highlight URL
[InlineData("https://www.twitch.tv/videos/4894164023/", "4894164023")]
public void CorrectlyParsesVodLink(string link, string expectedId)
{
var match = IdParse.MatchVideoId(link);

Assert.NotNull(match);
Assert.Equal(expectedId, match.Value);
}

[Theory]
[InlineData("SpineyPieTwitchRPGNurturing")]
[InlineData("FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
public void CorrectlyParsesClipId(string id)
{
var match = IdParse.MatchClipId(id);

Assert.NotNull(match);
Assert.Equal(id, match.Value);
}

[Theory]
[InlineData("https://www.twitch.tv/streamer8/clip/SpineyPieTwitchRPGNurturing", "SpineyPieTwitchRPGNurturing")]
[InlineData("https://www.twitch.tv/streamer8/clip/FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf", "FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
[InlineData("https://www.twitch.tv/streamer8/clip/SpineyPieTwitchRPGNurturing?featured=false&filter=clips&range=all&sort=time", "SpineyPieTwitchRPGNurturing")]
[InlineData("https://www.twitch.tv/streamer8/clip/FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf?featured=false&filter=clips&range=all&sort=time", "FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
[InlineData("https://clips.twitch.tv/SpineyPieTwitchRPGNurturing", "SpineyPieTwitchRPGNurturing")]
[InlineData("https://clips.twitch.tv/FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf", "FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
[InlineData("https://clips.twitch.tv/SpineyPieTwitchRPGNurturing/", "SpineyPieTwitchRPGNurturing")]
[InlineData("https://clips.twitch.tv/FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf/", "FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
public void CorrectlyParsesClipLink(string link, string expectedId)
{
var match = IdParse.MatchClipId(link);

Assert.NotNull(match);
Assert.Equal(expectedId, match.Value);
}

[Theory]
[InlineData("41546181")] // Oldest VODs - 8
[InlineData("982306410")] // Old VODs - 9
[InlineData("6834869128")] // Current VODs - 10
[InlineData("11987163407")] // Future VODs - 11
[InlineData("SpineyPieTwitchRPGNurturing")]
[InlineData("FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
public void CorrectlyParsesVodOrClipId(string id)
{
var match = IdParse.MatchVideoOrClipId(id);

Assert.NotNull(match);
Assert.Equal(id, match.Value);
}

[Theory]
[InlineData("https://www.twitch.tv/videos/41546181", "41546181")] // Oldest VODs - 8
[InlineData("https://www.twitch.tv/videos/982306410", "982306410")] // Old VODs - 9
[InlineData("https://www.twitch.tv/videos/6834869128", "6834869128")] // Current VODs - 10
[InlineData("https://www.twitch.tv/videos/11987163407", "11987163407")] // Future VODs - 11
[InlineData("https://www.twitch.tv/kitboga/video/2865132173", "2865132173")] // Alternate highlight URL
[InlineData("https://www.twitch.tv/kitboga/v/2865132173", "2865132173")] // Alternate VOD URL
[InlineData("https://www.twitch.tv/videos/4894164023/", "4894164023")]
[InlineData("https://www.twitch.tv/streamer8/clip/SpineyPieTwitchRPGNurturing", "SpineyPieTwitchRPGNurturing")]
[InlineData("https://www.twitch.tv/streamer8/clip/FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf", "FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
[InlineData("https://www.twitch.tv/streamer8/clip/SpineyPieTwitchRPGNurturing?featured=false&filter=clips&range=all&sort=time", "SpineyPieTwitchRPGNurturing")]
[InlineData("https://www.twitch.tv/streamer8/clip/FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf?featured=false&filter=clips&range=all&sort=time", "FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
[InlineData("https://clips.twitch.tv/SpineyPieTwitchRPGNurturing", "SpineyPieTwitchRPGNurturing")]
[InlineData("https://clips.twitch.tv/FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf", "FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
[InlineData("https://clips.twitch.tv/SpineyPieTwitchRPGNurturing/", "SpineyPieTwitchRPGNurturing")]
[InlineData("https://clips.twitch.tv/FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf/", "FuriousFlaccidTireArgieB8-NHbTiYQlzwHVvv_Vf")]
public void CorrectlyParsesVodOrClipLink(string link, string expectedId)
{
var match = IdParse.MatchVideoOrClipId(link);

Assert.NotNull(match);
Assert.Equal(expectedId, match.Value);
}

[Fact]
public void DoesNotParseGarbageVodId()
{
const string GARBAGE = "SORRY FOR THE TRAFFIC NaM";

var match = IdParse.MatchVideoId(GARBAGE);

Assert.Null(match);
}

[Fact]
public void DoesNotParseGarbageClipId()
{
const string GARBAGE = "SORRY FOR THE TRAFFIC NaM";

var match = IdParse.MatchClipId(GARBAGE);

Assert.Null(match);
}

[Fact]
public void DoesNotParseGarbageVodOrClipId()
{
const string GARBAGE = "SORRY FOR THE TRAFFIC NaM";

var match = IdParse.MatchVideoOrClipId(GARBAGE);

Assert.Null(match);
}
}
}
71 changes: 71 additions & 0 deletions TwitchDownloaderCore/Tools/IdParse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;

namespace TwitchDownloaderCore.Tools
{
public static class IdParse
{
// TODO: Use source generators when .NET7
private static readonly Regex VideoId = new(@"(?<=^|twitch\.tv\/videos\/)\d+(?=\/?(?:$|\?))", RegexOptions.Compiled);
private static readonly Regex HighlightId = new(@"(?<=^|twitch\.tv\/\w+\/v(?:ideo)?\/)\d+(?=\/?(?:$|\?))", RegexOptions.Compiled);
private static readonly Regex ClipId = new(@"(?<=^|(?:clips\.)?twitch\.tv\/(?:\w+\/clip\/)?)[\w-]+?(?=\/?(?:$|\?))", RegexOptions.Compiled);

/// <returns>A <see cref="Match"/> of the video's id or <see langword="null"/>.</returns>
[return: MaybeNull]
public static Match MatchVideoId(string text)
{
text = text.Trim();

var videoIdMatch = VideoId.Match(text);
if (videoIdMatch.Success)
{
return videoIdMatch;
}

var highlightIdMatch = HighlightId.Match(text);
if (highlightIdMatch.Success)
{
return highlightIdMatch;
}

return null;
}

/// <returns>A <see cref="Match"/> of the clip's id or <see langword="null"/>.</returns>
[return: MaybeNull]
public static Match MatchClipId(string text)
{
text = text.Trim();

var clipIdMatch = ClipId.Match(text);
if (clipIdMatch.Success && !clipIdMatch.Value.All(char.IsDigit))
{
return clipIdMatch;
}

return null;
}

/// <returns>A <see cref="Match"/> of the video/clip's id or <see langword="null"/>.</returns>
[return: MaybeNull]
public static Match MatchVideoOrClipId(string text)
{
text = text.Trim();

var videoIdMatch = MatchVideoId(text);
if (videoIdMatch is { Success: true })
{
return videoIdMatch;
}

var clipIdMatch = MatchClipId(text);
if (clipIdMatch is { Success: true })
{
return clipIdMatch;
}

return null;
}
}
}
54 changes: 0 additions & 54 deletions TwitchDownloaderCore/Tools/TwitchRegex.cs
Original file line number Diff line number Diff line change
@@ -1,66 +1,12 @@
using System.Linq;
using System.Text.RegularExpressions;

namespace TwitchDownloaderCore.Tools
{
public static class TwitchRegex
{
// TODO: Use source generators when .NET7
private static readonly Regex VideoId = new(@"(?<=^|twitch\.tv\/videos\/)\d+(?=$|\?|\s)", RegexOptions.Compiled);
private static readonly Regex HighlightId = new(@"(?<=^|twitch\.tv\/\w+\/v(?:ideo)?\/)\d+(?=$|\?|\s)", RegexOptions.Compiled);
private static readonly Regex ClipId = new(@"(?<=^|(?:clips\.)?twitch\.tv\/(?:\w+\/clip\/)?)[\w-]+?(?=$|\?|\s)", RegexOptions.Compiled);

public static readonly Regex UrlTimeCode = new(@"(?<=(?:\?|&)t=)\d+h\d+m\d+s(?=$|\?|\s)", RegexOptions.Compiled);
public static readonly Regex BitsRegex = new(
@"(?<=(?:\s|^)(?:4Head|Anon|Bi(?:bleThumb|tBoss)|bday|C(?:h(?:eer|arity)|orgo)|cheerwal|D(?:ansGame|oodleCheer)|EleGiggle|F(?:rankerZ|ailFish)|Goal|H(?:eyGuys|olidayCheer)|K(?:appa|reygasm)|M(?:rDestructoid|uxy)|NotLikeThis|P(?:arty|ride|JSalt)|RIPCheer|S(?:coops|h(?:owLove|amrock)|eemsGood|wiftRage|treamlabs)|TriHard|uni|VoHiYo))[1-9]\d{0,6}(?=\s|$)",
RegexOptions.Compiled);

/// <returns>A <see cref="Match"/> of the video's id or <see langword="null"/>.</returns>
public static Match MatchVideoId(string text)
{
var videoIdMatch = VideoId.Match(text);
if (videoIdMatch.Success)
{
return videoIdMatch;
}

var highlightIdMatch = HighlightId.Match(text);
if (highlightIdMatch.Success)
{
return highlightIdMatch;
}

return null;
}

/// <returns>A <see cref="Match"/> of the clip's id or <see langword="null"/>.</returns>
public static Match MatchClipId(string text)
{
var clipIdMatch = ClipId.Match(text);
if (clipIdMatch.Success && !clipIdMatch.Value.All(char.IsDigit))
{
return clipIdMatch;
}

return null;
}

/// <returns>A <see cref="Match"/> of the video/clip's id or <see langword="null"/>.</returns>
public static Match MatchVideoOrClipId(string text)
{
var videoIdMatch = MatchVideoId(text);
if (videoIdMatch is { Success: true })
{
return videoIdMatch;
}

var clipIdMatch = MatchClipId(text);
if (clipIdMatch is { Success: true })
{
return clipIdMatch;
}

return null;
}
}
}
2 changes: 1 addition & 1 deletion TwitchDownloaderWPF/PageChatDownload.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ private void UpdateActionButtons(bool isDownloading)

public static string ValidateUrl(string text)
{
var vodClipIdMatch = TwitchRegex.MatchVideoOrClipId(text);
var vodClipIdMatch = IdParse.MatchVideoOrClipId(text);
return vodClipIdMatch is { Success: true }
? vodClipIdMatch.Value
: null;
Expand Down
2 changes: 1 addition & 1 deletion TwitchDownloaderWPF/PageClipDownload.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ private void UpdateActionButtons(bool isDownloading)

private static string ValidateUrl(string text)
{
var clipIdMatch = TwitchRegex.MatchClipId(text);
var clipIdMatch = IdParse.MatchClipId(text);
return clipIdMatch is { Success: true }
? clipIdMatch.Value
: null;
Expand Down
2 changes: 1 addition & 1 deletion TwitchDownloaderWPF/PageVodDownload.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ public void SetImage(string imageUri, bool isGif)

private static long ValidateUrl(string text)
{
var vodIdMatch = TwitchRegex.MatchVideoId(text);
var vodIdMatch = IdParse.MatchVideoId(text);
if (vodIdMatch is {Success: true} && long.TryParse(vodIdMatch.ValueSpan, out var vodId))
{
return vodId;
Expand Down

0 comments on commit 631fb78

Please sign in to comment.