Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Identify audio stream languages #847

Merged
merged 7 commits into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions YoutubeExplode.Converter.Tests/GeneralSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down
33 changes: 29 additions & 4 deletions YoutubeExplode.Converter/Converter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
}
}
}
Expand Down
56 changes: 56 additions & 0 deletions YoutubeExplode.Tests/StreamSpecs.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Buffers;
using System.IO;
using System.Linq;
Expand Down Expand Up @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions YoutubeExplode.Tests/TestData/VideoIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
6 changes: 6 additions & 0 deletions YoutubeExplode/Bridge/DashManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
6 changes: 6 additions & 0 deletions YoutubeExplode/Bridge/IStreamData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
22 changes: 22 additions & 0 deletions YoutubeExplode/Bridge/PlayerResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Diagnostics.CodeAnalysis;

// TODO: breaking change: update the namespace
// ReSharper disable once CheckNamespace
namespace YoutubeExplode.Videos.ClosedCaptions;

/// <summary>
Expand Down
8 changes: 8 additions & 0 deletions YoutubeExplode/Utils/Extensions/JsonExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
16 changes: 14 additions & 2 deletions YoutubeExplode/Videos/Streams/AudioOnlyStreamInfo.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using YoutubeExplode.Videos.ClosedCaptions;

namespace YoutubeExplode.Videos.Streams;

Expand All @@ -10,7 +11,9 @@ public class AudioOnlyStreamInfo(
Container container,
FileSize size,
Bitrate bitrate,
string audioCodec
string audioCodec,
Language? audioLanguage,
bool? isAudioLanguageDefault
) : IAudioStreamInfo
{
/// <inheritdoc />
Expand All @@ -28,7 +31,16 @@ string audioCodec
/// <inheritdoc />
public string AudioCodec { get; } = audioCodec;

/// <inheritdoc />
public Language? AudioLanguage { get; } = audioLanguage;

/// <inheritdoc />
public bool? IsAudioLanguageDefault { get; } = isAudioLanguageDefault;

/// <inheritdoc />
[ExcludeFromCodeCoverage]
public override string ToString() => $"Audio-only ({Container})";
public override string ToString() =>
AudioLanguage is not null
? $"Audio-only ({Container} | {AudioLanguage})"
: $"Audio-only ({Container})";
}
18 changes: 18 additions & 0 deletions YoutubeExplode/Videos/Streams/IAudioStreamInfo.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using YoutubeExplode.Videos.ClosedCaptions;

namespace YoutubeExplode.Videos.Streams;

/// <summary>
Expand All @@ -9,4 +11,20 @@ public interface IAudioStreamInfo : IStreamInfo
/// Audio codec.
/// </summary>
string AudioCodec { get; }

/// <summary>
/// Audio language.
/// </summary>
/// <remarks>
/// May be null if the audio stream does not contain language information.
/// </remarks>
Language? AudioLanguage { get; }

/// <summary>
/// Whether the audio stream's language corresponds to the default language of the video.
/// </summary>
/// <remarks>
/// May be null if the audio stream does not contain language information.
/// </remarks>
bool? IsAudioLanguageDefault { get; }
}
9 changes: 9 additions & 0 deletions YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using YoutubeExplode.Common;
using YoutubeExplode.Videos.ClosedCaptions;

namespace YoutubeExplode.Videos.Streams;

Expand All @@ -12,6 +13,8 @@ public class MuxedStreamInfo(
FileSize size,
Bitrate bitrate,
string audioCodec,
Language? audioLanguage,
bool? isAudioLanguageDefault,
string videoCodec,
VideoQuality videoQuality,
Resolution videoResolution
Expand All @@ -32,6 +35,12 @@ Resolution videoResolution
/// <inheritdoc />
public string AudioCodec { get; } = audioCodec;

/// <inheritdoc />
public Language? AudioLanguage { get; } = audioLanguage;

/// <inheritdoc />
public bool? IsAudioLanguageDefault { get; } = isAudioLanguageDefault;

/// <inheritdoc />
public string VideoCodec { get; } = videoCodec;

Expand Down
14 changes: 13 additions & 1 deletion YoutubeExplode/Videos/Streams/StreamClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using YoutubeExplode.Exceptions;
using YoutubeExplode.Utils;
using YoutubeExplode.Utils.Extensions;
using YoutubeExplode.Videos.ClosedCaptions;

namespace YoutubeExplode.Videos.Streams;

Expand Down Expand Up @@ -123,6 +124,13 @@ private async IAsyncEnumerable<IStreamInfo> 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))
{
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down