Skip to content

Commit

Permalink
Print video information in the CLI (#951)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ScrubN authored Aug 1, 2024
1 parent 810387b commit d0c48db
Show file tree
Hide file tree
Showing 14 changed files with 624 additions and 123 deletions.
9 changes: 9 additions & 0 deletions TwitchDownloaderCLI/Models/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,13 @@ public enum OverwriteBehavior
Rename,
Prompt,
}

public enum InfoPrintFormat
{
Raw,
Table,
M3U8,
M3U = M3U8,
Json
}
}
25 changes: 25 additions & 0 deletions TwitchDownloaderCLI/Modes/Arguments/InfoArgs.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
418 changes: 418 additions & 0 deletions TwitchDownloaderCLI/Modes/InfoHandler.cs

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion TwitchDownloaderCLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<VideoDownloadArgs, ClipDownloadArgs, ChatDownloadArgs, ChatUpdateArgs, ChatRenderArgs, FfmpegArgs, CacheArgs, TsMergeArgs>(preParsedArgs);
var parserResult = parser.ParseArguments<VideoDownloadArgs, ClipDownloadArgs, ChatDownloadArgs, ChatUpdateArgs, ChatRenderArgs, InfoArgs, FfmpegArgs, CacheArgs, TsMergeArgs>(preParsedArgs);
parserResult.WithNotParsed(errors => WriteHelpText(errors, parserResult, parser.Settings));

CoreLicensor.EnsureFilesExist(AppContext.BaseDirectory);
Expand All @@ -37,6 +37,7 @@ private static void Main(string[] args)
.WithParsed<ChatDownloadArgs>(DownloadChat.Download)
.WithParsed<ChatUpdateArgs>(UpdateChat.Update)
.WithParsed<ChatRenderArgs>(RenderChat.Render)
.WithParsed<InfoArgs>(InfoHandler.PrintInfo)
.WithParsed<FfmpegArgs>(FfmpegHandler.ParseArgs)
.WithParsed<CacheArgs>(CacheHandler.ParseArgs)
.WithParsed<TsMergeArgs>(MergeTs.Merge);
Expand Down
25 changes: 25 additions & 0 deletions TwitchDownloaderCLI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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. <ins>**DO NOT SHARE YOUR OAUTH TOKEN WITH ANYONE.**</ins>

## Arguments for mode ffmpeg
#### Manage standalone FFmpeg

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions TwitchDownloaderCLI/TwitchDownloaderCLI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="Spectre.Console" Version="0.49.1" />
<PackageReference Include="Xabe.FFmpeg.Downloader" Version="5.2.6" />
</ItemGroup>

Expand Down
2 changes: 0 additions & 2 deletions TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
187 changes: 90 additions & 97 deletions TwitchDownloaderCore/Tools/M3U8.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -54,44 +54,49 @@ 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<KeyValuePair<string, string>> _unparsedValues = new();
private List<KeyValuePair<string, string>> _unparsedValues = new();
public IReadOnlyList<KeyValuePair<string, string>> UnparsedValues => _unparsedValues;

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)
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -188,21 +190,17 @@ public override string ToString()
ReadOnlySpan<char> 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();

Expand Down Expand Up @@ -248,12 +246,23 @@ public override string ToString()
var sb = new StringBuilder(STREAM_INFO_KEY);
ReadOnlySpan<char> 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();
}
Expand Down Expand Up @@ -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<char> end)
{
public static void AppendIfNotDefault(StringBuilder sb, string keyName, uint value, ReadOnlySpan<char> 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<char> 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<char> end)
{
if (value == default)
return;
public static void AppendKeyValue(this StringBuilder sb, string keyName, decimal value, ReadOnlySpan<char> 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<char> end)
{
sb.Append(keyName);
sb.Append(value.ToString());
sb.Append(end);
}

public static void AppendIfNotDefault(StringBuilder sb, string keyName, Stream.ExtStreamInfo.StreamResolution value, ReadOnlySpan<char> end)
{
if (value == default)
return;
public static void AppendKeyValue(this StringBuilder sb, string keyName, string value, ReadOnlySpan<char> 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<char> end)
{
sb.Append(keyName);

public static void AppendStringIfNotNullOrEmpty(StringBuilder sb, string keyName, string value, ReadOnlySpan<char> 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)
{
Expand Down
Loading

0 comments on commit d0c48db

Please sign in to comment.