From b28cbf0736cde9a6a56820bda0bd97f4d0f81450 Mon Sep 17 00:00:00 2001 From: John Smith Date: Thu, 8 Oct 2026 22:18:14 +0100 Subject: [PATCH] feat: add --format, --segments, --list-languages and --proxy - --format md|txt|srt|vtt writes only the transcript; SRT and WebVTT keep millisecond cue times. - --segments adds a segments array of {start, end, text} to the JSON. - --list-languages prints the caption tracks as JSON. - --proxy routes requests through an http, https or socks proxy; HTTPS_PROXY was already honoured and is now documented. Refs #3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FPou7f8nRjX78T2qR7hTjb --- AGENTS.md | 2 +- README.md | 30 +++- src/Ytx/Program.cs | 221 ++++++++++++++++++++++++--- tests/Ytx.Tests/GoldenOutputTests.cs | 22 ++- tests/Ytx.Tests/ProgramTests.cs | 91 +++++++++++ 5 files changed, 343 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 040cd42..eb8b241 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ This is `ytx`, a .NET Global Tool that extracts YouTube video metadata and trans 2. YouTube video data extraction via YoutubeExplode 3. Caption track discovery and selection (prefers English, falls back to any available) 4. Transcript formatting (raw text + markdown with timestamped links) and caption status -5. JSON serialization to stdout +5. JSON serialization to stdout, or a transcript-only format (`--format md|txt|srt|vtt`) built from the normalized segments ## Development Commands diff --git a/README.md b/README.md index b3dc114..36d8df8 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,14 @@ The positional URL and piped JSON forms are the original interfaces and remain s (default: English). Falls back to English, then any track, with a warning on stderr --metadata-only Skip caption retrieval and return metadata only +-f, --format json (default), or only the transcript as md, txt, + srt or vtt + --segments Add a segments array of {start, end, text} to the + JSON, with times in seconds + --list-languages List the video's caption tracks as JSON and exit + --proxy Send requests through this proxy, such as + http://host:8080 or socks5://host:1080. Without it, + HTTPS_PROXY and the system proxy are used --timeout Give up after this many seconds (default: none) -c, --compact Write compact JSON instead of indented JSON -h, -?, --help Show help and exit @@ -86,6 +94,19 @@ ytx dQw4w9WgXcQ # Prefer French captions; falls back to English with a warning on stderr ytx --language fr "https://www.youtube.com/watch?v=dQw4w9WgXcQ" +# See which caption tracks a video has +ytx --list-languages dQw4w9WgXcQ + +# Write subtitles, or plain text with one caption per line +ytx --format srt dQw4w9WgXcQ > video.srt +ytx --format txt dQw4w9WgXcQ + +# Add timed segments for agents and tools +ytx --segments dQw4w9WgXcQ + +# Work from a server or CI runner through a proxy +ytx --proxy http://user:pass@proxy.example:8080 dQw4w9WgXcQ + # Retrieve only the metadata and emit one-line JSON ytx --metadata-only --compact "https://www.youtube.com/watch?v=dQw4w9WgXcQ" ``` @@ -119,6 +140,11 @@ Every document has the same keys in the same order. Fields that YouTube does not | `captionError` | Error message when `captionStatus` is `blocked` or `error` | | `transcriptRaw` | All caption text as a single normalized string | | `transcript` | Markdown list with `[HH:MM:SS](link) text` format | +| `segments` | With `--segments`, an array of `{start, end, text}` with times in seconds to the millisecond; otherwise `null` | + +With `--format md|txt|srt|vtt`, only the transcript is written, with no JSON. If there is no transcript, stdout is empty and stderr says why. The exit codes are the same as for JSON. + +`--list-languages` writes `{"videoId": "...", "tracks": [{"code": "en", "name": "English", "isAutoGenerated": false}, ...]}`. Pass a `code` or `name` from it to `--language`. When `captionStatus` is not `ok`, the caption fields are `null` and both transcript fields are empty. Check `captionStatus` rather than the transcript text. @@ -197,8 +223,8 @@ The GitHub Actions workflow uses NuGet Trusted Publishing and does not need a st - Private, age-restricted, or region-blocked videos may not provide transcripts - Videos with disabled captions return `captionStatus: "none"` -- YouTube blocks many cloud and datacenter IP addresses, which shows up as `captionStatus: "blocked"` or exit code `1` -- Timestamps are based on caption segment start times +- YouTube blocks many cloud and datacenter IP addresses, which shows up as `captionStatus: "blocked"` or exit code `1`. Use `--proxy` or `HTTPS_PROXY` with a residential proxy +- Markdown timestamps are rounded down to whole seconds; `segments`, SRT and WebVTT keep milliseconds - Large transcripts are not truncated (full output provided) ## Dependencies diff --git a/src/Ytx/Program.cs b/src/Ytx/Program.cs index 731a270..c258b95 100644 --- a/src/Ytx/Program.cs +++ b/src/Ytx/Program.cs @@ -1,8 +1,10 @@ +using System.Net; using System.Reflection; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; using YoutubeExplode; using YoutubeExplode.Exceptions; @@ -19,7 +21,21 @@ record Options( bool ShowHelp, bool ShowVersion, bool LanguageSpecified = false, - TimeSpan? Timeout = null); + TimeSpan? Timeout = null, + string Format = OutputFormat.Json, + bool Segments = false, + bool ListLanguages = false, + Uri? Proxy = null); + +static class OutputFormat +{ + public const string Json = "json"; + public const string Markdown = "md"; + public const string Text = "txt"; + public const string Srt = "srt"; + public const string Vtt = "vtt"; + public static readonly string[] All = [Json, Markdown, Text, Srt, Vtt]; +} // Property order is the JSON field order. Nullable fields are written as null, never // omitted, so every document has the same keys. @@ -42,6 +58,28 @@ class Output public string? captionError { get; set; } public string transcriptRaw { get; set; } = ""; public string transcript { get; set; } = ""; + public IReadOnlyList? segments { get; set; } +} + +// Times are in seconds, rounded to the millisecond. +class Segment +{ + public double start { get; set; } + public double end { get; set; } + public string text { get; set; } = ""; +} + +class LanguagesOutput +{ + public string videoId { get; set; } = ""; + public IReadOnlyList tracks { get; set; } = []; +} + +class TrackOutput +{ + public string code { get; set; } = ""; + public string name { get; set; } = ""; + public bool isAutoGenerated { get; set; } } static class CaptionStatus @@ -57,6 +95,7 @@ static class CaptionStatus // working under trimming and NativeAOT, where reflection-based serialization is disabled. [JsonSerializable(typeof(Input))] [JsonSerializable(typeof(Output))] +[JsonSerializable(typeof(LanguagesOutput))] partial class YtxJsonContext : JsonSerializerContext { } @@ -81,13 +120,22 @@ not read. Piped JSON uses only its "url" key. (default: English). Falls back to English, then any track, with a warning on stderr. --metadata-only Skip caption retrieval and return metadata only. + -f, --format json (default), or only the transcript as md, txt, + srt or vtt. + --segments Add a segments array of {start, end, text} to the + JSON, with times in seconds. + --list-languages List the video's caption tracks as JSON and exit. + --proxy Send requests through this proxy, such as + http://host:8080 or socks5://host:1080. Without it, + HTTPS_PROXY and the system proxy are used. --timeout Give up after this many seconds (default: none). -c, --compact Write compact JSON instead of indented JSON. -h, -?, --help Show help and exit. -v, --version Show the ytx version and exit. Output: - JSON is written to stdout. Diagnostics are written to stderr. + JSON (or the chosen format) is written to stdout. Diagnostics are written + to stderr. Exit codes: 0 Success, including videos with no captions @@ -171,19 +219,45 @@ static async Task Main(string[] args) try { - var client = new YoutubeClient(); + using var http = CreateHttpClient(options.Proxy); + var client = new YoutubeClient(http); + Console.OutputEncoding = Encoding.UTF8; + + if (options.ListLanguages) + { + var manifest = await client.Videos.ClosedCaptions.GetManifestAsync(videoId.Value, cts.Token); + using var languagesStdout = Console.OpenStandardOutput(); + WriteJson(languagesStdout, CreateLanguagesOutput(videoId.Value, manifest.Tracks), + YtxJsonContextFor(options.Compact).LanguagesOutput); + return 0; + } + var video = await client.Videos.GetAsync(videoId.Value, cts.Token); var output = CreateOutput(video); if (!options.MetadataOnly) { - await AddCaptionsAsync(client, video.Id, options.Language, output, cts.Token); + var segments = await AddCaptionsAsync(client, video.Id, options.Language, output, cts.Token); + // Text formats are built from the segments, so keep them for those as well. + if (options.Segments || options.Format != OutputFormat.Json) output.segments = segments; } - Console.OutputEncoding = Encoding.UTF8; using (var stdout = Console.OpenStandardOutput()) { - WriteOutput(stdout, output, options.Compact); + if (options.Format == OutputFormat.Json) + { + WriteOutput(stdout, output, options.Compact); + } + else if (output.captionStatus == CaptionStatus.Ok) + { + var text = FormatTranscript(options.Format, output); + stdout.Write(new UTF8Encoding(false).GetBytes(text)); + } + } + + if (options.Format != OutputFormat.Json && output.captionStatus == CaptionStatus.None) + { + Console.Error.WriteLine("Warning: This video has no captions."); } if (output.captionError != null) @@ -220,7 +294,22 @@ static async Task Main(string[] args) description = video.Description ?? "", }; - private static async Task AddCaptionsAsync( + // No handler is passed without a proxy, so HttpClient keeps its default proxy, which + // reads HTTPS_PROXY, HTTP_PROXY and NO_PROXY and falls back to the system setting. + internal static HttpClient CreateHttpClient(Uri? proxy) => proxy == null + ? new HttpClient() + : new HttpClient(new HttpClientHandler { Proxy = new WebProxy(proxy), UseProxy = true }, disposeHandler: true); + + internal static LanguagesOutput CreateLanguagesOutput(VideoId videoId, IEnumerable tracks) => new() + { + videoId = videoId.Value, + tracks = tracks + .Select(t => new TrackOutput { code = t.Language.Code, name = t.Language.Name, isAutoGenerated = t.IsAutoGenerated }) + .ToList(), + }; + + // Returns the normalized segments, or an empty list when there is no transcript. + private static async Task> AddCaptionsAsync( YoutubeClient client, VideoId videoId, string language, Output output, CancellationToken cancellationToken) { try @@ -230,7 +319,7 @@ private static async Task AddCaptionsAsync( if (track == null) { output.captionStatus = CaptionStatus.None; - return; + return []; } if (!LanguageMatches(track.Language.Code, track.Language.Name, language)) @@ -245,6 +334,7 @@ private static async Task AddCaptionsAsync( output.captionLanguage = track.Language.Code; output.captionLanguageName = track.Language.Name; output.captionIsAutoGenerated = track.IsAutoGenerated; + return BuildSegments(captions.Captions); } // Only failures of the caption fetch itself are reported in the output. Anything // else is a bug and should fail loudly instead of looking like a video without captions. @@ -254,6 +344,7 @@ private static async Task AddCaptionsAsync( { output.captionStatus = ClassifyCaptionError(ex); output.captionError = ex.Message; + return []; } } @@ -295,18 +386,81 @@ internal static (string Raw, string Markdown) BuildTranscript(string videoId, IE return (rawSb.ToString().Trim(), mdSb.ToString().TrimEnd()); } - // Writes UTF-8 without a BOM and ends with the platform newline, matching Console.WriteLine. - internal static void WriteOutput(Stream stream, Output output, bool compact) + internal static IReadOnlyList BuildSegments(IEnumerable captions) { - var context = new YtxJsonContext(new JsonSerializerOptions + var segments = new List(); + foreach (var caption in captions) { - WriteIndented = !compact, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }); + var text = NormalizeCaption(caption.Text); + if (string.IsNullOrWhiteSpace(text)) continue; + segments.Add(new Segment + { + start = Math.Round(caption.Offset.TotalSeconds, 3), + end = Math.Round((caption.Offset + caption.Duration).TotalSeconds, 3), + text = text, + }); + } + + return segments; + } + + internal static string FormatTranscript(string format, Output output) + { + var segments = output.segments ?? []; + var sb = new StringBuilder(); + switch (format) + { + case OutputFormat.Markdown: + return output.transcript + Environment.NewLine; + case OutputFormat.Text: + foreach (var s in segments) sb.Append(s.text).Append('\n'); + return sb.ToString(); + case OutputFormat.Srt: + for (var i = 0; i < segments.Count; i++) + { + sb.Append(i + 1).Append('\n'); + AppendCueTime(sb, segments[i].start, ',').Append(" --> "); + AppendCueTime(sb, segments[i].end, ',').Append('\n'); + sb.Append(segments[i].text).Append("\n\n"); + } + return sb.ToString(); + case OutputFormat.Vtt: + sb.Append("WEBVTT\n\n"); + foreach (var s in segments) + { + AppendCueTime(sb, s.start, '.').Append(" --> "); + AppendCueTime(sb, s.end, '.').Append('\n'); + // A blank line or "-->" would end or break a WebVTT cue. + sb.Append(s.text.Replace("-->", "->")).Append("\n\n"); + } + return sb.ToString(); + default: + throw new ArgumentOutOfRangeException(nameof(format), format, null); + } + } + + // HH:MM:SS,mmm for SRT and HH:MM:SS.mmm for WebVTT. Hours grow past two digits if needed. + private static StringBuilder AppendCueTime(StringBuilder sb, double seconds, char separator) + { + var ms = (long)Math.Round(seconds * 1000); + return sb.Append($"{ms / 3_600_000:00}:{ms / 60_000 % 60:00}:{ms / 1000 % 60:00}{separator}{ms % 1000:000}"); + } + + private static YtxJsonContext YtxJsonContextFor(bool compact) => new(new JsonSerializerOptions + { + WriteIndented = !compact, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + // Writes UTF-8 without a BOM and ends with the platform newline, matching Console.WriteLine. + internal static void WriteOutput(Stream stream, Output output, bool compact) => + WriteJson(stream, output, YtxJsonContextFor(compact).Output); - // Serializing to a string sizes its buffer for the worst-case escape of the whole - // transcript and then copies it as UTF-16. Writing UTF-8 to the stream avoids both. - JsonSerializer.Serialize(stream, output, context.Output); + // Serializing to a string sizes its buffer for the worst-case escape of the whole + // transcript and then copies it as UTF-16. Writing UTF-8 to the stream avoids both. + internal static void WriteJson(Stream stream, T value, JsonTypeInfo typeInfo) + { + JsonSerializer.Serialize(stream, value, typeInfo); stream.Write(Encoding.UTF8.GetBytes(Environment.NewLine)); stream.Flush(); } @@ -318,6 +472,10 @@ internal static (Options? Options, string? Error) ParseOptions(string[] args) var languageSpecified = false; TimeSpan? timeout = null; var metadataOnly = false; + var format = OutputFormat.Json; + var segments = false; + var listLanguages = false; + Uri? proxy = null; var compact = false; var showHelp = false; var showVersion = false; @@ -359,6 +517,28 @@ internal static (Options? Options, string? Error) ParseOptions(string[] args) language = args[i]; languageSpecified = true; } + else if (!positionalOnly && arg is "-f" or "--format") + { + if (++i >= args.Length) return (null, $"Option '{arg}' requires a value."); + format = args[i].ToLowerInvariant(); + if (!OutputFormat.All.Contains(format)) + return (null, $"Option '{arg}' must be one of: {string.Join(", ", OutputFormat.All)}."); + } + else if (!positionalOnly && arg == "--segments") + { + segments = true; + } + else if (!positionalOnly && arg == "--list-languages") + { + listLanguages = true; + } + else if (!positionalOnly && arg == "--proxy") + { + if (++i >= args.Length) return (null, $"Option '{arg}' requires a value."); + if (!Uri.TryCreate(args[i], UriKind.Absolute, out proxy) + || proxy.Scheme is not ("http" or "https" or "socks4" or "socks4a" or "socks5")) + return (null, $"Option '{arg}' requires an http, https, socks4, socks4a or socks5 URL."); + } else if (!positionalOnly && arg == "--timeout") { if (++i >= args.Length) return (null, $"Option '{arg}' requires a value."); @@ -384,8 +564,13 @@ internal static (Options? Options, string? Error) ParseOptions(string[] args) if (showHelp && showVersion) return (null, "Options '--help' and '--version' cannot be used together."); + if (format != OutputFormat.Json && (metadataOnly || segments)) + return (null, $"Option '--format {format}' writes only the transcript, so it cannot be used with '{(metadataOnly ? "--metadata-only" : "--segments")}'."); + if (listLanguages && (metadataOnly || segments || format != OutputFormat.Json)) + return (null, "Option '--list-languages' cannot be used with '--metadata-only', '--segments' or '--format'."); - return (new Options(url, language, metadataOnly, compact, showHelp, showVersion, languageSpecified, timeout), null); + return (new Options(url, language, metadataOnly, compact, showHelp, showVersion, languageSpecified, timeout, + format, segments, listLanguages, proxy), null); } internal static string GetVersion() diff --git a/tests/Ytx.Tests/GoldenOutputTests.cs b/tests/Ytx.Tests/GoldenOutputTests.cs index bad29cd..8ffdcc3 100644 --- a/tests/Ytx.Tests/GoldenOutputTests.cs +++ b/tests/Ytx.Tests/GoldenOutputTests.cs @@ -204,6 +204,7 @@ public void WriteOutput_MatchesGoldenIndentedJson() captionIsAutoGenerated = false, transcriptRaw = raw, transcript = markdown, + segments = Program.BuildSegments(captions), }; var expected = """ @@ -227,7 +228,24 @@ public void WriteOutput_MatchesGoldenIndentedJson() "captionIsAutoGenerated": false, "captionError": null, "transcriptRaw": "hello world café & \"tea\" late", - "transcript": "- [00:03](https://www.youtube.com/watch?v=AbCdEfGhIjK&t=3s) hello world{NL}- [01:02:03](https://www.youtube.com/watch?v=AbCdEfGhIjK&t=3723s) café & \"tea\"{NL}- [100:00:07](https://www.youtube.com/watch?v=AbCdEfGhIjK&t=360007s) late" + "transcript": "- [00:03](https://www.youtube.com/watch?v=AbCdEfGhIjK&t=3s) hello world{NL}- [01:02:03](https://www.youtube.com/watch?v=AbCdEfGhIjK&t=3723s) café & \"tea\"{NL}- [100:00:07](https://www.youtube.com/watch?v=AbCdEfGhIjK&t=360007s) late", + "segments": [ + { + "start": 3, + "end": 5, + "text": "hello world" + }, + { + "start": 3723, + "end": 3725, + "text": "café & \"tea\"" + }, + { + "start": 360007, + "end": 360009, + "text": "late" + } + ] } """.Replace("\n", Environment.NewLine) @@ -252,7 +270,7 @@ public void WriteOutput_MatchesGoldenCompactJson() var expected = "{\"url\":\"u\",\"videoId\":\"v\",\"title\":\"\\u2028\\u0085\\u3000\\uD83D\\uDE00\",\"channel\":\"\",\"channelId\":\"\"," + "\"uploadDate\":null,\"durationSeconds\":null,\"viewCount\":null,\"keywords\":[],\"description\":\"\"," + "\"captionStatus\":\"blocked\",\"captionLanguage\":null,\"captionLanguageName\":null,\"captionIsAutoGenerated\":null," - + "\"captionError\":\"Sign in to confirm you're not a bot\",\"transcriptRaw\":\"\",\"transcript\":\"\"}" + Environment.NewLine; + + "\"captionError\":\"Sign in to confirm you're not a bot\",\"transcriptRaw\":\"\",\"transcript\":\"\",\"segments\":null}" + Environment.NewLine; AssertWrites(expected, output, compact: true); } diff --git a/tests/Ytx.Tests/ProgramTests.cs b/tests/Ytx.Tests/ProgramTests.cs index 905b47e..1a68999 100644 --- a/tests/Ytx.Tests/ProgramTests.cs +++ b/tests/Ytx.Tests/ProgramTests.cs @@ -136,6 +136,35 @@ public void ParseOptions_RecordsWhetherLanguageWasGiven() Assert.True(Program.ParseOptions(["-l", "English", "id"]).Options?.LanguageSpecified); } + [Fact] + public void ParseOptions_ParsesFeatureOptions() + { + var (options, error) = Program.ParseOptions( + ["-f", "SRT", "--proxy", "socks5://127.0.0.1:1080", "id"]); + + Assert.Null(error); + Assert.Equal(OutputFormat.Srt, options?.Format); + Assert.Equal(new Uri("socks5://127.0.0.1:1080"), options?.Proxy); + Assert.True(Program.ParseOptions(["--segments", "id"]).Options?.Segments); + Assert.True(Program.ParseOptions(["--list-languages", "id"]).Options?.ListLanguages); + } + + [Theory] + [InlineData("--format xml", "Option '--format' must be one of: json, md, txt, srt, vtt.")] + [InlineData("--proxy ftp://host", "Option '--proxy' requires an http, https, socks4, socks4a or socks5 URL.")] + [InlineData("--proxy host:8080", "Option '--proxy' requires an http, https, socks4, socks4a or socks5 URL.")] + [InlineData("--format srt --metadata-only", "Option '--format srt' writes only the transcript, so it cannot be used with '--metadata-only'.")] + [InlineData("--format txt --segments", "Option '--format txt' writes only the transcript, so it cannot be used with '--segments'.")] + [InlineData("--list-languages --format vtt", "Option '--list-languages' cannot be used with '--metadata-only', '--segments' or '--format'.")] + [InlineData("--list-languages --metadata-only", "Option '--list-languages' cannot be used with '--metadata-only', '--segments' or '--format'.")] + public void ParseOptions_RejectsInvalidFeatureOptions(string arguments, string expectedError) + { + var (options, error) = Program.ParseOptions([.. arguments.Split(' '), "id"]); + + Assert.Null(options); + Assert.Equal(expectedError, error); + } + [Fact] public void ParseOptions_RejectsDuplicateUrlSources() { @@ -315,4 +344,66 @@ public void ClassifyCaptionError_SeparatesBlockingFromOtherFailures(Exception ex { Assert.Equal(expected, Program.ClassifyCaptionError(ex)); } + + private static Output TranscriptOutput() => new() + { + transcript = "- [00:01](link) first", + segments = Program.BuildSegments([ + new ClosedCaption("first", TimeSpan.FromMilliseconds(1234.4), TimeSpan.FromSeconds(2), []), + new ClosedCaption(" ", TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(1), []), + new ClosedCaption("a --> b", new TimeSpan(0, 101, 2, 3, 45), TimeSpan.FromMilliseconds(500), []), + ]), + }; + + [Fact] + public void BuildSegments_SkipsBlankCaptionsAndRoundsToMilliseconds() + { + var segments = TranscriptOutput().segments!; + + Assert.Equal(2, segments.Count); + Assert.Equal(1.234, segments[0].start); + Assert.Equal(3.234, segments[0].end); + Assert.Equal(363723.045, segments[1].start); + } + + [Fact] + public void FormatTranscript_WritesSrt() + { + Assert.Equal( + "1\n00:00:01,234 --> 00:00:03,234\nfirst\n\n2\n101:02:03,045 --> 101:02:03,545\na --> b\n\n", + Program.FormatTranscript(OutputFormat.Srt, TranscriptOutput())); + } + + [Fact] + public void FormatTranscript_WritesVttWithoutCueArrowsInText() + { + Assert.Equal( + "WEBVTT\n\n00:00:01.234 --> 00:00:03.234\nfirst\n\n101:02:03.045 --> 101:02:03.545\na -> b\n\n", + Program.FormatTranscript(OutputFormat.Vtt, TranscriptOutput())); + } + + [Fact] + public void FormatTranscript_WritesTextAndMarkdown() + { + Assert.Equal("first\na --> b\n", Program.FormatTranscript(OutputFormat.Text, TranscriptOutput())); + Assert.Equal("- [00:01](link) first" + Environment.NewLine, + Program.FormatTranscript(OutputFormat.Markdown, TranscriptOutput())); + } + + [Fact] + public void CreateLanguagesOutput_ListsEveryTrack() + { + var tracks = new[] + { + new ClosedCaptionTrackInfo("https://example.com/en", new Language("en", "English"), false), + new ClosedCaptionTrackInfo("https://example.com/a", new Language("en", "English (auto-generated)"), true), + }; + + var languages = Program.CreateLanguagesOutput(VideoId.Parse("dQw4w9WgXcQ"), tracks); + + Assert.Equal("dQw4w9WgXcQ", languages.videoId); + Assert.Collection(languages.tracks, + t => Assert.Equal(("en", "English", false), (t.code, t.name, t.isAutoGenerated)), + t => Assert.Equal(("en", "English (auto-generated)", true), (t.code, t.name, t.isAutoGenerated))); + } }