diff --git a/YoutubeExplode.Converter.Tests/GeneralSpecs.cs b/YoutubeExplode.Converter.Tests/GeneralSpecs.cs index 7657193f..6c161937 100644 --- a/YoutubeExplode.Converter.Tests/GeneralSpecs.cs +++ b/YoutubeExplode.Converter.Tests/GeneralSpecs.cs @@ -92,7 +92,7 @@ public async Task I_can_download_a_video_as_a_single_mp4_file_with_multiple_stre var filePath = Path.Combine(dir.Path, "video.mp4"); // Act - var manifest = await youtube.Videos.Streams.GetManifestAsync("9bZkp7q19f0"); + var manifest = await youtube.Videos.Streams.GetManifestAsync("ngqcjXfggHQ"); var audioStreamInfos = manifest .GetAudioOnlyStreams() @@ -117,6 +117,20 @@ await youtube.Videos.DownloadAsync( // Assert MediaFormat.IsMp4File(filePath).Should().BeTrue(); + foreach (var streamInfo in audioStreamInfos) + { + if (streamInfo.AudioLanguage is not null) + { + FileEx + .ContainsBytes( + filePath, + Encoding.ASCII.GetBytes(streamInfo.AudioLanguage.Value.Name) + ) + .Should() + .BeTrue(); + } + } + foreach (var streamInfo in videoStreamInfos) { FileEx @@ -136,7 +150,7 @@ public async Task I_can_download_a_video_as_a_single_webm_file_with_multiple_str var filePath = Path.Combine(dir.Path, "video.webm"); // Act - var manifest = await youtube.Videos.Streams.GetManifestAsync("9bZkp7q19f0"); + var manifest = await youtube.Videos.Streams.GetManifestAsync("ngqcjXfggHQ"); var audioStreamInfos = manifest .GetAudioOnlyStreams() @@ -161,6 +175,20 @@ await youtube.Videos.DownloadAsync( // Assert MediaFormat.IsWebMFile(filePath).Should().BeTrue(); + foreach (var streamInfo in audioStreamInfos) + { + if (streamInfo.AudioLanguage is not null) + { + FileEx + .ContainsBytes( + filePath, + Encoding.ASCII.GetBytes(streamInfo.AudioLanguage.Value.Name) + ) + .Should() + .BeTrue(); + } + } + foreach (var streamInfo in videoStreamInfos) { FileEx diff --git a/YoutubeExplode.Converter/Converter.cs b/YoutubeExplode.Converter/Converter.cs index fc13d457..d63d3c2b 100644 --- a/YoutubeExplode.Converter/Converter.cs +++ b/YoutubeExplode.Converter/Converter.cs @@ -116,18 +116,43 @@ private async ValueTask ProcessAsync( if (streamInput.Info is IAudioStreamInfo audioStreamInfo) { - arguments - .Add($"-metadata:s:a:{lastAudioStreamIndex++}") - .Add($"title={audioStreamInfo.Bitrate}"); + // Contains language information + if (audioStreamInfo.AudioLanguage is not null) + { + // Language codes can be stored in any format, but most players expect + // three-letter codes, so we'll try to convert to that first. + var languageCode = + audioStreamInfo.AudioLanguage.Value.TryGetThreeLetterCode() + ?? audioStreamInfo.AudioLanguage.Value.Code; + + arguments + .Add($"-metadata:s:a:{lastAudioStreamIndex}") + .Add($"language={languageCode}") + .Add($"-metadata:s:a:{lastAudioStreamIndex}") + .Add( + $"title={audioStreamInfo.AudioLanguage.Value.Name} | {audioStreamInfo.Bitrate}" + ); + } + // Does not contain language information + else + { + arguments + .Add($"-metadata:s:a:{lastAudioStreamIndex}") + .Add($"title={audioStreamInfo.Bitrate}"); + } + + lastAudioStreamIndex++; } if (streamInput.Info is IVideoStreamInfo videoStreamInfo) { arguments - .Add($"-metadata:s:v:{lastVideoStreamIndex++}") + .Add($"-metadata:s:v:{lastVideoStreamIndex}") .Add( $"title={videoStreamInfo.VideoQuality.Label} | {videoStreamInfo.Bitrate}" ); + + lastVideoStreamIndex++; } } } diff --git a/YoutubeExplode.Tests/StreamSpecs.cs b/YoutubeExplode.Tests/StreamSpecs.cs index 25ffc4de..02d9ddfe 100644 --- a/YoutubeExplode.Tests/StreamSpecs.cs +++ b/YoutubeExplode.Tests/StreamSpecs.cs @@ -1,3 +1,4 @@ +using System; using System.Buffers; using System.IO; using System.Linq; @@ -57,6 +58,61 @@ public async Task I_can_get_the_list_of_available_streams_of_a_video() .Contain(s => s.VideoQuality.MaxHeight == 144 && !s.VideoQuality.IsHighDefinition); } + [Fact] + public async Task I_can_get_the_list_of_available_streams_of_a_video_with_multiple_audio_languages() + { + // Arrange + var youtube = new YoutubeClient(); + + // Act + var manifest = await youtube.Videos.Streams.GetManifestAsync( + VideoIds.WithMultipleAudioLanguages + ); + + // Assert + manifest.Streams.Should().NotBeEmpty(); + + manifest + .GetAudioStreams() + .Should() + .Contain(t => + t.AudioLanguage != null + && t.AudioLanguage.Value.Code == "en-US" + && t.AudioLanguage.Value.Name == "English (United States) original" + && t.IsAudioLanguageDefault == true + ); + + manifest + .GetAudioStreams() + .Should() + .Contain(t => + t.AudioLanguage != null + && t.AudioLanguage.Value.Code == "fr-FR" + && t.AudioLanguage.Value.Name == "French (France)" + && t.IsAudioLanguageDefault == false + ); + + manifest + .GetAudioStreams() + .Should() + .Contain(t => + t.AudioLanguage != null + && t.AudioLanguage.Value.Code == "it" + && t.AudioLanguage.Value.Name == "Italian" + && t.IsAudioLanguageDefault == false + ); + + manifest + .GetAudioStreams() + .Should() + .Contain(t => + t.AudioLanguage != null + && t.AudioLanguage.Value.Code == "pt-BR" + && t.AudioLanguage.Value.Name == "Portuguese (Brazil)" + && t.IsAudioLanguageDefault == false + ); + } + [Theory] [InlineData(VideoIds.Normal)] [InlineData(VideoIds.Unlisted)] diff --git a/YoutubeExplode.Tests/TestData/VideoIds.cs b/YoutubeExplode.Tests/TestData/VideoIds.cs index 902256c9..b822e5a5 100644 --- a/YoutubeExplode.Tests/TestData/VideoIds.cs +++ b/YoutubeExplode.Tests/TestData/VideoIds.cs @@ -21,4 +21,5 @@ internal static class VideoIds public const string WithHighDynamicRangeStreams = "vX2vsvdq8nw"; public const string WithClosedCaptions = "YltHGKX80Y8"; public const string WithBrokenClosedCaptions = "1VKIIw05JnE"; + public const string WithMultipleAudioLanguages = "ngqcjXfggHQ"; } diff --git a/YoutubeExplode/Bridge/DashManifest.cs b/YoutubeExplode/Bridge/DashManifest.cs index 1892cd67..cb24589b 100644 --- a/YoutubeExplode/Bridge/DashManifest.cs +++ b/YoutubeExplode/Bridge/DashManifest.cs @@ -70,6 +70,12 @@ public class StreamData(XElement content) : IStreamData [Lazy] public string? AudioCodec => IsAudioOnly ? (string?)content.Attribute("codecs") : null; + public string? AudioLanguageCode => null; + + public string? AudioLanguageName => null; + + public bool? IsAudioLanguageDefault => null; + [Lazy] public string? VideoCodec => IsAudioOnly ? null : (string?)content.Attribute("codecs"); diff --git a/YoutubeExplode/Bridge/IStreamData.cs b/YoutubeExplode/Bridge/IStreamData.cs index f2385bab..067e91e1 100644 --- a/YoutubeExplode/Bridge/IStreamData.cs +++ b/YoutubeExplode/Bridge/IStreamData.cs @@ -18,6 +18,12 @@ internal interface IStreamData string? AudioCodec { get; } + string? AudioLanguageCode { get; } + + string? AudioLanguageName { get; } + + bool? IsAudioLanguageDefault { get; } + string? VideoCodec { get; } string? VideoQualityLabel { get; } diff --git a/YoutubeExplode/Bridge/PlayerResponse.cs b/YoutubeExplode/Bridge/PlayerResponse.cs index 8462bbb8..caaa1948 100644 --- a/YoutubeExplode/Bridge/PlayerResponse.cs +++ b/YoutubeExplode/Bridge/PlayerResponse.cs @@ -246,6 +246,28 @@ public class StreamData(JsonElement content) : IStreamData public string? AudioCodec => IsAudioOnly ? Codecs : Codecs?.SubstringAfter(", ").NullIfWhiteSpace(); + [Lazy] + public string? AudioLanguageCode => + content + .GetPropertyOrNull("audioTrack") + ?.GetPropertyOrNull("id") + ?.GetStringOrNull() + ?.SubstringUntil("."); + + [Lazy] + public string? AudioLanguageName => + content + .GetPropertyOrNull("audioTrack") + ?.GetPropertyOrNull("displayName") + ?.GetStringOrNull(); + + [Lazy] + public bool? IsAudioLanguageDefault => + content + .GetPropertyOrNull("audioTrack") + ?.GetPropertyOrNull("audioIsDefault") + ?.GetBooleanOrNull(); + [Lazy] public string? VideoCodec { diff --git a/YoutubeExplode/Videos/ClosedCaptions/Language.cs b/YoutubeExplode/Common/Language.cs similarity index 94% rename from YoutubeExplode/Videos/ClosedCaptions/Language.cs rename to YoutubeExplode/Common/Language.cs index 4d25af38..d7b552ec 100644 --- a/YoutubeExplode/Videos/ClosedCaptions/Language.cs +++ b/YoutubeExplode/Common/Language.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; +// TODO: breaking change: update the namespace +// ReSharper disable once CheckNamespace namespace YoutubeExplode.Videos.ClosedCaptions; /// diff --git a/YoutubeExplode/Utils/Extensions/JsonExtensions.cs b/YoutubeExplode/Utils/Extensions/JsonExtensions.cs index 70628a7b..501707f8 100644 --- a/YoutubeExplode/Utils/Extensions/JsonExtensions.cs +++ b/YoutubeExplode/Utils/Extensions/JsonExtensions.cs @@ -25,6 +25,14 @@ internal static class JsonExtensions return null; } + public static bool? GetBooleanOrNull(this JsonElement element) => + element.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, + }; + public static string? GetStringOrNull(this JsonElement element) => element.ValueKind == JsonValueKind.String ? element.GetString() : null; diff --git a/YoutubeExplode/Videos/Streams/AudioOnlyStreamInfo.cs b/YoutubeExplode/Videos/Streams/AudioOnlyStreamInfo.cs index 59cb8e55..26ec59f5 100644 --- a/YoutubeExplode/Videos/Streams/AudioOnlyStreamInfo.cs +++ b/YoutubeExplode/Videos/Streams/AudioOnlyStreamInfo.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using YoutubeExplode.Videos.ClosedCaptions; namespace YoutubeExplode.Videos.Streams; @@ -10,7 +11,9 @@ public class AudioOnlyStreamInfo( Container container, FileSize size, Bitrate bitrate, - string audioCodec + string audioCodec, + Language? audioLanguage, + bool? isAudioLanguageDefault ) : IAudioStreamInfo { /// @@ -28,7 +31,16 @@ string audioCodec /// public string AudioCodec { get; } = audioCodec; + /// + public Language? AudioLanguage { get; } = audioLanguage; + + /// + public bool? IsAudioLanguageDefault { get; } = isAudioLanguageDefault; + /// [ExcludeFromCodeCoverage] - public override string ToString() => $"Audio-only ({Container})"; + public override string ToString() => + AudioLanguage is not null + ? $"Audio-only ({Container} | {AudioLanguage})" + : $"Audio-only ({Container})"; } diff --git a/YoutubeExplode/Videos/Streams/IAudioStreamInfo.cs b/YoutubeExplode/Videos/Streams/IAudioStreamInfo.cs index 9de2853a..e8103f84 100644 --- a/YoutubeExplode/Videos/Streams/IAudioStreamInfo.cs +++ b/YoutubeExplode/Videos/Streams/IAudioStreamInfo.cs @@ -1,3 +1,5 @@ +using YoutubeExplode.Videos.ClosedCaptions; + namespace YoutubeExplode.Videos.Streams; /// @@ -9,4 +11,20 @@ public interface IAudioStreamInfo : IStreamInfo /// Audio codec. /// string AudioCodec { get; } + + /// + /// Audio language. + /// + /// + /// May be null if the audio stream does not contain language information. + /// + Language? AudioLanguage { get; } + + /// + /// Whether the audio stream's language corresponds to the default language of the video. + /// + /// + /// May be null if the audio stream does not contain language information. + /// + bool? IsAudioLanguageDefault { get; } } diff --git a/YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs b/YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs index 0983038b..88258441 100644 --- a/YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs +++ b/YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using YoutubeExplode.Common; +using YoutubeExplode.Videos.ClosedCaptions; namespace YoutubeExplode.Videos.Streams; @@ -12,6 +13,8 @@ public class MuxedStreamInfo( FileSize size, Bitrate bitrate, string audioCodec, + Language? audioLanguage, + bool? isAudioLanguageDefault, string videoCodec, VideoQuality videoQuality, Resolution videoResolution @@ -32,6 +35,12 @@ Resolution videoResolution /// public string AudioCodec { get; } = audioCodec; + /// + public Language? AudioLanguage { get; } = audioLanguage; + + /// + public bool? IsAudioLanguageDefault { get; } = isAudioLanguageDefault; + /// public string VideoCodec { get; } = videoCodec; diff --git a/YoutubeExplode/Videos/Streams/StreamClient.cs b/YoutubeExplode/Videos/Streams/StreamClient.cs index d6db8c58..aeb3d66c 100644 --- a/YoutubeExplode/Videos/Streams/StreamClient.cs +++ b/YoutubeExplode/Videos/Streams/StreamClient.cs @@ -13,6 +13,7 @@ using YoutubeExplode.Exceptions; using YoutubeExplode.Utils; using YoutubeExplode.Utils.Extensions; +using YoutubeExplode.Videos.ClosedCaptions; namespace YoutubeExplode.Videos.Streams; @@ -123,6 +124,13 @@ private async IAsyncEnumerable GetStreamInfosAsync( streamData.Bitrate?.Pipe(s => new Bitrate(s)) ?? throw new YoutubeExplodeException("Failed to extract the stream bitrate."); + var audioLanguage = !string.IsNullOrWhiteSpace(streamData.AudioLanguageCode) + ? new Language( + streamData.AudioLanguageCode, + streamData.AudioLanguageName ?? streamData.AudioLanguageCode + ) + : (Language?)null; + // Muxed or video-only stream if (!string.IsNullOrWhiteSpace(streamData.VideoCodec)) { @@ -146,6 +154,8 @@ streamData.VideoWidth is not null && streamData.VideoHeight is not null new FileSize(contentLength.Value), bitrate, streamData.AudioCodec, + audioLanguage, + streamData.IsAudioLanguageDefault, streamData.VideoCodec, videoQuality, videoResolution @@ -177,7 +187,9 @@ streamData.VideoWidth is not null && streamData.VideoHeight is not null container, new FileSize(contentLength.Value), bitrate, - streamData.AudioCodec + streamData.AudioCodec, + audioLanguage, + streamData.IsAudioLanguageDefault ); yield return streamInfo;