From d0c48db5d9e2db76bdd329d4f1a0edf675c7c0b8 Mon Sep 17 00:00:00 2001 From: Scrub <72096833+ScrubN@users.noreply.github.com> Date: Thu, 1 Aug 2024 15:52:41 -0400 Subject: [PATCH] Print video information in the CLI (#951) * Store clip framerate as decimal instead of double * Use null instead of default to represent lack of value for M3U8.Metadata * Create initial streaminfo arguments * Create Table.cs * Create initial implementation for StreamInfo.cs * Make M3U8.Metadata properties init * Refactor * Use progress reporter where appropriate * Provide at least 3 digits when stringifying byte count * Fetch clip curator & clip/vod broadcaster login * Switch from TwitchDownloaderCLI.Tools.Table to Spectre.Console.Table * Hide JSON format from help text * Extract code into dedicated methods * Cleanup * More cleanup * Fix tests * Add video chapter table * Oops * Better timestamp strings * Display ASCII login for users with non-ASCII usernames, cleanup * Ensure output encoding is UTF-8 * Add README entry * StreamInfo -> Info * Only link user page if login is present * Fix NRE * Info -> InfoHandler * Remove redundant cast --- TwitchDownloaderCLI/Models/Enums.cs | 9 + .../Modes/Arguments/InfoArgs.cs | 25 ++ TwitchDownloaderCLI/Modes/InfoHandler.cs | 418 ++++++++++++++++++ TwitchDownloaderCLI/Program.cs | 3 +- TwitchDownloaderCLI/README.md | 25 ++ .../TwitchDownloaderCLI.csproj | 1 + .../ToolTests/M3U8Tests.cs | 2 - TwitchDownloaderCore/Tools/M3U8.cs | 187 ++++---- TwitchDownloaderCore/Tools/M3U8Parse.cs | 54 ++- .../Tools/VideoSizeEstimator.cs | 7 + TwitchDownloaderCore/TwitchHelper.cs | 4 +- .../TwitchObjects/Gql/GqlClipResponse.cs | 9 + .../TwitchObjects/Gql/GqlClipTokenResponse.cs | 2 +- .../TwitchObjects/Gql/GqlVideoResponse.cs | 1 + 14 files changed, 624 insertions(+), 123 deletions(-) create mode 100644 TwitchDownloaderCLI/Modes/Arguments/InfoArgs.cs create mode 100644 TwitchDownloaderCLI/Modes/InfoHandler.cs diff --git a/TwitchDownloaderCLI/Models/Enums.cs b/TwitchDownloaderCLI/Models/Enums.cs index 6b6cd172..e004d792 100644 --- a/TwitchDownloaderCLI/Models/Enums.cs +++ b/TwitchDownloaderCLI/Models/Enums.cs @@ -21,4 +21,13 @@ public enum OverwriteBehavior Rename, Prompt, } + + public enum InfoPrintFormat + { + Raw, + Table, + M3U8, + M3U = M3U8, + Json + } } \ No newline at end of file diff --git a/TwitchDownloaderCLI/Modes/Arguments/InfoArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/InfoArgs.cs new file mode 100644 index 00000000..4490479b --- /dev/null +++ b/TwitchDownloaderCLI/Modes/Arguments/InfoArgs.cs @@ -0,0 +1,25 @@ +using CommandLine; +using TwitchDownloaderCLI.Models; + +namespace TwitchDownloaderCLI.Modes.Arguments +{ + [Verb("info", HelpText = "Prints stream information about a VOD or clip to stdout")] + internal sealed class InfoArgs : ITwitchDownloaderArgs + { + [Option('u', "id", Required = true, HelpText = "The ID or URL of the VOD or clip to print the stream info about.")] + public string Id { get; set; } + + [Option('f', "format", Default = InfoPrintFormat.Table, HelpText = "The format in which the information should be printed. When using table format, use a terminal that supports ANSI escape sequences for best results. Valid values are: Raw, Table, and M3U/M3U8")] + public InfoPrintFormat Format { get; set; } + + [Option("use-utf8", Default = true, HelpText = "Ensures UTF-8 encoding is used when writing results to standard output.")] + public bool? UseUtf8 { get; set; } + + [Option("oauth", HelpText = "OAuth access token to access subscriber only VODs. DO NOT SHARE THIS WITH ANYONE.")] + public string Oauth { get; set; } + + // Interface args + public bool? ShowBanner { get; set; } + public LogLevel LogLevel { get; set; } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCLI/Modes/InfoHandler.cs b/TwitchDownloaderCLI/Modes/InfoHandler.cs new file mode 100644 index 00000000..f0e94f09 --- /dev/null +++ b/TwitchDownloaderCLI/Modes/InfoHandler.cs @@ -0,0 +1,418 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Web; +using Spectre.Console; +using TwitchDownloaderCLI.Models; +using TwitchDownloaderCLI.Modes.Arguments; +using TwitchDownloaderCLI.Tools; +using TwitchDownloaderCore; +using TwitchDownloaderCore.Extensions; +using TwitchDownloaderCore.Interfaces; +using TwitchDownloaderCore.Tools; +using TwitchDownloaderCore.TwitchObjects.Gql; + +namespace TwitchDownloaderCLI.Modes +{ + internal static class InfoHandler + { + public static void PrintInfo(InfoArgs inputOptions) + { + var progress = new CliTaskProgress(inputOptions.LogLevel); + SetUtf8Encoding(inputOptions.UseUtf8.GetValueOrDefault(), progress); + + var vodClipIdMatch = IdParse.MatchVideoOrClipId(inputOptions.Id); + if (vodClipIdMatch is not { Success: true }) + { + progress.LogError("Unable to parse VOD/Clip ID/URL."); + Environment.Exit(1); + } + + inputOptions.Id = vodClipIdMatch.Value; + if (inputOptions.Id.All(char.IsDigit)) + { + HandleVod(inputOptions, progress); + } + else + { + HandleClip(inputOptions, progress); + } + } + + private static void HandleVod(InfoArgs inputOptions, ITaskProgress progress) + { + var videoId = long.Parse(inputOptions.Id); + var (videoInfo, chapters, playlistString) = GetVideoInfo(videoId, inputOptions.Oauth, inputOptions.Format != InfoPrintFormat.Raw, progress).GetAwaiter().GetResult(); + + switch (inputOptions.Format) + { + case InfoPrintFormat.Raw: + HandleVodRaw(videoInfo, chapters, playlistString); + break; + case InfoPrintFormat.Table: + HandleVodTable(videoInfo, chapters, playlistString); + break; + case InfoPrintFormat.M3U8: + HandleVodM3U8(playlistString); + break; + case InfoPrintFormat.Json: + HandleVodJson(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private static async Task<(GqlVideoResponse videoInfo, GqlVideoChapterResponse chapters, string playlistString)> GetVideoInfo(long videoId, string oauth, bool canThrow, ITaskProgress progress) + { + progress.SetStatus("Fetching Video Info [1/1]"); + + var videoInfo = await TwitchHelper.GetVideoInfo(videoId); + var accessToken = await TwitchHelper.GetVideoToken(videoId, oauth); + + if (accessToken.data.videoPlaybackAccessToken is null) + { + if (canThrow) + { + throw new NullReferenceException("Invalid VOD, deleted/expired VOD possibly?"); + } + + return (videoInfo, null, null); + } + + var playlistString = await TwitchHelper.GetVideoPlaylist(videoId, accessToken.data.videoPlaybackAccessToken.value, accessToken.data.videoPlaybackAccessToken.signature); + if (canThrow && (playlistString.Contains("vod_manifest_restricted") || playlistString.Contains("unauthorized_entitlements"))) + { + throw new NullReferenceException("Insufficient access to VOD, OAuth may be required."); + } + + var chapters = await TwitchHelper.GetOrGenerateVideoChapters(videoId, videoInfo.data.video); + + return (videoInfo, chapters, playlistString); + } + + private static void HandleVodRaw(GqlVideoResponse videoInfo, GqlVideoChapterResponse chapters, string playlistString) + { + var stdOut = Console.OpenStandardOutput(); + JsonSerializer.Serialize(stdOut, videoInfo); + Console.WriteLine(); + JsonSerializer.Serialize(stdOut, chapters); + Console.WriteLine(); + Console.Write(playlistString); + } + + private static void HandleVodTable(GqlVideoResponse videoInfo, GqlVideoChapterResponse chapters, string playlistString) + { + var m3u8 = M3U8.Parse(playlistString); + m3u8.SortStreamsByQuality(); + + const string DEFAULT_STRING = "-"; + var infoVideo = videoInfo.data.video; + var hasBitrate = m3u8.Streams.Any(x => x.StreamInfo.Bandwidth != default); + + var infoTableTitle = new TableTitle("Video Info"); + var infoTable = new Table() + .Title(infoTableTitle) + .RoundedBorder() + .AddColumn(new TableColumn("Key")) + .AddColumn(new TableColumn("Value")) + .AddRow(new Markup("Streamer"), GetUserNameMarkup(infoVideo.owner.displayName, infoVideo.owner.login, DEFAULT_STRING)) + .AddRow("Title", infoVideo.title) + .AddRow("Length", StringifyTimestamp(TimeSpan.FromSeconds(infoVideo.lengthSeconds))) + .AddRow("Category", infoVideo.game?.displayName ?? DEFAULT_STRING) + .AddRow("Views", infoVideo.viewCount.ToString("N0", CultureInfo.CurrentCulture)) + .AddRow("Created at", $"{infoVideo.createdAt.ToUniversalTime():yyyy-MM-dd hh:mm:ss} UTC") + .AddRow("Description", infoVideo.description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd() ?? DEFAULT_STRING); + + AnsiConsole.Write(infoTable); + + var streamTableTitle = new TableTitle("Video Streams"); + var streamTable = new Table() + .Title(streamTableTitle) + .AddColumn(new TableColumn("Name")) + .AddColumn(new TableColumn("Resolution")) + .AddColumn(new TableColumn("FPS").RightAligned()) + .AddColumn(new TableColumn("Codecs").RightAligned()); + + if (hasBitrate) + { + streamTable + .AddColumn(new TableColumn("File size").RightAligned()) + .AddColumn(new TableColumn("Bitrate").RightAligned()); + } + + foreach (var stream in m3u8.Streams) + { + var name = stream.GetResolutionFramerateString(); + var resolution = stream.StreamInfo.Resolution.StringifyOrDefault(x => x.ToString(), DEFAULT_STRING); + var fps = stream.StreamInfo.Framerate.StringifyOrDefault(x => x.ToString(CultureInfo.CurrentCulture), DEFAULT_STRING); + var codecs = stream.StreamInfo.Codecs.StringifyOrDefault(x => x, DEFAULT_STRING); + + if (hasBitrate) + { + var videoLength = TimeSpan.FromSeconds(videoInfo.data.video.lengthSeconds); + var fileSize = stream.StreamInfo.Bandwidth.StringifyOrDefault(x => $"~{VideoSizeEstimator.StringifyByteCount(VideoSizeEstimator.EstimateVideoSize(x, TimeSpan.Zero, videoLength))}", DEFAULT_STRING); + var bitrate = stream.StreamInfo.Bandwidth.StringifyOrDefault(x => $"{x / 1000}kbps", DEFAULT_STRING); + streamTable.AddRow(name, resolution, fps, codecs, fileSize, bitrate); + } + else + { + streamTable.AddRow(name, resolution, fps, codecs); + } + } + + AnsiConsole.Write(streamTable); + + if (chapters.data.video.moments.edges.Count == 0) + return; + + var chapterTableTitle = new TableTitle("Video Chapters"); + var chapterTable = new Table() + .Title(chapterTableTitle) + .AddColumn(new TableColumn("Category")) + .AddColumn(new TableColumn("Type")) + .AddColumn(new TableColumn("Start").RightAligned()) + .AddColumn(new TableColumn("End").RightAligned()) + .AddColumn(new TableColumn("Length").RightAligned()); + + foreach (var chapter in chapters.data.video.moments.edges) + { + var category = chapter.node.details.game?.displayName ?? DEFAULT_STRING; + var type = chapter.node._type; + var start = TimeSpan.FromMilliseconds(chapter.node.positionMilliseconds); + var length = TimeSpan.FromMilliseconds(chapter.node.durationMilliseconds); + var end = start + length; + var startString = StringifyTimestamp(start); + var endString = StringifyTimestamp(end); + var lengthString = StringifyTimestamp(length); + chapterTable.AddRow(category, type, startString, endString, lengthString); + } + + AnsiConsole.Write(chapterTable); + } + + private static void HandleVodM3U8(string playlistString) + { + // Parse as m3u8 to verify that it is a valid playlist + var m3u8 = M3U8.Parse(playlistString); + Console.Write(m3u8.ToString()); + } + + private static void HandleVodJson() + { + throw new NotImplementedException("JSON format is not yet supported"); + } + + private static void HandleClip(InfoArgs inputOptions, ITaskProgress progress) + { + var (clipInfo, clipQualities) = GetClipInfo(inputOptions.Id, inputOptions.Format != InfoPrintFormat.Raw, progress).GetAwaiter().GetResult(); + + switch (inputOptions.Format) + { + case InfoPrintFormat.Raw: + HandleClipRaw(clipInfo, clipQualities); + break; + case InfoPrintFormat.Table: + HandleClipTable(clipInfo, clipQualities); + break; + case InfoPrintFormat.M3U8: + HandleClipM3U8(clipQualities, clipInfo); + break; + case InfoPrintFormat.Json: + HandleClipJson(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private static async Task<(GqlClipResponse clipInfo, GqlClipTokenResponse listLinks)> GetClipInfo(string clipId, bool canThrow, ITaskProgress progress) + { + progress.SetStatus("Fetching Clip Info [1/1]"); + + var clipInfo = await TwitchHelper.GetClipInfo(clipId); + var listLinks = await TwitchHelper.GetClipLinks(clipId); + + if (!canThrow) + { + return (clipInfo, listLinks); + } + + var clip = listLinks.data.clip; + if (clip.playbackAccessToken is null) + { + throw new NullReferenceException("Invalid Clip, deleted possibly?"); + } + + if (clip.videoQualities is null || clip.videoQualities.Length == 0) + { + throw new NullReferenceException("Clip has no video qualities, deleted possibly?"); + } + + return (clipInfo, listLinks); + } + + private static void HandleClipRaw(GqlClipResponse clipInfo, GqlClipTokenResponse clipQualities) + { + var stdOut = Console.OpenStandardOutput(); + JsonSerializer.Serialize(stdOut, clipInfo); + Console.WriteLine(); + JsonSerializer.Serialize(stdOut, clipQualities); + } + + private static void HandleClipTable(GqlClipResponse clipInfo, GqlClipTokenResponse clipQualities) + { + const string DEFAULT_STRING = "-"; + var infoClip = clipInfo.data.clip; + + var infoTableTitle = new TableTitle("Clip Info"); + var infoTable = new Table() + .Title(infoTableTitle) + .AddColumn(new TableColumn("Key")) + .AddColumn(new TableColumn("Value")) + .AddRow(new Markup("Streamer"), GetUserNameMarkup(infoClip.broadcaster?.displayName, infoClip.broadcaster?.login, DEFAULT_STRING)) + .AddRow("Title", infoClip.title) + .AddRow("Length", StringifyTimestamp(TimeSpan.FromSeconds(infoClip.durationSeconds))) + .AddRow(new Markup("Clipped by"), GetUserNameMarkup(infoClip.curator?.displayName, infoClip.curator?.login, DEFAULT_STRING)) + .AddRow("Category", infoClip.game?.displayName ?? DEFAULT_STRING) + .AddRow("Views", infoClip.viewCount.ToString("N0", CultureInfo.CurrentCulture)) + .AddRow("Created at", $"{infoClip.createdAt.ToUniversalTime():yyyy-MM-dd hh:mm:ss} UTC"); + + if (infoClip.video != null) + { + var videoOffset = infoClip.videoOffsetSeconds.StringifyOrDefault(x => StringifyTimestamp(TimeSpan.FromSeconds(x)), DEFAULT_STRING); + infoTable + .AddRow("VOD ID", infoClip.video.id) + .AddRow("VOD offset", videoOffset); + } + + AnsiConsole.Write(infoTable); + + var qualityTableTitle = new TableTitle("Clip Qualities"); + var qualityTable = new Table() + .Title(qualityTableTitle) + .AddColumn(new TableColumn("Name")) + .AddColumn(new TableColumn("Height")) + .AddColumn(new TableColumn("FPS").RightAligned()); + + foreach (var quality in clipQualities.data.clip.videoQualities) + { + var name = string.Create(CultureInfo.CurrentCulture, $"{quality.quality}p{quality.frameRate:F0}"); + var height = quality.quality; + var fps = quality.frameRate.StringifyOrDefault(x => string.Create(CultureInfo.CurrentCulture, $"{x:F2}"), DEFAULT_STRING); + qualityTable.AddRow(name, height, fps); + } + + AnsiConsole.Write(qualityTable); + } + + private static void HandleClipM3U8(GqlClipTokenResponse clipQualities, GqlClipResponse clipInfo) + { + var clip = clipQualities.data.clip; + + var metadata = new M3U8.Metadata + { + Version = default, + MediaSequence = 0, + StreamTargetDuration = (uint)clipInfo.data.clip.durationSeconds, + TwitchElapsedSeconds = 0, + TwitchLiveSequence = default, + TwitchTotalSeconds = clipInfo.data.clip.durationSeconds, + Type = M3U8.Metadata.PlaylistType.Event, + }; + + var streams = clip.videoQualities.Select(x => new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, x.quality, x.quality, true, true), + new M3U8.Stream.ExtStreamInfo(default, default, default, default, x.quality, x.frameRate), + $"{x.sourceURL}?sig={clip.playbackAccessToken.signature}&token={HttpUtility.UrlEncode(clip.playbackAccessToken.value)}" + )).ToArray(); + + var m3u8 = new M3U8(metadata, streams); + Console.Write(m3u8.ToString()); + } + + private static void HandleClipJson() + { + throw new NotImplementedException("JSON format is not yet supported"); + } + + private static string StringifyOrDefault(this T value, Func stringify, string defaultString) where T : IEquatable + { + if (!typeof(T).IsValueType && value is null) + { + return defaultString; + } + + if (!value.Equals(default)) + { + return stringify(value); + } + + return defaultString; + } + + private static string StringifyOrDefault(this T? value, Func stringify, string defaultString) where T : struct, IEquatable + { + if (value.HasValue) + { + return stringify(value.Value); + } + + return defaultString; + } + + private static string StringifyTimestamp(TimeSpan timeSpan) + { + return timeSpan.Ticks switch + { + < TimeSpan.TicksPerSecond => "0:00", + < TimeSpan.TicksPerMinute => timeSpan.ToString(@"s\s"), + < TimeSpan.TicksPerHour => timeSpan.ToString(@"m\:ss"), + _ => TimeSpanHFormat.ReusableInstance.Format(@"H\:mm\:ss", timeSpan) + }; + } + + private static Markup GetUserNameMarkup([AllowNull] string displayName, [AllowNull] string login, string @default) + { + if (string.IsNullOrWhiteSpace(displayName)) + { + return string.IsNullOrWhiteSpace(login) ? new Markup(@default) : new Markup(login, Style.Plain.Link($"https://twitch.tv/{login}")); + } + + if (string.IsNullOrWhiteSpace(login)) + { + return new Markup(displayName); + } + + if (displayName.All(char.IsAscii)) + { + return new Markup(displayName, Style.Plain.Link($"https://twitch.tv/{login}")); + } + + return new Markup($"{displayName} ({login})", Style.Plain.Link($"https://twitch.tv/{login}")); + } + + // cmd.exe only supports chars from codepage 437, so the default console encoding on Windows is codepage 437 instead of UTF-8 + private static void SetUtf8Encoding(bool useUtf8, ITaskLogger logger) + { + if (!useUtf8 || Console.OutputEncoding.CodePage == Encoding.UTF8.CodePage) + { + return; + } + + try + { + Console.OutputEncoding = Encoding.UTF8; + logger.LogVerbose("Output encoding has switched to UTF-8."); + } + catch + { + logger.LogWarning("Failed to set UTF-8 encoding. Non-ASCII characters may not render correctly."); + } + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCLI/Program.cs b/TwitchDownloaderCLI/Program.cs index 070da004..b75909ec 100644 --- a/TwitchDownloaderCLI/Program.cs +++ b/TwitchDownloaderCLI/Program.cs @@ -25,7 +25,7 @@ private static void Main(string[] args) config.HelpWriter = null; // Use null instead of TextWriter.Null due to how CommandLine works internally }); - var parserResult = parser.ParseArguments(preParsedArgs); + var parserResult = parser.ParseArguments(preParsedArgs); parserResult.WithNotParsed(errors => WriteHelpText(errors, parserResult, parser.Settings)); CoreLicensor.EnsureFilesExist(AppContext.BaseDirectory); @@ -37,6 +37,7 @@ private static void Main(string[] args) .WithParsed(DownloadChat.Download) .WithParsed(UpdateChat.Update) .WithParsed(RenderChat.Render) + .WithParsed(InfoHandler.PrintInfo) .WithParsed(FfmpegHandler.ParseArgs) .WithParsed(CacheHandler.ParseArgs) .WithParsed(MergeTs.Merge); diff --git a/TwitchDownloaderCLI/README.md b/TwitchDownloaderCLI/README.md index c0361987..c3384de7 100644 --- a/TwitchDownloaderCLI/README.md +++ b/TwitchDownloaderCLI/README.md @@ -9,6 +9,7 @@ Also can concatenate/combine/merge Transport Stream files, either those parts do - [Arguments for mode chatdownload](#arguments-for-mode-chatdownload) - [Arguments for mode chatupdate](#arguments-for-mode-chatupdate) - [Arguments for mode chatrender](#arguments-for-mode-chatrender) + - [Arguments for mode info](#arguments-for-mode-info) - [Arguments for mode ffmpeg](#arguments-for-mode-ffmpeg) - [Arguments for mode cache](#arguments-for-mode-cache) - [Arguments for mode tsmerge](#arguments-for-mode-tsmerge) @@ -338,6 +339,22 @@ Other = `1`, Broadcaster = `2`, Moderator = `4`, VIP = `8`, Subscriber = `16`, P **--collision** (Default: `Prompt`) Sets the handling of output file name collisions. Valid values are: `Overwrite`, `Exit`, `Rename`, `Prompt`. +## Arguments for mode info +#### Prints information about a VOD, highlight, or clip + +**-u / --id (REQUIRED)** The ID or URL of the VOD or clip to print the stream info about. + +**-f / --format** +(Default: `Table`) The format in which the information should be printed. Valid values are: `Raw`, `Table`, and `M3U` / `M3U8`. + +When using table format, use a terminal that supports ANSI escape sequences for best results. + +**--use-utf8** +(Default: `true`) Ensures UTF-8 encoding is used when writing results to standard output. + +**--oauth** +OAuth access token to access subscriber only VODs. **DO NOT SHARE YOUR OAUTH TOKEN WITH ANYONE.** + ## Arguments for mode ffmpeg #### Manage standalone FFmpeg @@ -408,6 +425,14 @@ Render a chat with custom video settings and message outlines ./TwitchDownloaderCLI chatrender -i chat.json -h 1440 -w 720 --framerate 60 --outline -o chat.mp4 +Display the info about a VOD in table format + + ./TwitchDownloaderCLI info --id 612942303 --format table + +Display the info about a clip in raw format + + ./TwitchDownloaderCLI info --id NurturingCalmHamburgerVoHiYo --format raw + Render a chat with custom FFmpeg arguments ./TwitchDownloaderCLI chatrender -i chat.json --output-args='-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "{save_path}"' -o chat.mp4 diff --git a/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj b/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj index e1f77f1c..4c874123 100644 --- a/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj +++ b/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj @@ -15,6 +15,7 @@ + diff --git a/TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs b/TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs index aff18f4b..c3bd288f 100644 --- a/TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs +++ b/TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs @@ -142,7 +142,6 @@ public void CorrectlyParsesTwitchM3U8OfLiveStreams(bool useStream, string cultur Assert.Equal(3u, m3u8.FileMetadata.Version); Assert.Equal(5u, m3u8.FileMetadata.StreamTargetDuration); - Assert.Equal(M3U8.Metadata.PlaylistType.Unknown, m3u8.FileMetadata.Type); Assert.Equal(4815u, m3u8.FileMetadata.MediaSequence); Assert.Equal(4997u, m3u8.FileMetadata.TwitchLiveSequence); Assert.Equal(9994.338m, m3u8.FileMetadata.TwitchElapsedSeconds); @@ -356,7 +355,6 @@ public void CorrectlyParsesKickM3U8OfTransportStreams(bool useStream, string cul Assert.Equal(4u, m3u8.FileMetadata.Version); Assert.Equal(2u, m3u8.FileMetadata.StreamTargetDuration); - Assert.Equal(M3U8.Metadata.PlaylistType.Unknown, m3u8.FileMetadata.Type); Assert.Equal(0u, m3u8.FileMetadata.MediaSequence); Assert.Equal(streamValues.Length, m3u8.Streams.Length); diff --git a/TwitchDownloaderCore/Tools/M3U8.cs b/TwitchDownloaderCore/Tools/M3U8.cs index 47a444cd..5e26ecfe 100644 --- a/TwitchDownloaderCore/Tools/M3U8.cs +++ b/TwitchDownloaderCore/Tools/M3U8.cs @@ -17,7 +17,7 @@ public override string ToString() sb.AppendLine("#EXTM3U"); - if (FileMetadata?.ToString() is { Length: > 0} metadataString) + if (FileMetadata?.ToString() is { Length: > 0 } metadataString) { sb.AppendLine(metadataString); } @@ -54,18 +54,18 @@ public enum PlaylistType private const string TWITCH_INFO_KEY = "#EXT-X-TWITCH-INFO:"; // Generic M3U headers - public uint Version { get; internal set; } - public uint StreamTargetDuration { get; internal set; } - public PlaylistType Type { get; internal set; } = PlaylistType.Unknown; - public uint MediaSequence { get; internal set; } + public uint? Version { get; init; } + public uint? StreamTargetDuration { get; init; } + public PlaylistType? Type { get; init; } + public uint? MediaSequence { get; init; } // Twitch specific - public uint TwitchLiveSequence { get; internal set; } - public decimal TwitchElapsedSeconds { get; internal set; } - public decimal TwitchTotalSeconds { get; internal set; } + public uint? TwitchLiveSequence { get; init; } + public decimal? TwitchElapsedSeconds { get; init; } + public decimal? TwitchTotalSeconds { get; init; } // Other headers that we don't have dedicated properties for. Useful for debugging. - private readonly List> _unparsedValues = new(); + private List> _unparsedValues = new(); public IReadOnlyList> UnparsedValues => _unparsedValues; public override string ToString() @@ -73,25 +73,30 @@ public override string ToString() var sb = new StringBuilder(); var itemSeparator = Environment.NewLine; - StringBuilderHelpers.AppendIfNotDefault(sb, TARGET_VERSION_KEY, Version, itemSeparator); - StringBuilderHelpers.AppendIfNotDefault(sb, TARGET_DURATION_KEY, StreamTargetDuration, itemSeparator); - if (Type != PlaylistType.Unknown) - { - sb.Append(PLAYLIST_TYPE_KEY); - sb.Append(Type.AsString()); - sb.Append(itemSeparator); - } + if (Version.HasValue) + sb.AppendKeyValue(TARGET_VERSION_KEY, Version.Value, itemSeparator); + + if (StreamTargetDuration.HasValue) + sb.AppendKeyValue(TARGET_DURATION_KEY, StreamTargetDuration.Value, itemSeparator); + + if (Type.HasValue) + sb.AppendKeyValue(PLAYLIST_TYPE_KEY, Type.Value.AsString(), itemSeparator); + + if (MediaSequence.HasValue) + sb.AppendKeyValue(MEDIA_SEQUENCE_KEY, MediaSequence.Value, itemSeparator); + + if (TwitchLiveSequence.HasValue) + sb.AppendKeyValue(TWITCH_LIVE_SEQUENCE_KEY, TwitchLiveSequence.Value, itemSeparator); - StringBuilderHelpers.AppendIfNotDefault(sb, MEDIA_SEQUENCE_KEY, MediaSequence, itemSeparator); - StringBuilderHelpers.AppendIfNotDefault(sb, TWITCH_LIVE_SEQUENCE_KEY, TwitchLiveSequence, itemSeparator); - StringBuilderHelpers.AppendIfNotDefault(sb, TWITCH_ELAPSED_SECS_KEY, TwitchElapsedSeconds, itemSeparator); - StringBuilderHelpers.AppendIfNotDefault(sb, TWITCH_TOTAL_SECS_KEY, TwitchTotalSeconds, itemSeparator); + if (TwitchElapsedSeconds.HasValue) + sb.AppendKeyValue(TWITCH_ELAPSED_SECS_KEY, TwitchElapsedSeconds.Value, itemSeparator); + + if (TwitchTotalSeconds.HasValue) + sb.AppendKeyValue(TWITCH_TOTAL_SECS_KEY, TwitchTotalSeconds.Value, itemSeparator); foreach (var (key, value) in _unparsedValues) { - sb.Append(key); - sb.Append(value); - sb.Append(itemSeparator); + sb.AppendKeyValue(key, value, itemSeparator); } if (sb.Length == 0) @@ -125,10 +130,7 @@ public override string ToString() sb.AppendLine(PartInfo.ToString()); if (ProgramDateTime != default) - { - sb.Append("#EXT-X-PROGRAM-DATE-TIME:"); - sb.AppendLine(ProgramDateTime.ToString("O")); - } + sb.AppendKeyValue("#EXT-X-PROGRAM-DATE-TIME:", ProgramDateTime.ToString("O"), default); if (ByteRange != default) sb.AppendLine(ByteRange.ToString()); @@ -188,21 +190,17 @@ public override string ToString() ReadOnlySpan keyValueSeparator = stackalloc char[] { ',' }; if (Type != MediaType.Unknown) - { - sb.Append("TYPE="); - sb.Append(Type.AsString()); - sb.Append(keyValueSeparator); - } + sb.AppendKeyValue("TYPE=", Type.AsString(), keyValueSeparator); + + if (!string.IsNullOrWhiteSpace(GroupId)) + sb.AppendKeyQuoteValue("GROUP-ID=", GroupId, keyValueSeparator); - StringBuilderHelpers.AppendStringIfNotNullOrEmpty(sb, "GROUP-ID=", GroupId, keyValueSeparator); - StringBuilderHelpers.AppendStringIfNotNullOrEmpty(sb, "NAME=", Name, keyValueSeparator); + if (!string.IsNullOrWhiteSpace(Name)) + sb.AppendKeyQuoteValue("NAME=", Name, keyValueSeparator); - sb.Append("AUTOSELECT="); - sb.Append(BooleanToWord(AutoSelect)); - sb.Append(keyValueSeparator); + sb.AppendKeyValue("AUTOSELECT=", BooleanToWord(AutoSelect), keyValueSeparator); - sb.Append("DEFAULT="); - sb.Append(BooleanToWord(Default)); + sb.AppendKeyValue("DEFAULT=", BooleanToWord(Default), default); return sb.ToString(); @@ -248,12 +246,23 @@ public override string ToString() var sb = new StringBuilder(STREAM_INFO_KEY); ReadOnlySpan keyValueSeparator = stackalloc char[] { ',' }; - StringBuilderHelpers.AppendIfNotDefault(sb, "PROGRAM-ID=", ProgramId, keyValueSeparator); - StringBuilderHelpers.AppendIfNotDefault(sb, "BANDWIDTH=", Bandwidth, keyValueSeparator); - StringBuilderHelpers.AppendStringIfNotNullOrEmpty(sb, "CODECS=", Codecs, keyValueSeparator); - StringBuilderHelpers.AppendIfNotDefault(sb, "RESOLUTION=", Resolution, keyValueSeparator); - StringBuilderHelpers.AppendStringIfNotNullOrEmpty(sb, "VIDEO=", Video, keyValueSeparator); - StringBuilderHelpers.AppendIfNotDefault(sb, "FRAME-RATE=", Framerate, default); + if (ProgramId != default) + sb.AppendKeyValue("PROGRAM-ID=", ProgramId, keyValueSeparator); + + if (Bandwidth != default) + sb.AppendKeyValue("BANDWIDTH=", Bandwidth, keyValueSeparator); + + if (!string.IsNullOrWhiteSpace(Codecs)) + sb.AppendKeyQuoteValue("CODECS=", Codecs, keyValueSeparator); + + if (Resolution != default) + sb.AppendKeyValue("RESOLUTION=", Resolution, keyValueSeparator); + + if (!string.IsNullOrWhiteSpace(Video)) + sb.AppendKeyQuoteValue("VIDEO=", Video, keyValueSeparator); + + if (Framerate != default) + sb.AppendKeyValue("FRAME-RATE=", Framerate, default); return sb.ToString(); } @@ -284,76 +293,60 @@ public override string ToString() sb.Append(','); if (Live) - { sb.Append("live"); - } return sb.ToString(); } } } + } - private static class StringBuilderHelpers + internal static class StringBuilderExtensions + { + public static void AppendKeyValue(this StringBuilder sb, string keyName, int value, ReadOnlySpan end) { - public static void AppendIfNotDefault(StringBuilder sb, string keyName, uint value, ReadOnlySpan end) - { - if (value == default) - return; - - sb.Append(keyName); - sb.Append(value); - sb.Append(end); - } - - public static void AppendIfNotDefault(StringBuilder sb, string keyName, int value, ReadOnlySpan end) - { - if (value == default) - return; - - sb.Append(keyName); - sb.Append(value); - sb.Append(end); - } + sb.Append(keyName); + sb.Append(value); + sb.Append(end); + } - public static void AppendIfNotDefault(StringBuilder sb, string keyName, decimal value, ReadOnlySpan end) - { - if (value == default) - return; + public static void AppendKeyValue(this StringBuilder sb, string keyName, decimal value, ReadOnlySpan end) + { + sb.Append(keyName); + sb.Append(value.ToString(CultureInfo.InvariantCulture)); + sb.Append(end); + } - sb.Append(keyName); - sb.Append(value.ToString(CultureInfo.InvariantCulture)); - sb.Append(end); - } + public static void AppendKeyValue(this StringBuilder sb, string keyName, M3U8.Stream.ExtStreamInfo.StreamResolution value, ReadOnlySpan end) + { + sb.Append(keyName); + sb.Append(value.ToString()); + sb.Append(end); + } - public static void AppendIfNotDefault(StringBuilder sb, string keyName, Stream.ExtStreamInfo.StreamResolution value, ReadOnlySpan end) - { - if (value == default) - return; + public static void AppendKeyValue(this StringBuilder sb, string keyName, string value, ReadOnlySpan end) + { + sb.Append(keyName); + sb.Append(value); + sb.Append(end); + } - sb.Append(keyName); - sb.Append(value.ToString()); - sb.Append(end); - } + public static void AppendKeyQuoteValue(this StringBuilder sb, string keyName, string value, ReadOnlySpan end) + { + sb.Append(keyName); - public static void AppendStringIfNotNullOrEmpty(StringBuilder sb, string keyName, string value, ReadOnlySpan end) + if (!keyName.EndsWith('"')) { - if (string.IsNullOrEmpty(value)) - return; - - sb.Append(keyName); - - if (!keyName.EndsWith('"')) - { - sb.Append('"'); - } - sb.Append(value); sb.Append('"'); - sb.Append(end); } + + sb.Append(value); + sb.Append('"'); + sb.Append(end); } } - public static class EnumExtensions + internal static class EnumExtensions { public static string AsString(this M3U8.Stream.ExtMediaInfo.MediaType mediaType) { diff --git a/TwitchDownloaderCore/Tools/M3U8Parse.cs b/TwitchDownloaderCore/Tools/M3U8Parse.cs index 1d5186f9..27886c78 100644 --- a/TwitchDownloaderCore/Tools/M3U8Parse.cs +++ b/TwitchDownloaderCore/Tools/M3U8Parse.cs @@ -174,7 +174,19 @@ public partial record Metadata { public sealed class Builder { - private Metadata _metadata; + // Generic M3U headers + private uint? _version; + private uint? _streamTargetDuration; + private PlaylistType? _type; + private uint? _mediaSequence; + + // Twitch specific + private uint? _twitchLiveSequence; + private decimal? _twitchElapsedSeconds; + private decimal? _twitchTotalSeconds; + + // Other headers that we don't have dedicated properties for. Useful for debugging. + private readonly List> _unparsedValues = new(); public Builder ParseAndAppend(ReadOnlySpan text) { @@ -192,44 +204,37 @@ private void ParseAndAppendCore(ReadOnlySpan text) { if (text.StartsWith(TARGET_VERSION_KEY)) { - _metadata ??= new Metadata(); - _metadata.Version = ParsingHelpers.ParseUIntValue(text, TARGET_VERSION_KEY); + _version = ParsingHelpers.ParseUIntValue(text, TARGET_VERSION_KEY); } else if (text.StartsWith(TARGET_DURATION_KEY)) { - _metadata ??= new Metadata(); - _metadata.StreamTargetDuration = ParsingHelpers.ParseUIntValue(text, TARGET_DURATION_KEY); + _streamTargetDuration = ParsingHelpers.ParseUIntValue(text, TARGET_DURATION_KEY); } else if (text.StartsWith(PLAYLIST_TYPE_KEY)) { - _metadata ??= new Metadata(); var temp = text[PLAYLIST_TYPE_KEY.Length..]; if (temp.StartsWith(PLAYLIST_TYPE_VOD)) - _metadata.Type = PlaylistType.Vod; + _type = PlaylistType.Vod; else if (temp.StartsWith(PLAYLIST_TYPE_EVENT)) - _metadata.Type = PlaylistType.Event; + _type = PlaylistType.Event; else throw new FormatException($"Unable to parse PlaylistType from: {text}"); } else if (text.StartsWith(MEDIA_SEQUENCE_KEY)) { - _metadata ??= new Metadata(); - _metadata.MediaSequence = ParsingHelpers.ParseUIntValue(text, MEDIA_SEQUENCE_KEY); + _mediaSequence = ParsingHelpers.ParseUIntValue(text, MEDIA_SEQUENCE_KEY); } else if (text.StartsWith(TWITCH_LIVE_SEQUENCE_KEY)) { - _metadata ??= new Metadata(); - _metadata.TwitchLiveSequence = ParsingHelpers.ParseUIntValue(text, TWITCH_LIVE_SEQUENCE_KEY); + _twitchLiveSequence = ParsingHelpers.ParseUIntValue(text, TWITCH_LIVE_SEQUENCE_KEY); } else if (text.StartsWith(TWITCH_ELAPSED_SECS_KEY)) { - _metadata ??= new Metadata(); - _metadata.TwitchElapsedSeconds = ParsingHelpers.ParseDecimalValue(text, TWITCH_ELAPSED_SECS_KEY); + _twitchElapsedSeconds = ParsingHelpers.ParseDecimalValue(text, TWITCH_ELAPSED_SECS_KEY); } else if (text.StartsWith(TWITCH_TOTAL_SECS_KEY)) { - _metadata ??= new Metadata(); - _metadata.TwitchTotalSeconds = ParsingHelpers.ParseDecimalValue(text, TWITCH_TOTAL_SECS_KEY); + _twitchTotalSeconds = ParsingHelpers.ParseDecimalValue(text, TWITCH_TOTAL_SECS_KEY); } else if (text.StartsWith(TWITCH_INFO_KEY)) { @@ -237,24 +242,33 @@ private void ParseAndAppendCore(ReadOnlySpan text) } else if (text[0] == '#') { - _metadata ??= new Metadata(); var colonIndex = text.IndexOf(':'); if (colonIndex != -1) { var kvp = new KeyValuePair(text[..(colonIndex + 1)].ToString(), text[(colonIndex + 1)..].ToString()); - _metadata._unparsedValues.Add(kvp); + _unparsedValues.Add(kvp); } else { var kvp = new KeyValuePair("", text.ToString()); - _metadata._unparsedValues.Add(kvp); + _unparsedValues.Add(kvp); } } } public Metadata ToMetadata() { - return _metadata; + return new Metadata + { + Version = _version, + StreamTargetDuration = _streamTargetDuration, + Type = _type, + MediaSequence = _mediaSequence, + TwitchLiveSequence = _twitchLiveSequence, + TwitchElapsedSeconds = _twitchElapsedSeconds, + TwitchTotalSeconds = _twitchTotalSeconds, + _unparsedValues = _unparsedValues + }; } } } diff --git a/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs b/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs index a3d4dee2..b9e89ffb 100644 --- a/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs +++ b/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs @@ -13,9 +13,16 @@ public static string StringifyByteCount(long sizeInBytes) return sizeInBytes switch { < 1 => "", + < ONE_KIBIBYTE => $"{sizeInBytes}B", + + < 100 * ONE_KIBIBYTE => $"{(float)sizeInBytes / ONE_KIBIBYTE:F2}KiB", < ONE_MEBIBYTE => $"{(float)sizeInBytes / ONE_KIBIBYTE:F1}KiB", + + < 100 * ONE_MEBIBYTE => $"{(float)sizeInBytes / ONE_MEBIBYTE:F2}MiB", < ONE_GIBIBYTE => $"{(float)sizeInBytes / ONE_MEBIBYTE:F1}MiB", + + < 100 * ONE_GIBIBYTE => $"{(float)sizeInBytes / ONE_GIBIBYTE:F2}GiB", _ => $"{(float)sizeInBytes / ONE_GIBIBYTE:F1}GiB", }; } diff --git a/TwitchDownloaderCore/TwitchHelper.cs b/TwitchDownloaderCore/TwitchHelper.cs index af52fee6..a5943c3b 100644 --- a/TwitchDownloaderCore/TwitchHelper.cs +++ b/TwitchDownloaderCore/TwitchHelper.cs @@ -34,7 +34,7 @@ public static async Task GetVideoInfo(long videoId) { RequestUri = new Uri("https://gql.twitch.tv/gql"), Method = HttpMethod.Post, - Content = new StringContent("{\"query\":\"query{video(id:\\\"" + videoId + "\\\"){title,thumbnailURLs(height:180,width:320),createdAt,lengthSeconds,owner{id,displayName},viewCount,game{id,displayName,boxArtURL},description}}\",\"variables\":{}}", Encoding.UTF8, "application/json") + Content = new StringContent("{\"query\":\"query{video(id:\\\"" + videoId + "\\\"){title,thumbnailURLs(height:180,width:320),createdAt,lengthSeconds,owner{id,displayName,login},viewCount,game{id,displayName,boxArtURL},description}}\",\"variables\":{}}", Encoding.UTF8, "application/json") }; request.Headers.Add("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko"); using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); @@ -124,7 +124,7 @@ public static async Task GetClipInfo(object clipId) { RequestUri = new Uri("https://gql.twitch.tv/gql"), Method = HttpMethod.Post, - Content = new StringContent("{\"query\":\"query{clip(slug:\\\"" + clipId + "\\\"){title,thumbnailURL,createdAt,durationSeconds,broadcaster{id,displayName},videoOffsetSeconds,video{id},viewCount,game{id,displayName,boxArtURL}}}\",\"variables\":{}}", Encoding.UTF8, "application/json") + Content = new StringContent("{\"query\":\"query{clip(slug:\\\"" + clipId + "\\\"){title,thumbnailURL,createdAt,curator{id,displayName,login},durationSeconds,broadcaster{id,displayName,login},videoOffsetSeconds,video{id},viewCount,game{id,displayName,boxArtURL}}}\",\"variables\":{}}", Encoding.UTF8, "application/json") }; request.Headers.Add("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko"); using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); diff --git a/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipResponse.cs b/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipResponse.cs index 8083af9c..9911d554 100644 --- a/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipResponse.cs +++ b/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipResponse.cs @@ -6,6 +6,14 @@ public class ClipBroadcaster { public string id { get; set; } public string displayName { get; set; } + public string login { get; set; } + } + + public class ClipCurator + { + public string id { get; set; } + public string displayName { get; set; } + public string login { get; set; } } public class ClipVideo @@ -18,6 +26,7 @@ public class Clip public string title { get; set; } public string thumbnailURL { get; set; } public DateTime createdAt { get; set; } + public ClipCurator curator { get; set; } public int durationSeconds { get; set; } public ClipBroadcaster broadcaster { get; set; } public int? videoOffsetSeconds { get; set; } diff --git a/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipTokenResponse.cs b/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipTokenResponse.cs index 97fbce66..4e0f4b10 100644 --- a/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipTokenResponse.cs +++ b/TwitchDownloaderCore/TwitchObjects/Gql/GqlClipTokenResponse.cs @@ -26,7 +26,7 @@ public class GqlClipTokenResponse public class VideoQuality { - public double frameRate { get; set; } + public decimal frameRate { get; set; } public string quality { get; set; } public string sourceURL { get; set; } } diff --git a/TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoResponse.cs b/TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoResponse.cs index 9b0856a2..fd25dc2a 100644 --- a/TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoResponse.cs +++ b/TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoResponse.cs @@ -7,6 +7,7 @@ public class VideoOwner { public string id { get; set; } public string displayName { get; set; } + public string login { get; set; } } public class VideoInfo