Skip to content

Commit d0c48db

Browse files
authored
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
1 parent 810387b commit d0c48db

File tree

14 files changed

+624
-123
lines changed

14 files changed

+624
-123
lines changed

TwitchDownloaderCLI/Models/Enums.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,13 @@ public enum OverwriteBehavior
2121
Rename,
2222
Prompt,
2323
}
24+
25+
public enum InfoPrintFormat
26+
{
27+
Raw,
28+
Table,
29+
M3U8,
30+
M3U = M3U8,
31+
Json
32+
}
2433
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using CommandLine;
2+
using TwitchDownloaderCLI.Models;
3+
4+
namespace TwitchDownloaderCLI.Modes.Arguments
5+
{
6+
[Verb("info", HelpText = "Prints stream information about a VOD or clip to stdout")]
7+
internal sealed class InfoArgs : ITwitchDownloaderArgs
8+
{
9+
[Option('u', "id", Required = true, HelpText = "The ID or URL of the VOD or clip to print the stream info about.")]
10+
public string Id { get; set; }
11+
12+
[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")]
13+
public InfoPrintFormat Format { get; set; }
14+
15+
[Option("use-utf8", Default = true, HelpText = "Ensures UTF-8 encoding is used when writing results to standard output.")]
16+
public bool? UseUtf8 { get; set; }
17+
18+
[Option("oauth", HelpText = "OAuth access token to access subscriber only VODs. DO NOT SHARE THIS WITH ANYONE.")]
19+
public string Oauth { get; set; }
20+
21+
// Interface args
22+
public bool? ShowBanner { get; set; }
23+
public LogLevel LogLevel { get; set; }
24+
}
25+
}

TwitchDownloaderCLI/Modes/InfoHandler.cs

Lines changed: 418 additions & 0 deletions
Large diffs are not rendered by default.

TwitchDownloaderCLI/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ private static void Main(string[] args)
2525
config.HelpWriter = null; // Use null instead of TextWriter.Null due to how CommandLine works internally
2626
});
2727

28-
var parserResult = parser.ParseArguments<VideoDownloadArgs, ClipDownloadArgs, ChatDownloadArgs, ChatUpdateArgs, ChatRenderArgs, FfmpegArgs, CacheArgs, TsMergeArgs>(preParsedArgs);
28+
var parserResult = parser.ParseArguments<VideoDownloadArgs, ClipDownloadArgs, ChatDownloadArgs, ChatUpdateArgs, ChatRenderArgs, InfoArgs, FfmpegArgs, CacheArgs, TsMergeArgs>(preParsedArgs);
2929
parserResult.WithNotParsed(errors => WriteHelpText(errors, parserResult, parser.Settings));
3030

3131
CoreLicensor.EnsureFilesExist(AppContext.BaseDirectory);
@@ -37,6 +37,7 @@ private static void Main(string[] args)
3737
.WithParsed<ChatDownloadArgs>(DownloadChat.Download)
3838
.WithParsed<ChatUpdateArgs>(UpdateChat.Update)
3939
.WithParsed<ChatRenderArgs>(RenderChat.Render)
40+
.WithParsed<InfoArgs>(InfoHandler.PrintInfo)
4041
.WithParsed<FfmpegArgs>(FfmpegHandler.ParseArgs)
4142
.WithParsed<CacheArgs>(CacheHandler.ParseArgs)
4243
.WithParsed<TsMergeArgs>(MergeTs.Merge);

TwitchDownloaderCLI/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Also can concatenate/combine/merge Transport Stream files, either those parts do
99
- [Arguments for mode chatdownload](#arguments-for-mode-chatdownload)
1010
- [Arguments for mode chatupdate](#arguments-for-mode-chatupdate)
1111
- [Arguments for mode chatrender](#arguments-for-mode-chatrender)
12+
- [Arguments for mode info](#arguments-for-mode-info)
1213
- [Arguments for mode ffmpeg](#arguments-for-mode-ffmpeg)
1314
- [Arguments for mode cache](#arguments-for-mode-cache)
1415
- [Arguments for mode tsmerge](#arguments-for-mode-tsmerge)
@@ -338,6 +339,22 @@ Other = `1`, Broadcaster = `2`, Moderator = `4`, VIP = `8`, Subscriber = `16`, P
338339
**--collision**
339340
(Default: `Prompt`) Sets the handling of output file name collisions. Valid values are: `Overwrite`, `Exit`, `Rename`, `Prompt`.
340341

342+
## Arguments for mode info
343+
#### Prints information about a VOD, highlight, or clip
344+
345+
**-u / --id (REQUIRED)** The ID or URL of the VOD or clip to print the stream info about.
346+
347+
**-f / --format**
348+
(Default: `Table`) The format in which the information should be printed. Valid values are: `Raw`, `Table`, and `M3U` / `M3U8`.
349+
350+
When using table format, use a terminal that supports ANSI escape sequences for best results.
351+
352+
**--use-utf8**
353+
(Default: `true`) Ensures UTF-8 encoding is used when writing results to standard output.
354+
355+
**--oauth**
356+
OAuth access token to access subscriber only VODs. <ins>**DO NOT SHARE YOUR OAUTH TOKEN WITH ANYONE.**</ins>
357+
341358
## Arguments for mode ffmpeg
342359
#### Manage standalone FFmpeg
343360

@@ -408,6 +425,14 @@ Render a chat with custom video settings and message outlines
408425

409426
./TwitchDownloaderCLI chatrender -i chat.json -h 1440 -w 720 --framerate 60 --outline -o chat.mp4
410427

428+
Display the info about a VOD in table format
429+
430+
./TwitchDownloaderCLI info --id 612942303 --format table
431+
432+
Display the info about a clip in raw format
433+
434+
./TwitchDownloaderCLI info --id NurturingCalmHamburgerVoHiYo --format raw
435+
411436
Render a chat with custom FFmpeg arguments
412437

413438
./TwitchDownloaderCLI chatrender -i chat.json --output-args='-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "{save_path}"' -o chat.mp4

TwitchDownloaderCLI/TwitchDownloaderCLI.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<PackageReference Include="CommandLineParser" Version="2.9.1" />
1616
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
1717
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
18+
<PackageReference Include="Spectre.Console" Version="0.49.1" />
1819
<PackageReference Include="Xabe.FFmpeg.Downloader" Version="5.2.6" />
1920
</ItemGroup>
2021

TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ public void CorrectlyParsesTwitchM3U8OfLiveStreams(bool useStream, string cultur
142142

143143
Assert.Equal(3u, m3u8.FileMetadata.Version);
144144
Assert.Equal(5u, m3u8.FileMetadata.StreamTargetDuration);
145-
Assert.Equal(M3U8.Metadata.PlaylistType.Unknown, m3u8.FileMetadata.Type);
146145
Assert.Equal(4815u, m3u8.FileMetadata.MediaSequence);
147146
Assert.Equal(4997u, m3u8.FileMetadata.TwitchLiveSequence);
148147
Assert.Equal(9994.338m, m3u8.FileMetadata.TwitchElapsedSeconds);
@@ -356,7 +355,6 @@ public void CorrectlyParsesKickM3U8OfTransportStreams(bool useStream, string cul
356355

357356
Assert.Equal(4u, m3u8.FileMetadata.Version);
358357
Assert.Equal(2u, m3u8.FileMetadata.StreamTargetDuration);
359-
Assert.Equal(M3U8.Metadata.PlaylistType.Unknown, m3u8.FileMetadata.Type);
360358
Assert.Equal(0u, m3u8.FileMetadata.MediaSequence);
361359

362360
Assert.Equal(streamValues.Length, m3u8.Streams.Length);

TwitchDownloaderCore/Tools/M3U8.cs

Lines changed: 90 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public override string ToString()
1717

1818
sb.AppendLine("#EXTM3U");
1919

20-
if (FileMetadata?.ToString() is { Length: > 0} metadataString)
20+
if (FileMetadata?.ToString() is { Length: > 0 } metadataString)
2121
{
2222
sb.AppendLine(metadataString);
2323
}
@@ -54,44 +54,49 @@ public enum PlaylistType
5454
private const string TWITCH_INFO_KEY = "#EXT-X-TWITCH-INFO:";
5555

5656
// Generic M3U headers
57-
public uint Version { get; internal set; }
58-
public uint StreamTargetDuration { get; internal set; }
59-
public PlaylistType Type { get; internal set; } = PlaylistType.Unknown;
60-
public uint MediaSequence { get; internal set; }
57+
public uint? Version { get; init; }
58+
public uint? StreamTargetDuration { get; init; }
59+
public PlaylistType? Type { get; init; }
60+
public uint? MediaSequence { get; init; }
6161

6262
// Twitch specific
63-
public uint TwitchLiveSequence { get; internal set; }
64-
public decimal TwitchElapsedSeconds { get; internal set; }
65-
public decimal TwitchTotalSeconds { get; internal set; }
63+
public uint? TwitchLiveSequence { get; init; }
64+
public decimal? TwitchElapsedSeconds { get; init; }
65+
public decimal? TwitchTotalSeconds { get; init; }
6666

6767
// Other headers that we don't have dedicated properties for. Useful for debugging.
68-
private readonly List<KeyValuePair<string, string>> _unparsedValues = new();
68+
private List<KeyValuePair<string, string>> _unparsedValues = new();
6969
public IReadOnlyList<KeyValuePair<string, string>> UnparsedValues => _unparsedValues;
7070

7171
public override string ToString()
7272
{
7373
var sb = new StringBuilder();
7474
var itemSeparator = Environment.NewLine;
7575

76-
StringBuilderHelpers.AppendIfNotDefault(sb, TARGET_VERSION_KEY, Version, itemSeparator);
77-
StringBuilderHelpers.AppendIfNotDefault(sb, TARGET_DURATION_KEY, StreamTargetDuration, itemSeparator);
78-
if (Type != PlaylistType.Unknown)
79-
{
80-
sb.Append(PLAYLIST_TYPE_KEY);
81-
sb.Append(Type.AsString());
82-
sb.Append(itemSeparator);
83-
}
76+
if (Version.HasValue)
77+
sb.AppendKeyValue(TARGET_VERSION_KEY, Version.Value, itemSeparator);
78+
79+
if (StreamTargetDuration.HasValue)
80+
sb.AppendKeyValue(TARGET_DURATION_KEY, StreamTargetDuration.Value, itemSeparator);
81+
82+
if (Type.HasValue)
83+
sb.AppendKeyValue(PLAYLIST_TYPE_KEY, Type.Value.AsString(), itemSeparator);
84+
85+
if (MediaSequence.HasValue)
86+
sb.AppendKeyValue(MEDIA_SEQUENCE_KEY, MediaSequence.Value, itemSeparator);
87+
88+
if (TwitchLiveSequence.HasValue)
89+
sb.AppendKeyValue(TWITCH_LIVE_SEQUENCE_KEY, TwitchLiveSequence.Value, itemSeparator);
8490

85-
StringBuilderHelpers.AppendIfNotDefault(sb, MEDIA_SEQUENCE_KEY, MediaSequence, itemSeparator);
86-
StringBuilderHelpers.AppendIfNotDefault(sb, TWITCH_LIVE_SEQUENCE_KEY, TwitchLiveSequence, itemSeparator);
87-
StringBuilderHelpers.AppendIfNotDefault(sb, TWITCH_ELAPSED_SECS_KEY, TwitchElapsedSeconds, itemSeparator);
88-
StringBuilderHelpers.AppendIfNotDefault(sb, TWITCH_TOTAL_SECS_KEY, TwitchTotalSeconds, itemSeparator);
91+
if (TwitchElapsedSeconds.HasValue)
92+
sb.AppendKeyValue(TWITCH_ELAPSED_SECS_KEY, TwitchElapsedSeconds.Value, itemSeparator);
93+
94+
if (TwitchTotalSeconds.HasValue)
95+
sb.AppendKeyValue(TWITCH_TOTAL_SECS_KEY, TwitchTotalSeconds.Value, itemSeparator);
8996

9097
foreach (var (key, value) in _unparsedValues)
9198
{
92-
sb.Append(key);
93-
sb.Append(value);
94-
sb.Append(itemSeparator);
99+
sb.AppendKeyValue(key, value, itemSeparator);
95100
}
96101

97102
if (sb.Length == 0)
@@ -125,10 +130,7 @@ public override string ToString()
125130
sb.AppendLine(PartInfo.ToString());
126131

127132
if (ProgramDateTime != default)
128-
{
129-
sb.Append("#EXT-X-PROGRAM-DATE-TIME:");
130-
sb.AppendLine(ProgramDateTime.ToString("O"));
131-
}
133+
sb.AppendKeyValue("#EXT-X-PROGRAM-DATE-TIME:", ProgramDateTime.ToString("O"), default);
132134

133135
if (ByteRange != default)
134136
sb.AppendLine(ByteRange.ToString());
@@ -188,21 +190,17 @@ public override string ToString()
188190
ReadOnlySpan<char> keyValueSeparator = stackalloc char[] { ',' };
189191

190192
if (Type != MediaType.Unknown)
191-
{
192-
sb.Append("TYPE=");
193-
sb.Append(Type.AsString());
194-
sb.Append(keyValueSeparator);
195-
}
193+
sb.AppendKeyValue("TYPE=", Type.AsString(), keyValueSeparator);
194+
195+
if (!string.IsNullOrWhiteSpace(GroupId))
196+
sb.AppendKeyQuoteValue("GROUP-ID=", GroupId, keyValueSeparator);
196197

197-
StringBuilderHelpers.AppendStringIfNotNullOrEmpty(sb, "GROUP-ID=", GroupId, keyValueSeparator);
198-
StringBuilderHelpers.AppendStringIfNotNullOrEmpty(sb, "NAME=", Name, keyValueSeparator);
198+
if (!string.IsNullOrWhiteSpace(Name))
199+
sb.AppendKeyQuoteValue("NAME=", Name, keyValueSeparator);
199200

200-
sb.Append("AUTOSELECT=");
201-
sb.Append(BooleanToWord(AutoSelect));
202-
sb.Append(keyValueSeparator);
201+
sb.AppendKeyValue("AUTOSELECT=", BooleanToWord(AutoSelect), keyValueSeparator);
203202

204-
sb.Append("DEFAULT=");
205-
sb.Append(BooleanToWord(Default));
203+
sb.AppendKeyValue("DEFAULT=", BooleanToWord(Default), default);
206204

207205
return sb.ToString();
208206

@@ -248,12 +246,23 @@ public override string ToString()
248246
var sb = new StringBuilder(STREAM_INFO_KEY);
249247
ReadOnlySpan<char> keyValueSeparator = stackalloc char[] { ',' };
250248

251-
StringBuilderHelpers.AppendIfNotDefault(sb, "PROGRAM-ID=", ProgramId, keyValueSeparator);
252-
StringBuilderHelpers.AppendIfNotDefault(sb, "BANDWIDTH=", Bandwidth, keyValueSeparator);
253-
StringBuilderHelpers.AppendStringIfNotNullOrEmpty(sb, "CODECS=", Codecs, keyValueSeparator);
254-
StringBuilderHelpers.AppendIfNotDefault(sb, "RESOLUTION=", Resolution, keyValueSeparator);
255-
StringBuilderHelpers.AppendStringIfNotNullOrEmpty(sb, "VIDEO=", Video, keyValueSeparator);
256-
StringBuilderHelpers.AppendIfNotDefault(sb, "FRAME-RATE=", Framerate, default);
249+
if (ProgramId != default)
250+
sb.AppendKeyValue("PROGRAM-ID=", ProgramId, keyValueSeparator);
251+
252+
if (Bandwidth != default)
253+
sb.AppendKeyValue("BANDWIDTH=", Bandwidth, keyValueSeparator);
254+
255+
if (!string.IsNullOrWhiteSpace(Codecs))
256+
sb.AppendKeyQuoteValue("CODECS=", Codecs, keyValueSeparator);
257+
258+
if (Resolution != default)
259+
sb.AppendKeyValue("RESOLUTION=", Resolution, keyValueSeparator);
260+
261+
if (!string.IsNullOrWhiteSpace(Video))
262+
sb.AppendKeyQuoteValue("VIDEO=", Video, keyValueSeparator);
263+
264+
if (Framerate != default)
265+
sb.AppendKeyValue("FRAME-RATE=", Framerate, default);
257266

258267
return sb.ToString();
259268
}
@@ -284,76 +293,60 @@ public override string ToString()
284293
sb.Append(',');
285294

286295
if (Live)
287-
{
288296
sb.Append("live");
289-
}
290297

291298
return sb.ToString();
292299
}
293300
}
294301
}
302+
}
295303

296-
private static class StringBuilderHelpers
304+
internal static class StringBuilderExtensions
305+
{
306+
public static void AppendKeyValue(this StringBuilder sb, string keyName, int value, ReadOnlySpan<char> end)
297307
{
298-
public static void AppendIfNotDefault(StringBuilder sb, string keyName, uint value, ReadOnlySpan<char> end)
299-
{
300-
if (value == default)
301-
return;
302-
303-
sb.Append(keyName);
304-
sb.Append(value);
305-
sb.Append(end);
306-
}
307-
308-
public static void AppendIfNotDefault(StringBuilder sb, string keyName, int value, ReadOnlySpan<char> end)
309-
{
310-
if (value == default)
311-
return;
312-
313-
sb.Append(keyName);
314-
sb.Append(value);
315-
sb.Append(end);
316-
}
308+
sb.Append(keyName);
309+
sb.Append(value);
310+
sb.Append(end);
311+
}
317312

318-
public static void AppendIfNotDefault(StringBuilder sb, string keyName, decimal value, ReadOnlySpan<char> end)
319-
{
320-
if (value == default)
321-
return;
313+
public static void AppendKeyValue(this StringBuilder sb, string keyName, decimal value, ReadOnlySpan<char> end)
314+
{
315+
sb.Append(keyName);
316+
sb.Append(value.ToString(CultureInfo.InvariantCulture));
317+
sb.Append(end);
318+
}
322319

323-
sb.Append(keyName);
324-
sb.Append(value.ToString(CultureInfo.InvariantCulture));
325-
sb.Append(end);
326-
}
320+
public static void AppendKeyValue(this StringBuilder sb, string keyName, M3U8.Stream.ExtStreamInfo.StreamResolution value, ReadOnlySpan<char> end)
321+
{
322+
sb.Append(keyName);
323+
sb.Append(value.ToString());
324+
sb.Append(end);
325+
}
327326

328-
public static void AppendIfNotDefault(StringBuilder sb, string keyName, Stream.ExtStreamInfo.StreamResolution value, ReadOnlySpan<char> end)
329-
{
330-
if (value == default)
331-
return;
327+
public static void AppendKeyValue(this StringBuilder sb, string keyName, string value, ReadOnlySpan<char> end)
328+
{
329+
sb.Append(keyName);
330+
sb.Append(value);
331+
sb.Append(end);
332+
}
332333

333-
sb.Append(keyName);
334-
sb.Append(value.ToString());
335-
sb.Append(end);
336-
}
334+
public static void AppendKeyQuoteValue(this StringBuilder sb, string keyName, string value, ReadOnlySpan<char> end)
335+
{
336+
sb.Append(keyName);
337337

338-
public static void AppendStringIfNotNullOrEmpty(StringBuilder sb, string keyName, string value, ReadOnlySpan<char> end)
338+
if (!keyName.EndsWith('"'))
339339
{
340-
if (string.IsNullOrEmpty(value))
341-
return;
342-
343-
sb.Append(keyName);
344-
345-
if (!keyName.EndsWith('"'))
346-
{
347-
sb.Append('"');
348-
}
349-
sb.Append(value);
350340
sb.Append('"');
351-
sb.Append(end);
352341
}
342+
343+
sb.Append(value);
344+
sb.Append('"');
345+
sb.Append(end);
353346
}
354347
}
355348

356-
public static class EnumExtensions
349+
internal static class EnumExtensions
357350
{
358351
public static string AsString(this M3U8.Stream.ExtMediaInfo.MediaType mediaType)
359352
{

0 commit comments

Comments
 (0)