diff --git a/.gitignore b/.gitignore index 26d5010f..ef8150c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,12 @@ # User-specific files +.vs/ +.idea/ *.suo *.user -*.userosscache -*.sln.docstates # Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -[Xx]64/ -[Xx]86/ -[Bb]uild/ -bld/ -[Bb]in/ -[Oo]bj/ - -# Coverage -*.opencover.xml +bin/ +obj/ -#Visual studio cache/options directory -.vs/ +# Test results +TestResults/ \ No newline at end of file diff --git a/Changelog.md b/Changelog.md index a5499de0..496ca61b 100644 --- a/Changelog.md +++ b/Changelog.md @@ -10,6 +10,14 @@ https://github.com/Etherna/YoutubeExplode/releases # Changelog +> **Important**: +> This changelog is no longer maintained and will be removed in the future. +> Going forward, new versions of this package will have the corresponding release notes published on [GitHub Releases](https://github.com/Tyrrrz/YoutubeExplode/releases). + +## v6.3.8 (23-Nov-2023) + +- [Converter] Fixed an issue where downloading subtitled videos with `VideoClient.DownloadAsync(...)` crashed with an exception for certain language codes. + ## v6.3.7 (09-Nov-2023) - [Converter] Fixed an issue where subtitles embedded via `VideoClient.DownloadAsync(...)` had their languages specified using ISO 639-1 codes instead of ISO 639-2 codes. @@ -573,4 +581,4 @@ Thanks to [@d4n3436](https://github.com/d4n3436), [@Benjamin K.](https://github. - Fixed other issues that prevented the library from being usable due to YouTube changes. - Added dependency on `Newtonsoft.Json` and `AngleSharp`. -This version has a lot of breaking changes and the migration isn't very straightforward. The readme has been updated with new usage examples and demo projects have been changed to work with new API. \ No newline at end of file +This version has a lot of breaking changes and the migration isn't very straightforward. The readme has been updated with new usage examples and demo projects have been changed to work with new API. diff --git a/LICENSE b/LICENSE index 6003f645..a53ed379 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License YoutubeExplode -Copyright (C) 2016-2023 Oleksii Holub +Copyright (c) 2016-2024 Oleksii Holub Etherna YoutubeDownloader fork Copyright (C) 2023-present Etherna SA diff --git a/Readme.md b/Readme.md index 96c04a03..19b07a2f 100644 --- a/Readme.md +++ b/Readme.md @@ -28,8 +28,12 @@ For questions or problems please write an email to [info@etherna.io](mailto:info **YoutubeExplode** is a library that provides an interface to query metadata of YouTube videos, playlists and channels, as well as to resolve and download video streams and closed caption tracks. Behind a layer of abstraction, this library works by scraping raw page data and exploiting reverse-engineered internal endpoints. -> 📝 Want to learn more about how YouTube works under the hood? -> [Read this article](https://tyrrrz.me/blog/reverse-engineering-youtube-revisited). +> 📝 Interested in the inner workings of this library? +> See the [Reverse-Engineering YouTube](https://tyrrrz.me/blog/reverse-engineering-youtube-revisited) article. + +**Extension packages**: + +- [YoutubeExplode.Converter](YoutubeExplode.Converter) — provides an interface to download and convert videos using FFmpeg ## Install @@ -124,7 +128,7 @@ await youtube.Videos.Streams.DownloadAsync(streamInfo, $"video.{streamInfo.Conta > **Warning**: > While the `Url` property in the stream metadata can be used to access the underlying content, you need a series of carefully crafted HTTP requests in order to do so. -> It's highly recommended to use `Videos.Streams.GetAsync(...)` or `Videos.Streams.DownloadAsync(...)` instead, as they will all the heavy lifting for you. +> It's highly recommended to use `Videos.Streams.GetAsync(...)` or `Videos.Streams.DownloadAsync(...)` instead, as they will perform all the heavy lifting for you. #### Downloading closed captions @@ -239,6 +243,10 @@ await foreach (var batch in youtube.Playlists.GetVideoBatchesAsync(playlistUrl)) } ``` +> **Note**: +> You can craft playlist IDs to fetch special auto-generated playlists, such as music mixes, popular channel uploads, liked videos, and more. +> See [this reference](https://wiki.archiveteam.org/index.php/YouTube/Technical_details#Playlists) for more information. + ### Channels #### Retrieving channel metadata @@ -410,9 +418,4 @@ In order to actually perform the authentication, you can use an embedded browser ## Etymology -The "Explode" in **YoutubeExplode** comes from the name of a PHP function that splits up strings, [`explode(...)`](https://php.net/manual/en/function.explode.php). When I was starting the development of this library, most of the reference source code I read was written in PHP, hence the inspiration for the name. - -## Related projects - -- [**YoutubeExplode.Converter**](YoutubeExplode.Converter) — provides capabilities for downloading YouTube videos with conversion to other formats, using FFmpeg. -- [**YoutubeDownloader**](https://github.com/Tyrrrz/YoutubeDownloader) — desktop application for downloading YouTube videos, based on **YoutubeExplode**. \ No newline at end of file +The "Explode" in **YoutubeExplode** comes from the name of a PHP function that splits up strings, [`explode(...)`](https://php.net/manual/en/function.explode.php). When I was starting the development of this library, most of the reference source code I read was written in PHP, hence the inspiration for the name. \ No newline at end of file diff --git a/YoutubeDownloader.Converter.Tests/GeneralSpecs.cs b/YoutubeDownloader.Converter.Tests/GeneralSpecs.cs index d67d3cc1..7657193f 100644 --- a/YoutubeDownloader.Converter.Tests/GeneralSpecs.cs +++ b/YoutubeDownloader.Converter.Tests/GeneralSpecs.cs @@ -12,12 +12,8 @@ namespace YoutubeExplode.Converter.Tests; -public class GeneralSpecs : IAsyncLifetime +public class GeneralSpecs(ITestOutputHelper testOutput) : IAsyncLifetime { - private readonly ITestOutputHelper _testOutput; - - public GeneralSpecs(ITestOutputHelper testOutput) => _testOutput = testOutput; - public async Task InitializeAsync() => await FFmpeg.InitializeAsync(); public Task DisposeAsync() => Task.CompletedTask; @@ -113,12 +109,10 @@ public async Task I_can_download_a_video_as_a_single_mp4_file_with_multiple_stre .Take(3) .ToArray(); - await youtube - .Videos - .DownloadAsync( - videoStreamInfos.Concat(audioStreamInfos).ToArray(), - new ConversionRequestBuilder(filePath).Build() - ); + await youtube.Videos.DownloadAsync( + videoStreamInfos.Concat(audioStreamInfos).ToArray(), + new ConversionRequestBuilder(filePath).Build() + ); // Assert MediaFormat.IsMp4File(filePath).Should().BeTrue(); @@ -159,12 +153,10 @@ public async Task I_can_download_a_video_as_a_single_webm_file_with_multiple_str .Take(3) .ToArray(); - await youtube - .Videos - .DownloadAsync( - videoStreamInfos.Concat(audioStreamInfos).ToArray(), - new ConversionRequestBuilder(filePath).Build() - ); + await youtube.Videos.DownloadAsync( + videoStreamInfos.Concat(audioStreamInfos).ToArray(), + new ConversionRequestBuilder(filePath).Build() + ); // Assert MediaFormat.IsWebMFile(filePath).Should().BeTrue(); @@ -188,21 +180,46 @@ public async Task I_can_download_a_video_using_custom_conversion_settings() var filePath = Path.Combine(dir.Path, "video.mp3"); // Act - await youtube - .Videos - .DownloadAsync( - "9bZkp7q19f0", - filePath, - o => - o.SetFFmpegPath(FFmpeg.FilePath) - .SetContainer("mp4") - .SetPreset(ConversionPreset.UltraFast) - ); + await youtube.Videos.DownloadAsync( + "9bZkp7q19f0", + filePath, + o => + o.SetFFmpegPath(FFmpeg.FilePath) + .SetContainer("mp4") + .SetPreset(ConversionPreset.UltraFast) + ); // Assert MediaFormat.IsMp4File(filePath).Should().BeTrue(); } + [Fact] + public async Task I_can_try_to_download_a_video_and_get_an_error_if_the_conversion_settings_are_invalid() + { + // Arrange + var youtube = new YoutubeClient(); + + using var dir = TempDir.Create(); + var filePath = Path.Combine(dir.Path, "video.mp4"); + + // Act & assert + var ex = await Assert.ThrowsAnyAsync( + async () => + await youtube.Videos.DownloadAsync( + "9bZkp7q19f0", + filePath, + o => + o.SetFFmpegPath(FFmpeg.FilePath) + .SetContainer("invalid_format") + .SetPreset(ConversionPreset.UltraFast) + ) + ); + + Directory.EnumerateFiles(dir.Path, "*", SearchOption.AllDirectories).Should().BeEmpty(); + + testOutput.WriteLine(ex.ToString()); + } + [Fact] public async Task I_can_download_a_video_while_tracking_progress() { @@ -224,6 +241,6 @@ public async Task I_can_download_a_video_while_tracking_progress() progressValues.Should().NotContain(p => p < 0 || p > 1); foreach (var value in progressValues) - _testOutput.WriteLine($"Progress: {value:P2}"); + testOutput.WriteLine($"Progress: {value:P2}"); } } diff --git a/YoutubeDownloader.Converter.Tests/SubtitleSpecs.cs b/YoutubeDownloader.Converter.Tests/SubtitleSpecs.cs index 845dd628..d93d4031 100644 --- a/YoutubeDownloader.Converter.Tests/SubtitleSpecs.cs +++ b/YoutubeDownloader.Converter.Tests/SubtitleSpecs.cs @@ -24,7 +24,7 @@ public async Task I_can_download_a_video_as_a_single_mp4_file_with_subtitles() using var dir = TempDir.Create(); var filePath = Path.Combine(dir.Path, "video.mp4"); - var streamManifest = await youtube.Videos.Streams.GetManifestAsync("YltHGKX80Y8"); + var streamManifest = await youtube.Videos.Streams.GetManifestAsync("NtQkz0aRDe8"); var streamInfos = streamManifest .GetVideoStreams() .Where(s => s.Container == Container.Mp4) @@ -32,13 +32,15 @@ public async Task I_can_download_a_video_as_a_single_mp4_file_with_subtitles() .Take(1) .ToArray(); - var trackManifest = await youtube.Videos.ClosedCaptions.GetManifestAsync("YltHGKX80Y8"); + var trackManifest = await youtube.Videos.ClosedCaptions.GetManifestAsync("NtQkz0aRDe8"); var trackInfos = trackManifest.Tracks; // Act - await youtube - .Videos - .DownloadAsync(streamInfos, trackInfos, new ConversionRequestBuilder(filePath).Build()); + await youtube.Videos.DownloadAsync( + streamInfos, + trackInfos, + new ConversionRequestBuilder(filePath).Build() + ); // Assert MediaFormat.IsMp4File(filePath).Should().BeTrue(); @@ -61,7 +63,7 @@ public async Task I_can_download_a_video_as_a_single_webm_file_with_subtitles() using var dir = TempDir.Create(); var filePath = Path.Combine(dir.Path, "video.webm"); - var streamManifest = await youtube.Videos.Streams.GetManifestAsync("YltHGKX80Y8"); + var streamManifest = await youtube.Videos.Streams.GetManifestAsync("NtQkz0aRDe8"); var streamInfos = streamManifest .GetVideoStreams() .Where(s => s.Container == Container.WebM) @@ -69,13 +71,15 @@ public async Task I_can_download_a_video_as_a_single_webm_file_with_subtitles() .Take(1) .ToArray(); - var trackManifest = await youtube.Videos.ClosedCaptions.GetManifestAsync("YltHGKX80Y8"); + var trackManifest = await youtube.Videos.ClosedCaptions.GetManifestAsync("NtQkz0aRDe8"); var trackInfos = trackManifest.Tracks; // Act - await youtube - .Videos - .DownloadAsync(streamInfos, trackInfos, new ConversionRequestBuilder(filePath).Build()); + await youtube.Videos.DownloadAsync( + streamInfos, + trackInfos, + new ConversionRequestBuilder(filePath).Build() + ); // Assert MediaFormat.IsWebMFile(filePath).Should().BeTrue(); diff --git a/YoutubeDownloader.Converter.Tests/Utils/FFmpeg.cs b/YoutubeDownloader.Converter.Tests/Utils/FFmpeg.cs index 628bad0d..f42dd5bf 100644 --- a/YoutubeDownloader.Converter.Tests/Utils/FFmpeg.cs +++ b/YoutubeDownloader.Converter.Tests/Utils/FFmpeg.cs @@ -16,7 +16,7 @@ public static class FFmpeg { private static readonly SemaphoreSlim Lock = new(1, 1); - public static Version Version { get; } = new(6, 0); + public static Version Version { get; } = new(6, 1); private static string FileName { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ffmpeg.exe" : "ffmpeg"; @@ -71,28 +71,28 @@ static string GetHashString() if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { if (RuntimeInformation.ProcessArchitecture == Architecture.X64) - return "29289b1008a8fadbb012e7dc0e325fea9eebbe87ac2019a4fa7df7fc15af02d0"; + return "48130a80aebffb61d06913350c3ad3187efd85096f898045fd65001bf89d7d7f"; if (RuntimeInformation.ProcessArchitecture == Architecture.X86) - return "edc8c9bda8a10e138386cd9b6953127906bde0f89d2b872cf8e9046d3c559b28"; + return "71e83e4d5b4ed8e9e5b13a8bc118b73affef2ff12f9e14c388bfb17db7008f8d"; if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) - return "dfd42f47c47559ccb594965f897530bb9daa62d4ce6883c3f4082b7d037832d1"; + return "cd2d765565d1cc36e3fc0653d8ad6444c1736b883144de885c1f178a404c977c"; } if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { if (RuntimeInformation.ProcessArchitecture == Architecture.X64) - return "0b7808c8f93a3235efc2448c33086e8ce10295999bd93a40b060fbe7f2e92338"; + return "856b4f0e5cd9de45c98b703f7258d578bbdc0ac818073a645315241f9e7d5780"; } if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { if (RuntimeInformation.ProcessArchitecture == Architecture.X64) - return "7898153f5785a739b1314ef3fb9c511be26bc7879d972c301a170e6ab8027652"; + return "1671abe5dcc0b4adfaea6f2e531e377a3ccd8ea21aa2b5a0589b0e5ae7d85a37"; if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) - return "a26adea0b56376df8c46118c15ae478ba02e9ac57097f569a32100760cea1cd2"; + return "bcbc7de089f68c3565dd40e8fe462df28a181af8df756621fc4004a747b845cf"; } throw new NotSupportedException("Unsupported architecture."); diff --git a/YoutubeDownloader.Converter.Tests/Utils/TempDir.cs b/YoutubeDownloader.Converter.Tests/Utils/TempDir.cs index 724555d8..8fcf7ad8 100644 --- a/YoutubeDownloader.Converter.Tests/Utils/TempDir.cs +++ b/YoutubeDownloader.Converter.Tests/Utils/TempDir.cs @@ -5,11 +5,9 @@ namespace YoutubeExplode.Converter.Tests.Utils; -internal partial class TempDir : IDisposable +internal partial class TempDir(string path) : IDisposable { - public string Path { get; } - - public TempDir(string path) => Path = path; + public string Path { get; } = path; public void Dispose() { diff --git a/YoutubeDownloader.Converter.Tests/Utils/TempFile.cs b/YoutubeDownloader.Converter.Tests/Utils/TempFile.cs index c125501d..a62e7446 100644 --- a/YoutubeDownloader.Converter.Tests/Utils/TempFile.cs +++ b/YoutubeDownloader.Converter.Tests/Utils/TempFile.cs @@ -5,11 +5,9 @@ namespace YoutubeExplode.Converter.Tests.Utils; -internal partial class TempFile : IDisposable +internal partial class TempFile(string path) : IDisposable { - public string Path { get; } - - public TempFile(string path) => Path = path; + public string Path { get; } = path; public void Dispose() { diff --git a/YoutubeDownloader.Converter.Tests/YoutubeDownloader.Converter.Tests.csproj b/YoutubeDownloader.Converter.Tests/YoutubeDownloader.Converter.Tests.csproj index a08c49fb..45ff5067 100644 --- a/YoutubeDownloader.Converter.Tests/YoutubeDownloader.Converter.Tests.csproj +++ b/YoutubeDownloader.Converter.Tests/YoutubeDownloader.Converter.Tests.csproj @@ -1,7 +1,7 @@  - net7.0 + net8.0 @@ -10,13 +10,13 @@ - + - - - + + + diff --git a/YoutubeDownloader.Converter/ConversionFormat.cs b/YoutubeDownloader.Converter/ConversionFormat.cs index c911bfa3..4f4fbebd 100644 --- a/YoutubeDownloader.Converter/ConversionFormat.cs +++ b/YoutubeDownloader.Converter/ConversionFormat.cs @@ -8,23 +8,18 @@ namespace YoutubeExplode.Converter; /// Encapsulates conversion media format. /// [Obsolete("Use YoutubeExplode.Videos.Streams.Container instead"), ExcludeFromCodeCoverage] -public readonly struct ConversionFormat +public readonly struct ConversionFormat(string name) { /// /// Format name. /// - public string Name { get; } + public string Name { get; } = name; /// /// Whether this format is a known audio-only format. /// public bool IsAudioOnly => new Container(Name).IsAudioOnly(); - /// - /// Initializes an instance of . - /// - public ConversionFormat(string name) => Name = name; - /// public override string ToString() => Name; } diff --git a/YoutubeDownloader.Converter/ConversionRequest.cs b/YoutubeDownloader.Converter/ConversionRequest.cs index 876c6b4e..3fcb9f9e 100644 --- a/YoutubeDownloader.Converter/ConversionRequest.cs +++ b/YoutubeDownloader.Converter/ConversionRequest.cs @@ -7,22 +7,39 @@ namespace YoutubeExplode.Converter; /// /// Conversion options. /// -public class ConversionRequest +public class ConversionRequest( + string ffmpegCliFilePath, + string outputFilePath, + Container container, + ConversionPreset preset +) { + /// + /// Initializes an instance of . + /// + [Obsolete("Use the other constructor overload"), ExcludeFromCodeCoverage] + public ConversionRequest( + string ffmpegCliFilePath, + string outputFilePath, + ConversionFormat format, + ConversionPreset preset + ) + : this(ffmpegCliFilePath, outputFilePath, new Container(format.Name), preset) { } + /// /// Path to the FFmpeg CLI. /// - public string FFmpegCliFilePath { get; } + public string FFmpegCliFilePath { get; } = ffmpegCliFilePath; /// /// Output file path. /// - public string OutputFilePath { get; } + public string OutputFilePath { get; } = outputFilePath; /// /// Output container. /// - public Container Container { get; } + public Container Container { get; } = container; /// /// Output format. @@ -33,33 +50,5 @@ public class ConversionRequest /// /// Encoder preset. /// - public ConversionPreset Preset { get; } - - /// - /// Initializes an instance of . - /// - public ConversionRequest( - string ffmpegCliFilePath, - string outputFilePath, - Container container, - ConversionPreset preset - ) - { - FFmpegCliFilePath = ffmpegCliFilePath; - OutputFilePath = outputFilePath; - Container = container; - Preset = preset; - } - - /// - /// Initializes an instance of . - /// - [Obsolete("Use the other constructor overload"), ExcludeFromCodeCoverage] - public ConversionRequest( - string ffmpegCliFilePath, - string outputFilePath, - ConversionFormat format, - ConversionPreset preset - ) - : this(ffmpegCliFilePath, outputFilePath, new Container(format.Name), preset) { } + public ConversionPreset Preset { get; } = preset; } diff --git a/YoutubeDownloader.Converter/ConversionRequestBuilder.cs b/YoutubeDownloader.Converter/ConversionRequestBuilder.cs index f6a7c7ed..266c7fb4 100644 --- a/YoutubeDownloader.Converter/ConversionRequestBuilder.cs +++ b/YoutubeDownloader.Converter/ConversionRequestBuilder.cs @@ -9,21 +9,14 @@ namespace YoutubeExplode.Converter; /// /// Builder for . /// -public class ConversionRequestBuilder +public class ConversionRequestBuilder(string outputFilePath) { - private readonly string _outputFilePath; - private string? _ffmpegCliFilePath; private Container? _container; private ConversionPreset _preset; - /// - /// Initializes an instance of . - /// - public ConversionRequestBuilder(string outputFilePath) => _outputFilePath = outputFilePath; - private Container GetDefaultContainer() => - new(Path.GetExtension(_outputFilePath).TrimStart('.').NullIfWhiteSpace() ?? "mp4"); + new(Path.GetExtension(outputFilePath).TrimStart('.').NullIfWhiteSpace() ?? "mp4"); /// /// Sets the path to the FFmpeg CLI. @@ -77,7 +70,7 @@ public ConversionRequestBuilder SetPreset(ConversionPreset preset) public ConversionRequest Build() => new( _ffmpegCliFilePath ?? FFmpeg.GetFilePath(), - _outputFilePath, + outputFilePath, _container ?? GetDefaultContainer(), _preset ); diff --git a/YoutubeDownloader.Converter/Converter.cs b/YoutubeDownloader.Converter/Converter.cs index 8b6feed2..a6b7a93c 100644 --- a/YoutubeDownloader.Converter/Converter.cs +++ b/YoutubeDownloader.Converter/Converter.cs @@ -13,19 +13,8 @@ namespace YoutubeExplode.Converter; -internal partial class Converter +internal partial class Converter(VideoClient videoClient, FFmpeg ffmpeg, ConversionPreset preset) { - private readonly VideoClient _videoClient; - private readonly FFmpeg _ffmpeg; - private readonly ConversionPreset _preset; - - public Converter(VideoClient videoClient, FFmpeg ffmpeg, ConversionPreset preset) - { - _videoClient = videoClient; - _ffmpeg = ffmpeg; - _preset = preset; - } - private async ValueTask ProcessAsync( string filePath, Container container, @@ -39,19 +28,30 @@ private async ValueTask ProcessAsync( // Stream inputs foreach (var streamInput in streamInputs) + { arguments.Add("-i").Add(streamInput.FilePath); + } // Subtitle inputs foreach (var subtitleInput in subtitleInputs) - arguments.Add("-i").Add(subtitleInput.FilePath); + { + arguments + // Fix invalid subtitle durations for each input + // https://github.com/Tyrrrz/YoutubeExplode/issues/756 + .Add("-fix_sub_duration") + .Add("-i") + .Add(subtitleInput.FilePath); + } // Explicitly specify that all inputs should be used, because by default // FFmpeg only picks one input per stream type (audio, video, subtitle). for (var i = 0; i < streamInputs.Count + subtitleInputs.Count; i++) + { arguments.Add("-map").Add(i); + } // Output format and encoding preset - arguments.Add("-f").Add(container.Name).Add("-preset").Add(_preset); + arguments.Add("-f").Add(container.Name).Add("-preset").Add(preset); // Avoid transcoding inputs that have the same container as the output { @@ -140,9 +140,15 @@ private async ValueTask ProcessAsync( // Metadata for subtitles foreach (var (subtitleInput, i) in subtitleInputs.WithIndex()) { + // 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 = + subtitleInput.Info.Language.TryGetThreeLetterCode() + ?? subtitleInput.Info.Language.Code; + arguments .Add($"-metadata:s:s:{i}") - .Add($"language={subtitleInput.Info.Language.GetThreeLetterCode()}") + .Add($"language={languageCode}") .Add($"-metadata:s:s:{i}") .Add($"title={subtitleInput.Info.Language.Name}"); } @@ -165,7 +171,7 @@ private async ValueTask ProcessAsync( // Output arguments.Add(filePath); - await _ffmpeg.ExecuteAsync(arguments.Build(), progress, cancellationToken); + await ffmpeg.ExecuteAsync(arguments.Build(), progress, cancellationToken); } private async ValueTask PopulateStreamInputsAsync( @@ -192,9 +198,12 @@ private async ValueTask PopulateStreamInputsAsync( streamInputs.Add(streamInput); - await _videoClient - .Streams - .DownloadAsync(streamInfo, streamInput.FilePath, streamProgress, cancellationToken); + await videoClient.Streams.DownloadAsync( + streamInfo, + streamInput.FilePath, + streamProgress, + cancellationToken + ); } progress?.Report(1); @@ -224,9 +233,12 @@ private async ValueTask PopulateSubtitleInputsAsync( subtitleInputs.Add(subtitleInput); - await _videoClient - .ClosedCaptions - .DownloadAsync(trackInfo, subtitleInput.FilePath, trackProgress, cancellationToken); + await videoClient.ClosedCaptions.DownloadAsync( + trackInfo, + subtitleInput.FilePath, + trackProgress, + cancellationToken + ); } progress?.Report(1); @@ -299,17 +311,11 @@ await ProcessAsync( internal partial class Converter { - private class StreamInput : IDisposable + private class StreamInput(IStreamInfo info, string filePath) : IDisposable { - public IStreamInfo Info { get; } + public IStreamInfo Info { get; } = info; - public string FilePath { get; } - - public StreamInput(IStreamInfo info, string filePath) - { - Info = info; - FilePath = filePath; - } + public string FilePath { get; } = filePath; public void Dispose() { @@ -324,17 +330,11 @@ public void Dispose() } } - private class SubtitleInput : IDisposable + private class SubtitleInput(ClosedCaptionTrackInfo info, string filePath) : IDisposable { - public ClosedCaptionTrackInfo Info { get; } + public ClosedCaptionTrackInfo Info { get; } = info; - public string FilePath { get; } - - public SubtitleInput(ClosedCaptionTrackInfo info, string filePath) - { - Info = info; - FilePath = filePath; - } + public string FilePath { get; } = filePath; public void Dispose() { diff --git a/YoutubeDownloader.Converter/FFmpeg.cs b/YoutubeDownloader.Converter/FFmpeg.cs index 50179d9a..901244e6 100644 --- a/YoutubeDownloader.Converter/FFmpeg.cs +++ b/YoutubeDownloader.Converter/FFmpeg.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using CliWrap; +using CliWrap.Exceptions; using YoutubeExplode.Converter.Utils.Extensions; namespace YoutubeExplode.Converter; @@ -14,12 +15,8 @@ namespace YoutubeExplode.Converter; // Ideally this should use named pipes and stream through stdout. // However, named pipes aren't well supported on non-Windows OS and // stdout streaming only works with some specific formats. -internal partial class FFmpeg +internal partial class FFmpeg(string filePath) { - private readonly string _filePath; - - public FFmpeg(string filePath) => _filePath = filePath; - public async ValueTask ExecuteAsync( string arguments, IProgress? progress, @@ -35,24 +32,23 @@ public async ValueTask ExecuteAsync( progress?.Pipe(CreateProgressRouter) ?? PipeTarget.Null ); - var result = await Cli.Wrap(_filePath) - .WithArguments(arguments) - .WithStandardErrorPipe(stdErrPipe) - .WithValidation(CommandResultValidation.None) - .ExecuteAsync(cancellationToken); - - if (result.ExitCode != 0) + try + { + await Cli.Wrap(filePath) + .WithArguments(arguments) + .WithStandardErrorPipe(stdErrPipe) + .ExecuteAsync(cancellationToken); + } + catch (CommandExecutionException ex) { throw new InvalidOperationException( $""" - FFmpeg exited with a non-zero exit code ({result.ExitCode}). - - Arguments: - {arguments} + FFmpeg command-line tool failed with an error. Standard error: {stdErrBuffer} - """ + """, + ex ); } } diff --git a/YoutubeDownloader.Converter/Readme.md b/YoutubeDownloader.Converter/Readme.md index 2be6ebf0..1ba7c5bf 100644 --- a/YoutubeDownloader.Converter/Readme.md +++ b/YoutubeDownloader.Converter/Readme.md @@ -10,7 +10,7 @@ This package relies on [FFmpeg](https://ffmpeg.org) under the hood. - 📦 [NuGet](https://nuget.org/packages/YoutubeExplode.Converter): `dotnet add package YoutubeExplode.Converter` -> **Warning**: +> **Important**: > This package requires the [FFmpeg CLI](https://ffmpeg.org) to work, which can be downloaded [here](https://ffbinaries.com/downloads). > Ensure that it's located in your application's probe directory or on the system's `PATH`, or provide a custom location yourself using one of the available method overloads. diff --git a/YoutubeDownloader.Converter/Utils/Extensions/LanguageExtensions.cs b/YoutubeDownloader.Converter/Utils/Extensions/LanguageExtensions.cs index 36aa4489..ed141fd0 100644 --- a/YoutubeDownloader.Converter/Utils/Extensions/LanguageExtensions.cs +++ b/YoutubeDownloader.Converter/Utils/Extensions/LanguageExtensions.cs @@ -5,14 +5,20 @@ namespace YoutubeExplode.Converter.Utils.Extensions; internal static class LanguageExtensions { - public static string GetTwoLetterCode(this Language language) + public static string? TryGetThreeLetterCode(this Language language) { - var dashIndex = language.Code.IndexOf('-'); - return dashIndex >= 0 ? language.Code[..dashIndex] : language.Code; - } + // YouTube provides either a two-letter or a three-letter language code, + // which may or may not also contain a region identifier. + var regionNeutralLanguageCode = language.Code.SubstringUntil( + "-", + StringComparison.OrdinalIgnoreCase + ); + + // Already a three-letter code + if (regionNeutralLanguageCode.Length == 3) + return regionNeutralLanguageCode; - public static string GetThreeLetterCode(this Language language) => - language.GetTwoLetterCode().ToLowerInvariant() switch + return regionNeutralLanguageCode.ToLowerInvariant() switch { "aa" => "aar", "ab" => "abk", @@ -33,7 +39,7 @@ public static string GetThreeLetterCode(this Language language) => "bi" => "bis", "bm" => "bam", "bn" => "ben", - "bo" => "tib", + "bo" => "bod", "br" => "bre", "bs" => "bos", "ca" => "cat", @@ -44,24 +50,24 @@ public static string GetThreeLetterCode(this Language language) => "cs" => "cze", "cu" => "chu", "cv" => "chv", - "cy" => "wel", + "cy" => "cym", "da" => "dan", - "de" => "ger", + "de" => "deu", "dv" => "div", "dz" => "dzo", "ee" => "ewe", - "el" => "gre", + "el" => "ell", "en" => "eng", "eo" => "epo", "es" => "spa", "et" => "est", - "eu" => "baq", - "fa" => "per", + "eu" => "eus", + "fa" => "fas", "ff" => "ful", "fi" => "fin", "fj" => "fij", "fo" => "fao", - "fr" => "fre", + "fr" => "fra", "fy" => "fry", "ga" => "gle", "gd" => "gla", @@ -76,7 +82,7 @@ public static string GetThreeLetterCode(this Language language) => "hr" => "hrv", "ht" => "hat", "hu" => "hun", - "hy" => "arm", + "hy" => "hye", "hz" => "her", "ia" => "ina", "id" => "ind", @@ -84,13 +90,17 @@ public static string GetThreeLetterCode(this Language language) => "ig" => "ibo", "ii" => "iii", "ik" => "ipk", + "in" => "ind", "io" => "ido", - "is" => "ice", + "is" => "isl", "it" => "ita", "iu" => "iku", + "iw" => "heb", "ja" => "jpn", + "ji" => "yid", "jv" => "jav", - "ka" => "geo", + "jw" => "jav", + "ka" => "kat", "kg" => "kon", "ki" => "kik", "kj" => "kua", @@ -116,20 +126,21 @@ public static string GetThreeLetterCode(this Language language) => "lv" => "lav", "mg" => "mlg", "mh" => "mah", - "mi" => "mao", - "mk" => "mac", + "mi" => "mri", + "mk" => "mkd", "ml" => "mal", "mn" => "mon", + "mo" => "ron", "mr" => "mar", - "ms" => "may", + "ms" => "msa", "mt" => "mlt", - "my" => "bur", + "my" => "mya", "na" => "nau", "nb" => "nob", "nd" => "nde", "ne" => "nep", "ng" => "ndo", - "nl" => "dut", + "nl" => "nld", "nn" => "nno", "no" => "nor", "nr" => "nbl", @@ -148,7 +159,7 @@ public static string GetThreeLetterCode(this Language language) => "qu" => "que", "rm" => "roh", "rn" => "run", - "ro" => "rum", + "ro" => "ron", "ru" => "rus", "rw" => "kin", "sa" => "san", @@ -156,13 +167,14 @@ public static string GetThreeLetterCode(this Language language) => "sd" => "snd", "se" => "sme", "sg" => "sag", + "sh" => "hbs", "si" => "sin", "sk" => "slo", "sl" => "slv", "sm" => "smo", "sn" => "sna", "so" => "som", - "sq" => "alb", + "sq" => "sqi", "sr" => "srp", "ss" => "ssw", "st" => "sot", @@ -196,8 +208,9 @@ public static string GetThreeLetterCode(this Language language) => "yi" => "yid", "yo" => "yor", "za" => "zha", - "zh" => "chi", + "zh" => "zho", "zu" => "zul", - var code => throw new InvalidOperationException($"Unrecognized language code '{code}'.") + _ => null }; + } } diff --git a/YoutubeDownloader.Converter/Utils/Extensions/StringExtensions.cs b/YoutubeDownloader.Converter/Utils/Extensions/StringExtensions.cs index 3be3a493..4a1c6252 100644 --- a/YoutubeDownloader.Converter/Utils/Extensions/StringExtensions.cs +++ b/YoutubeDownloader.Converter/Utils/Extensions/StringExtensions.cs @@ -1,7 +1,19 @@ -namespace YoutubeExplode.Converter.Utils.Extensions; +using System; + +namespace YoutubeExplode.Converter.Utils.Extensions; internal static class StringExtensions { public static string? NullIfWhiteSpace(this string s) => !string.IsNullOrWhiteSpace(s) ? s : null; + + public static string SubstringUntil( + this string str, + string sub, + StringComparison comparison = StringComparison.Ordinal + ) + { + var index = str.IndexOf(sub, comparison); + return index < 0 ? str : str[..index]; + } } diff --git a/YoutubeDownloader.Converter/Utils/ProgressMuxer.cs b/YoutubeDownloader.Converter/Utils/ProgressMuxer.cs index 0c03a90c..21d1dcca 100644 --- a/YoutubeDownloader.Converter/Utils/ProgressMuxer.cs +++ b/YoutubeDownloader.Converter/Utils/ProgressMuxer.cs @@ -3,16 +3,12 @@ namespace YoutubeExplode.Converter.Utils; -internal class ProgressMuxer +internal class ProgressMuxer(IProgress target) { - private readonly IProgress _target; - private readonly object _lock = new(); private readonly Dictionary _splitWeights = new(); private readonly Dictionary _splitValues = new(); - public ProgressMuxer(IProgress target) => _target = target; - public IProgress CreateInput(double weight = 1) { lock (_lock) @@ -37,7 +33,7 @@ public IProgress CreateInput(double weight = 1) weightedMax += _splitWeights[i]; } - _target.Report(weightedSum / weightedMax); + target.Report(weightedSum / weightedMax); } }); } diff --git a/YoutubeDownloader.Converter/YoutubeDownloader.Converter.csproj b/YoutubeDownloader.Converter/YoutubeDownloader.Converter.csproj index aa069b4e..dfd65b2f 100644 --- a/YoutubeDownloader.Converter/YoutubeDownloader.Converter.csproj +++ b/YoutubeDownloader.Converter/YoutubeDownloader.Converter.csproj @@ -16,13 +16,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/YoutubeDownloader.Demo.Cli/Utils/ConsoleProgress.cs b/YoutubeDownloader.Demo.Cli/Utils/ConsoleProgress.cs index 3e002854..02128e9e 100644 --- a/YoutubeDownloader.Demo.Cli/Utils/ConsoleProgress.cs +++ b/YoutubeDownloader.Demo.Cli/Utils/ConsoleProgress.cs @@ -3,21 +3,13 @@ namespace YoutubeExplode.Demo.Cli.Utils; -internal class ConsoleProgress : IProgress, IDisposable +internal class ConsoleProgress(TextWriter writer) : IProgress, IDisposable { - private readonly TextWriter _writer; - private readonly int _posX; - private readonly int _posY; + private readonly int _posX = Console.CursorLeft; + private readonly int _posY = Console.CursorTop; private int _lastLength; - public ConsoleProgress(TextWriter writer) - { - _writer = writer; - _posX = Console.CursorLeft; - _posY = Console.CursorTop; - } - public ConsoleProgress() : this(Console.Out) { } @@ -26,7 +18,7 @@ private void EraseLast() if (_lastLength > 0) { Console.SetCursorPosition(_posX, _posY); - _writer.Write(new string(' ', _lastLength)); + writer.Write(new string(' ', _lastLength)); Console.SetCursorPosition(_posX, _posY); } } @@ -34,7 +26,7 @@ private void EraseLast() private void Write(string text) { EraseLast(); - _writer.Write(text); + writer.Write(text); _lastLength = text.Length; } diff --git a/YoutubeDownloader.Demo.Cli/YoutubeDownloader.Demo.Cli.csproj b/YoutubeDownloader.Demo.Cli/YoutubeDownloader.Demo.Cli.csproj index 9c28ab56..89ed5414 100644 --- a/YoutubeDownloader.Demo.Cli/YoutubeDownloader.Demo.Cli.csproj +++ b/YoutubeDownloader.Demo.Cli/YoutubeDownloader.Demo.Cli.csproj @@ -2,11 +2,11 @@ Exe - net7.0 + net8.0 - + diff --git a/YoutubeDownloader.Demo.Gui/ViewModels/Framework/AsyncRelayCommand.cs b/YoutubeDownloader.Demo.Gui/ViewModels/Framework/AsyncRelayCommand.cs index 1760914e..fbc45cd0 100644 --- a/YoutubeDownloader.Demo.Gui/ViewModels/Framework/AsyncRelayCommand.cs +++ b/YoutubeDownloader.Demo.Gui/ViewModels/Framework/AsyncRelayCommand.cs @@ -3,20 +3,16 @@ namespace YoutubeExplode.Demo.Gui.ViewModels.Framework; -public class AsyncRelayCommand : RelayCommand +public class AsyncRelayCommand(Func executeAsync, Func canExecute) + : RelayCommand(async x => await executeAsync(x), canExecute) { - public AsyncRelayCommand(Func executeAsync, Func canExecute) - : base(async x => await executeAsync(x), canExecute) { } - public AsyncRelayCommand(Func executeAsync) : this(executeAsync, _ => true) { } } -public class AsyncRelayCommand : RelayCommand +public class AsyncRelayCommand(Func executeAsync, Func canExecute) + : RelayCommand(async () => await executeAsync(), canExecute) { - public AsyncRelayCommand(Func executeAsync, Func canExecute) - : base(async () => await executeAsync(), canExecute) { } - public AsyncRelayCommand(Func executeAsync) : this(executeAsync, () => true) { } } diff --git a/YoutubeDownloader.Demo.Gui/ViewModels/Framework/RelayCommand.cs b/YoutubeDownloader.Demo.Gui/ViewModels/Framework/RelayCommand.cs index e30dcc87..bbdd0d51 100644 --- a/YoutubeDownloader.Demo.Gui/ViewModels/Framework/RelayCommand.cs +++ b/YoutubeDownloader.Demo.Gui/ViewModels/Framework/RelayCommand.cs @@ -3,50 +3,32 @@ namespace YoutubeExplode.Demo.Gui.ViewModels.Framework; -public class RelayCommand : ICommand +public class RelayCommand(Action execute, Func canExecute) : ICommand { - private readonly Action _execute; - private readonly Func _canExecute; - public event EventHandler? CanExecuteChanged; - public RelayCommand(Action execute, Func canExecute) - { - _execute = execute; - _canExecute = canExecute; - } - public RelayCommand(Action execute) : this(execute, _ => true) { } public bool CanExecute(object? parameter) => - _canExecute(parameter is not null ? (T)parameter : default!); + canExecute(parameter is not null ? (T)parameter : default!); public void Execute(object? parameter) => - _execute(parameter is not null ? (T)parameter : default!); + execute(parameter is not null ? (T)parameter : default!); public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); } -public class RelayCommand : ICommand +public class RelayCommand(Action execute, Func canExecute) : ICommand { - private readonly Action _execute; - private readonly Func _canExecute; - public event EventHandler? CanExecuteChanged; - public RelayCommand(Action execute, Func canExecute) - { - _execute = execute; - _canExecute = canExecute; - } - public RelayCommand(Action execute) : this(execute, () => true) { } - public bool CanExecute(object? parameter) => _canExecute(); + public bool CanExecute(object? parameter) => canExecute(); - public void Execute(object? parameter) => _execute(); + public void Execute(object? parameter) => execute(); public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); } diff --git a/YoutubeDownloader.Demo.Gui/ViewModels/MainViewModel.cs b/YoutubeDownloader.Demo.Gui/ViewModels/MainViewModel.cs index 5c005bb5..e5b3d487 100644 --- a/YoutubeDownloader.Demo.Gui/ViewModels/MainViewModel.cs +++ b/YoutubeDownloader.Demo.Gui/ViewModels/MainViewModel.cs @@ -316,10 +316,11 @@ private async Task DownloadClosedCaptionTrackAsync(ClosedCaptionTrackInfo trackI var progressHandler = new Progress(p => Progress = p); // Download to file - await _youtube - .Videos - .ClosedCaptions - .DownloadAsync(trackInfo, filePath, progressHandler); + await _youtube.Videos.ClosedCaptions.DownloadAsync( + trackInfo, + filePath, + progressHandler + ); } finally { diff --git a/YoutubeDownloader.Demo.Gui/YoutubeDownloader.Demo.Gui.csproj b/YoutubeDownloader.Demo.Gui/YoutubeDownloader.Demo.Gui.csproj index ab7166fc..eee35508 100644 --- a/YoutubeDownloader.Demo.Gui/YoutubeDownloader.Demo.Gui.csproj +++ b/YoutubeDownloader.Demo.Gui/YoutubeDownloader.Demo.Gui.csproj @@ -2,12 +2,12 @@ WinExe - net7.0-windows + net8.0-windows true - + diff --git a/YoutubeDownloader.Tests/ChannelHandleSpecs.cs b/YoutubeDownloader.Tests/ChannelHandleSpecs.cs index fda0fdd6..6bbd6594 100644 --- a/YoutubeDownloader.Tests/ChannelHandleSpecs.cs +++ b/YoutubeDownloader.Tests/ChannelHandleSpecs.cs @@ -43,7 +43,9 @@ string expectedChannelHandle [InlineData("youtube.com/@BeauMile$")] [InlineData("youtube.com/@Beau+Miles")] [InlineData("youtube.com/?@BeauMiles")] - public void I_cannot_parse_a_channel_handle_from_an_invalid_string(string channelHandleOrUrl) + public void I_can_try_to_parse_a_channel_handle_and_get_an_error_if_the_input_string_is_invalid( + string channelHandleOrUrl + ) { // Act & assert Assert.Throws(() => ChannelHandle.Parse(channelHandleOrUrl)); diff --git a/YoutubeDownloader.Tests/ChannelIdSpecs.cs b/YoutubeDownloader.Tests/ChannelIdSpecs.cs index 900be5e5..121385da 100644 --- a/YoutubeDownloader.Tests/ChannelIdSpecs.cs +++ b/YoutubeDownloader.Tests/ChannelIdSpecs.cs @@ -42,7 +42,9 @@ string expectedChannelId [InlineData("youtube.com/?channel=UCUC3xnGqlcL3y-GXz5N3wiTJQ")] [InlineData("youtube.com/channel/asd")] [InlineData("youtube.com/")] - public void I_cannot_parse_a_channel_ID_from_an_invalid_string(string channelIdOrUrl) + public void I_can_try_to_parse_a_channel_ID_and_get_an_error_if_the_input_string_is_invalid( + string channelIdOrUrl + ) { // Act & assert Assert.Throws(() => ChannelId.Parse(channelIdOrUrl)); diff --git a/YoutubeDownloader.Tests/ChannelSlugSpecs.cs b/YoutubeDownloader.Tests/ChannelSlugSpecs.cs index cd3b3292..178f01fd 100644 --- a/YoutubeDownloader.Tests/ChannelSlugSpecs.cs +++ b/YoutubeDownloader.Tests/ChannelSlugSpecs.cs @@ -44,7 +44,9 @@ string expectedChannelSlug [InlineData("youtube.com/?c=Tyrrrz")] [InlineData("youtube.com/channel/Tyrrrz")] [InlineData("youtube.com/")] - public void I_cannot_parse_a_channel_slug_from_an_invalid_string(string channelSlugOrUrl) + public void I_can_try_to_parse_a_channel_slug_and_get_an_error_if_the_input_string_is_invalid( + string channelSlugOrUrl + ) { // Act & assert Assert.Throws(() => ChannelSlug.Parse(channelSlugOrUrl)); diff --git a/YoutubeDownloader.Tests/ClosedCaptionSpecs.cs b/YoutubeDownloader.Tests/ClosedCaptionSpecs.cs index 5472dfd0..ca1613a5 100644 --- a/YoutubeDownloader.Tests/ClosedCaptionSpecs.cs +++ b/YoutubeDownloader.Tests/ClosedCaptionSpecs.cs @@ -17,17 +17,15 @@ public async Task I_can_get_the_list_of_available_closed_caption_tracks_on_a_vid var youtube = new YoutubeClient(); // Act - var manifest = await youtube - .Videos - .ClosedCaptions - .GetManifestAsync(VideoIds.WithClosedCaptions); + var manifest = await youtube.Videos.ClosedCaptions.GetManifestAsync( + VideoIds.WithClosedCaptions + ); // Assert manifest.Tracks.Should().HaveCountGreaterOrEqualTo(3); manifest - .Tracks - .Should() + .Tracks.Should() .Contain( t => t.Language.Code == "en" @@ -36,8 +34,7 @@ public async Task I_can_get_the_list_of_available_closed_caption_tracks_on_a_vid ); manifest - .Tracks - .Should() + .Tracks.Should() .Contain( t => t.Language.Code == "en-US" @@ -46,8 +43,7 @@ public async Task I_can_get_the_list_of_available_closed_caption_tracks_on_a_vid ); manifest - .Tracks - .Should() + .Tracks.Should() .Contain( t => t.Language.Code == "es-419" @@ -63,12 +59,11 @@ public async Task I_can_get_a_specific_closed_caption_track_from_a_video() var youtube = new YoutubeClient(); // Act - var manifest = await youtube - .Videos - .ClosedCaptions - .GetManifestAsync(VideoIds.WithClosedCaptions); - var trackInfo = manifest.GetByLanguage("en-US"); + var manifest = await youtube.Videos.ClosedCaptions.GetManifestAsync( + VideoIds.WithClosedCaptions + ); + var trackInfo = manifest.GetByLanguage("en-US"); var track = await youtube.Videos.ClosedCaptions.GetAsync(trackInfo); // Assert @@ -82,12 +77,11 @@ public async Task I_can_get_a_specific_closed_caption_track_from_a_video_that_ha var youtube = new YoutubeClient(); // Act - var manifest = await youtube - .Videos - .ClosedCaptions - .GetManifestAsync(VideoIds.WithBrokenClosedCaptions); - var trackInfo = manifest.GetByLanguage("en"); + var manifest = await youtube.Videos.ClosedCaptions.GetManifestAsync( + VideoIds.WithBrokenClosedCaptions + ); + var trackInfo = manifest.GetByLanguage("en"); var track = await youtube.Videos.ClosedCaptions.GetAsync(trackInfo); // Assert @@ -101,12 +95,11 @@ public async Task I_can_get_an_individual_closed_caption_from_a_video() var youtube = new YoutubeClient(); // Act - var manifest = await youtube - .Videos - .ClosedCaptions - .GetManifestAsync(VideoIds.WithClosedCaptions); - var trackInfo = manifest.GetByLanguage("en-US"); + var manifest = await youtube.Videos.ClosedCaptions.GetManifestAsync( + VideoIds.WithClosedCaptions + ); + var trackInfo = manifest.GetByLanguage("en-US"); var track = await youtube.Videos.ClosedCaptions.GetAsync(trackInfo); var caption = track.GetByTime(TimeSpan.FromSeconds(641)); @@ -122,12 +115,11 @@ public async Task I_can_get_an_individual_closed_caption_part_from_a_video() var youtube = new YoutubeClient(); // Act - var manifest = await youtube - .Videos - .ClosedCaptions - .GetManifestAsync(VideoIds.WithClosedCaptions); - var trackInfo = manifest.GetByLanguage("en"); + var manifest = await youtube.Videos.ClosedCaptions.GetManifestAsync( + VideoIds.WithClosedCaptions + ); + var trackInfo = manifest.GetByLanguage("en"); var track = await youtube.Videos.ClosedCaptions.GetAsync(trackInfo); var captionPart = track @@ -146,12 +138,11 @@ public async Task I_can_download_a_specific_closed_caption_track_from_a_video() var youtube = new YoutubeClient(); // Act - var manifest = await youtube - .Videos - .ClosedCaptions - .GetManifestAsync(VideoIds.WithClosedCaptions); - var trackInfo = manifest.GetByLanguage("en-US"); + var manifest = await youtube.Videos.ClosedCaptions.GetManifestAsync( + VideoIds.WithClosedCaptions + ); + var trackInfo = manifest.GetByLanguage("en-US"); await youtube.Videos.ClosedCaptions.DownloadAsync(trackInfo, file.Path); // Assert diff --git a/YoutubeDownloader.Tests/PlaylistIdSpecs.cs b/YoutubeDownloader.Tests/PlaylistIdSpecs.cs index 2e0ad2ba..3f16f6f8 100644 --- a/YoutubeDownloader.Tests/PlaylistIdSpecs.cs +++ b/YoutubeDownloader.Tests/PlaylistIdSpecs.cs @@ -70,7 +70,9 @@ string expectedPlaylistId [InlineData("PLm_3vnTS-pvmZFuF3L=Pyhqf8kTTYVKjW")] [InlineData("youtube.com/playlist?lisp=PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H")] [InlineData("youtube.com/")] - public void I_cannot_parse_a_playlist_ID_from_an_invalid_string(string playlistIdOrUrl) + public void I_can_try_to_parse_a_playlist_ID_and_get_an_error_if_the_input_string_is_invalid( + string playlistIdOrUrl + ) { // Act & assert Assert.Throws(() => PlaylistId.Parse(playlistIdOrUrl)); diff --git a/YoutubeDownloader.Tests/PlaylistSpecs.cs b/YoutubeDownloader.Tests/PlaylistSpecs.cs index ddf09afe..e411e641 100644 --- a/YoutubeDownloader.Tests/PlaylistSpecs.cs +++ b/YoutubeDownloader.Tests/PlaylistSpecs.cs @@ -9,12 +9,8 @@ namespace YoutubeExplode.Tests; -public class PlaylistSpecs +public class PlaylistSpecs(ITestOutputHelper testOutput) { - private readonly ITestOutputHelper _testOutput; - - public PlaylistSpecs(ITestOutputHelper testOutput) => _testOutput = testOutput; - [Fact] public async Task I_can_get_the_metadata_of_a_playlist() { @@ -33,14 +29,13 @@ public async Task I_can_get_the_metadata_of_a_playlist() playlist.Author?.ChannelUrl.Should().NotBeNullOrWhiteSpace(); playlist.Author?.ChannelTitle.Should().Be("Google Analytics"); playlist - .Description - .Should() + .Description.Should() .Contain("Digital Analytics Fundamentals course on Analytics Academy"); playlist.Thumbnails.Should().NotBeEmpty(); } [Fact] - public async Task I_cannot_get_the_metadata_of_a_private_playlist() + public async Task I_can_try_to_get_the_metadata_of_a_playlist_and_get_an_error_if_it_is_private() { // Arrange var youtube = new YoutubeClient(); @@ -50,11 +45,11 @@ public async Task I_cannot_get_the_metadata_of_a_private_playlist() async () => await youtube.Playlists.GetAsync(PlaylistIds.Private) ); - _testOutput.WriteLine(ex.Message); + testOutput.WriteLine(ex.ToString()); } [Fact] - public async Task I_cannot_get_the_metadata_of_a_non_existing_playlist() + public async Task I_can_try_to_get_the_metadata_of_a_playlist_and_get_an_error_if_it_does_not_exist() { // Arrange var youtube = new YoutubeClient(); @@ -64,7 +59,7 @@ public async Task I_cannot_get_the_metadata_of_a_non_existing_playlist() async () => await youtube.Playlists.GetAsync(PlaylistIds.NonExisting) ); - _testOutput.WriteLine(ex.Message); + testOutput.WriteLine(ex.ToString()); } [Theory] @@ -105,8 +100,7 @@ public async Task I_can_get_videos_included_in_a_playlist() .Select(v => v.Id.Value) .Should() .Contain( - new[] - { + [ "uPZSSdkGQhM", "fi0w57kr_jY", "xLJt5A-NeQI", @@ -128,7 +122,7 @@ public async Task I_can_get_videos_included_in_a_playlist() "8Kg-8ZjgLAQ", "E9zfpKsw6f8", "eBCw9sC5D40" - } + ] ); } @@ -147,8 +141,7 @@ public async Task I_can_get_videos_included_in_a_large_playlist() .Select(v => v.Id.Value) .Should() .Contain( - new[] - { + [ "RBumgq5yVrA", "kN0iD0pI3o0", "YqB8Dm65X18", @@ -158,7 +151,7 @@ public async Task I_can_get_videos_included_in_a_large_playlist() "x-IR7PtA7RA", "N-8E9mHxDy0", "5ly88Ju1N6A" - } + ] ); } diff --git a/YoutubeDownloader.Tests/StreamSpecs.cs b/YoutubeDownloader.Tests/StreamSpecs.cs index d9052962..36c5a555 100644 --- a/YoutubeDownloader.Tests/StreamSpecs.cs +++ b/YoutubeDownloader.Tests/StreamSpecs.cs @@ -12,29 +12,21 @@ namespace YoutubeExplode.Tests; -public class StreamSpecs +public class StreamSpecs(ITestOutputHelper testOutput) { - private readonly ITestOutputHelper _testOutput; - - public StreamSpecs(ITestOutputHelper testOutput) => _testOutput = testOutput; - [Fact] - public async Task I_can_get_the_list_of_available_streams_on_a_video() + public async Task I_can_get_the_list_of_available_streams_of_a_video() { // Arrange var youtube = new YoutubeClient(); // Act - var manifest = await youtube - .Videos - .Streams - .GetManifestAsync(VideoIds.WithHighQualityStreams); + var manifest = await youtube.Videos.Streams.GetManifestAsync( + VideoIds.WithHighQualityStreams + ); // Assert manifest.Streams.Should().NotBeEmpty(); - manifest.GetMuxedStreams().Should().NotBeEmpty(); - manifest.GetAudioStreams().Should().NotBeEmpty(); - manifest.GetVideoStreams().Should().NotBeEmpty(); manifest .GetVideoStreams() @@ -76,9 +68,10 @@ public async Task I_can_get_the_list_of_available_streams_on_a_video() [InlineData(VideoIds.AgeRestrictedSexual)] [InlineData(VideoIds.AgeRestrictedEmbedRestricted)] [InlineData(VideoIds.LiveStreamRecording)] + [InlineData(VideoIds.WithBrokenStreams)] [InlineData(VideoIds.WithOmnidirectionalStreams)] [InlineData(VideoIds.WithHighDynamicRangeStreams)] - public async Task I_can_get_the_list_of_available_streams_on_any_playable_video(string videoId) + public async Task I_can_get_the_list_of_available_streams_of_any_playable_video(string videoId) { // Arrange var youtube = new YoutubeClient(); @@ -91,7 +84,7 @@ public async Task I_can_get_the_list_of_available_streams_on_any_playable_video( } [Fact(Skip = "Preview video ID is not always available")] - public async Task I_cannot_get_the_list_of_available_streams_on_a_paid_video() + public async Task I_can_try_to_get_the_list_of_available_streams_of_a_video_and_get_an_error_if_it_is_paid() { // Arrange var youtube = new YoutubeClient(); @@ -103,11 +96,11 @@ public async Task I_cannot_get_the_list_of_available_streams_on_a_paid_video() ex.PreviewVideoId.Value.Should().NotBeNullOrWhiteSpace(); - _testOutput.WriteLine(ex.Message); + testOutput.WriteLine(ex.ToString()); } [Fact] - public async Task I_cannot_get_the_list_of_available_streams_on_a_private_video() + public async Task I_can_try_to_get_the_list_of_available_streams_of_a_video_and_get_an_error_if_it_is_private() { // Arrange var youtube = new YoutubeClient(); @@ -117,11 +110,11 @@ public async Task I_cannot_get_the_list_of_available_streams_on_a_private_video( async () => await youtube.Videos.Streams.GetManifestAsync(VideoIds.Private) ); - _testOutput.WriteLine(ex.Message); + testOutput.WriteLine(ex.ToString()); } [Fact] - public async Task I_cannot_get_the_list_of_available_streams_on_a_non_existing_video() + public async Task I_can_try_to_get_the_list_of_available_streams_of_a_video_and_get_an_error_if_it_does_not_exist() { // Arrange var youtube = new YoutubeClient(); @@ -131,7 +124,7 @@ public async Task I_cannot_get_the_list_of_available_streams_on_a_non_existing_v async () => await youtube.Videos.Streams.GetManifestAsync(VideoIds.Deleted) ); - _testOutput.WriteLine(ex.Message); + testOutput.WriteLine(ex.ToString()); } [Theory] @@ -139,8 +132,9 @@ public async Task I_cannot_get_the_list_of_available_streams_on_a_non_existing_v [InlineData(VideoIds.AgeRestrictedViolent)] [InlineData(VideoIds.AgeRestrictedSexual)] [InlineData(VideoIds.LiveStreamRecording)] + [InlineData(VideoIds.WithBrokenStreams)] [InlineData(VideoIds.WithOmnidirectionalStreams)] - public async Task I_can_get_a_specific_stream_from_a_video(string videoId) + public async Task I_can_get_a_specific_stream_of_a_video(string videoId) { // Arrange using var buffer = MemoryPool.Shared.Rent(1024); @@ -168,8 +162,9 @@ public async Task I_can_get_a_specific_stream_from_a_video(string videoId) [InlineData(VideoIds.AgeRestrictedSexual)] [InlineData(VideoIds.AgeRestrictedEmbedRestricted)] [InlineData(VideoIds.LiveStreamRecording)] + [InlineData(VideoIds.WithBrokenStreams)] [InlineData(VideoIds.WithOmnidirectionalStreams)] - public async Task I_can_download_a_specific_stream_from_a_video(string videoId) + public async Task I_can_download_a_specific_stream_of_a_video(string videoId) { // Arrange using var file = TempFile.Create(); @@ -188,7 +183,7 @@ public async Task I_can_download_a_specific_stream_from_a_video(string videoId) } [Fact] - public async Task I_can_download_the_highest_bitrate_stream_from_a_video() + public async Task I_can_download_the_highest_bitrate_stream_of_a_video() { // Arrange using var file = TempFile.Create(); @@ -207,7 +202,7 @@ public async Task I_can_download_the_highest_bitrate_stream_from_a_video() } [Fact] - public async Task I_can_download_the_highest_quality_stream_from_a_video() + public async Task I_can_download_the_highest_quality_stream_of_a_video() { // Arrange using var file = TempFile.Create(); @@ -226,7 +221,7 @@ public async Task I_can_download_the_highest_quality_stream_from_a_video() } [Fact] - public async Task I_can_seek_to_a_specific_position_on_a_stream_from_a_video() + public async Task I_can_seek_to_a_specific_position_of_a_stream_from_a_video() { // Arrange using var buffer = new MemoryStream(); @@ -245,7 +240,7 @@ public async Task I_can_seek_to_a_specific_position_on_a_stream_from_a_video() } [Fact] - public async Task I_can_get_the_HTTP_live_stream_URL_from_a_video() + public async Task I_can_get_the_HTTP_live_stream_URL_for_a_video() { // Arrange var youtube = new YoutubeClient(); @@ -258,7 +253,7 @@ public async Task I_can_get_the_HTTP_live_stream_URL_from_a_video() } [Fact] - public async Task I_cannot_get_the_HTTP_live_stream_URL_from_an_unplayable_video() + public async Task I_can_try_to_get_the_HTTP_live_stream_URL_for_a_video_and_get_an_error_if_it_is_unplayable() { // Arrange var youtube = new YoutubeClient(); @@ -269,11 +264,11 @@ public async Task I_cannot_get_the_HTTP_live_stream_URL_from_an_unplayable_video await youtube.Videos.Streams.GetHttpLiveStreamUrlAsync(VideoIds.RequiresPurchase) ); - _testOutput.WriteLine(ex.Message); + testOutput.WriteLine(ex.ToString()); } [Fact] - public async Task I_cannot_get_the_HTTP_live_stream_URL_from_a_non_live_video() + public async Task I_can_try_to_get_the_HTTP_live_stream_URL_for_a_video_and_get_an_error_if_it_is_not_live() { // Arrange var youtube = new YoutubeClient(); @@ -283,6 +278,6 @@ public async Task I_cannot_get_the_HTTP_live_stream_URL_from_a_non_live_video() async () => await youtube.Videos.Streams.GetHttpLiveStreamUrlAsync(VideoIds.Normal) ); - _testOutput.WriteLine(ex.Message); + testOutput.WriteLine(ex.ToString()); } } diff --git a/YoutubeDownloader.Tests/TestData/VideoIds.cs b/YoutubeDownloader.Tests/TestData/VideoIds.cs index bbf30715..ce9a8d74 100644 --- a/YoutubeDownloader.Tests/TestData/VideoIds.cs +++ b/YoutubeDownloader.Tests/TestData/VideoIds.cs @@ -12,9 +12,11 @@ internal static class VideoIds public const string AgeRestrictedSexual = "SkRSXFQerZs"; public const string AgeRestrictedEmbedRestricted = "hySoCSoH-g8"; public const string RequiresPurchase = "p3dDcKOFXQg"; + public const string RequiresPurchaseDistributed = "qs3NZHVM_Ik"; public const string LiveStream = "jfKfPfyJRdk"; public const string LiveStreamRecording = "rsAAeyAr-9Y"; public const string WithBrokenTitle = "4ZJWv6t-PfY"; + public const string WithBrokenStreams = "JQgKhZZyBYg"; public const string WithHighQualityStreams = "V5Fsj_sCKdg"; public const string WithOmnidirectionalStreams = "-xNN-bJQ4vI"; public const string WithHighDynamicRangeStreams = "vX2vsvdq8nw"; diff --git a/YoutubeDownloader.Tests/UserNameSpecs.cs b/YoutubeDownloader.Tests/UserNameSpecs.cs index d90dbe89..6cbffdd2 100644 --- a/YoutubeDownloader.Tests/UserNameSpecs.cs +++ b/YoutubeDownloader.Tests/UserNameSpecs.cs @@ -40,7 +40,9 @@ public void I_can_parse_a_user_name_from_a_URL_string(string userUrl, string exp [InlineData("=0123456789ABCDEF")] [InlineData("youtube.com/user/P_roZD")] [InlineData("example.com/user/ProZD")] - public void I_cannot_parse_a_user_name_from_an_invalid_string(string userName) + public void I_can_try_to_parse_a_user_name_and_get_an_error_if_the_input_string_is_invalid( + string userName + ) { // Act & assert Assert.Throws(() => UserName.Parse(userName)); diff --git a/YoutubeDownloader.Tests/Utils/TempFile.cs b/YoutubeDownloader.Tests/Utils/TempFile.cs index b8dff358..2243fbb0 100644 --- a/YoutubeDownloader.Tests/Utils/TempFile.cs +++ b/YoutubeDownloader.Tests/Utils/TempFile.cs @@ -5,11 +5,9 @@ namespace YoutubeExplode.Tests.Utils; -internal partial class TempFile : IDisposable +internal partial class TempFile(string path) : IDisposable { - public string Path { get; } - - public TempFile(string path) => Path = path; + public string Path { get; } = path; public void Dispose() { diff --git a/YoutubeDownloader.Tests/VideoIdSpecs.cs b/YoutubeDownloader.Tests/VideoIdSpecs.cs index 3bf067df..4bed8d1c 100644 --- a/YoutubeDownloader.Tests/VideoIdSpecs.cs +++ b/YoutubeDownloader.Tests/VideoIdSpecs.cs @@ -43,7 +43,9 @@ public void I_can_parse_a_video_ID_from_a_URL_string(string videoUrl, string exp [InlineData("youtu.be/watch?v=xxx")] [InlineData("youtube.com/embed/")] [InlineData("youtube.com/live/")] - public void I_cannot_parse_a_video_ID_from_an_invalid_string(string videoId) + public void I_can_try_to_parse_a_video_ID_and_get_an_error_if_the_input_string_is_invalid( + string videoId + ) { // Act & assert Assert.Throws(() => VideoId.Parse(videoId)); diff --git a/YoutubeDownloader.Tests/VideoSpecs.cs b/YoutubeDownloader.Tests/VideoSpecs.cs index a2630c8e..25cbf595 100644 --- a/YoutubeDownloader.Tests/VideoSpecs.cs +++ b/YoutubeDownloader.Tests/VideoSpecs.cs @@ -9,12 +9,8 @@ namespace YoutubeExplode.Tests; -public class VideoSpecs +public class VideoSpecs(ITestOutputHelper testOutput) { - private readonly ITestOutputHelper _testOutput; - - public VideoSpecs(ITestOutputHelper testOutput) => _testOutput = testOutput; - [Fact] public async Task I_can_get_the_metadata_of_a_video() { @@ -36,8 +32,7 @@ public async Task I_can_get_the_metadata_of_a_video() video.Duration.Should().BeCloseTo(TimeSpan.FromSeconds(252), TimeSpan.FromSeconds(1)); video.Thumbnails.Should().NotBeEmpty(); video - .Keywords - .Should() + .Keywords.Should() .BeEquivalentTo( "PSY", "싸이", @@ -61,7 +56,7 @@ public async Task I_can_get_the_metadata_of_a_video() } [Fact] - public async Task I_cannot_get_the_metadata_of_a_private_video() + public async Task I_can_try_to_get_the_metadata_of_a_video_and_get_an_error_if_it_is_private() { // Arrange var youtube = new YoutubeClient(); @@ -71,11 +66,11 @@ public async Task I_cannot_get_the_metadata_of_a_private_video() async () => await youtube.Videos.GetAsync(VideoIds.Private) ); - _testOutput.WriteLine(ex.Message); + testOutput.WriteLine(ex.ToString()); } [Fact] - public async Task I_cannot_get_the_metadata_of_a_non_existing_video() + public async Task I_can_try_to_get_the_metadata_of_a_video_and_get_an_error_if_it_does_not_exist() { // Arrange var youtube = new YoutubeClient(); @@ -85,12 +80,13 @@ public async Task I_cannot_get_the_metadata_of_a_non_existing_video() async () => await youtube.Videos.GetAsync(VideoIds.Deleted) ); - _testOutput.WriteLine(ex.Message); + testOutput.WriteLine(ex.ToString()); } [Theory] [InlineData(VideoIds.Normal)] [InlineData(VideoIds.Unlisted)] + [InlineData(VideoIds.RequiresPurchaseDistributed)] [InlineData(VideoIds.EmbedRestrictedByYouTube)] [InlineData(VideoIds.EmbedRestrictedByAuthor)] [InlineData(VideoIds.AgeRestrictedViolent)] diff --git a/YoutubeDownloader.Tests/YoutubeDownloader.Tests.csproj b/YoutubeDownloader.Tests/YoutubeDownloader.Tests.csproj index f9af5323..2a873f90 100644 --- a/YoutubeDownloader.Tests/YoutubeDownloader.Tests.csproj +++ b/YoutubeDownloader.Tests/YoutubeDownloader.Tests.csproj @@ -1,7 +1,7 @@  - net7.0 + net8.0 $(TargetFrameworks);net48 @@ -11,13 +11,13 @@ - + - - - - + + + + diff --git a/YoutubeDownloader/Bridge/ChannelPage.cs b/YoutubeDownloader/Bridge/ChannelPage.cs index c46ea4f2..af0adf4d 100644 --- a/YoutubeDownloader/Bridge/ChannelPage.cs +++ b/YoutubeDownloader/Bridge/ChannelPage.cs @@ -6,26 +6,22 @@ namespace YoutubeExplode.Bridge; -internal partial class ChannelPage +internal partial class ChannelPage(IHtmlDocument content) { - private readonly IHtmlDocument _content; - [Lazy] public string? Url => - _content.QuerySelector("meta[property=\"og:url\"]")?.GetAttribute("content"); + content.QuerySelector("meta[property=\"og:url\"]")?.GetAttribute("content"); [Lazy] public string? Id => Url?.SubstringAfter("channel/", StringComparison.OrdinalIgnoreCase); [Lazy] public string? Title => - _content.QuerySelector("meta[property=\"og:title\"]")?.GetAttribute("content"); + content.QuerySelector("meta[property=\"og:title\"]")?.GetAttribute("content"); [Lazy] public string? LogoUrl => - _content.QuerySelector("meta[property=\"og:image\"]")?.GetAttribute("content"); - - public ChannelPage(IHtmlDocument content) => _content = content; + content.QuerySelector("meta[property=\"og:image\"]")?.GetAttribute("content"); } internal partial class ChannelPage diff --git a/YoutubeDownloader/Bridge/Cipher/CipherManifest.cs b/YoutubeDownloader/Bridge/Cipher/CipherManifest.cs index 93ead367..253b6949 100644 --- a/YoutubeDownloader/Bridge/Cipher/CipherManifest.cs +++ b/YoutubeDownloader/Bridge/Cipher/CipherManifest.cs @@ -3,17 +3,11 @@ namespace YoutubeExplode.Bridge.Cipher; -internal class CipherManifest +internal class CipherManifest(string signatureTimestamp, IReadOnlyList operations) { - public string SignatureTimestamp { get; } + public string SignatureTimestamp { get; } = signatureTimestamp; - public IReadOnlyList Operations { get; } - - public CipherManifest(string signatureTimestamp, IReadOnlyList operations) - { - SignatureTimestamp = signatureTimestamp; - Operations = operations; - } + public IReadOnlyList Operations { get; } = operations; public string Decipher(string input) => Operations.Aggregate(input, (acc, op) => op.Decipher(acc)); diff --git a/YoutubeDownloader/Bridge/Cipher/SpliceCipherOperation.cs b/YoutubeDownloader/Bridge/Cipher/SpliceCipherOperation.cs index 8bf829cb..e8931cb7 100644 --- a/YoutubeDownloader/Bridge/Cipher/SpliceCipherOperation.cs +++ b/YoutubeDownloader/Bridge/Cipher/SpliceCipherOperation.cs @@ -2,14 +2,10 @@ namespace YoutubeExplode.Bridge.Cipher; -internal class SpliceCipherOperation : ICipherOperation +internal class SpliceCipherOperation(int index) : ICipherOperation { - private readonly int _index; - - public SpliceCipherOperation(int index) => _index = index; - - public string Decipher(string input) => input[_index..]; + public string Decipher(string input) => input[index..]; [ExcludeFromCodeCoverage] - public override string ToString() => $"Splice ({_index})"; + public override string ToString() => $"Splice ({index})"; } diff --git a/YoutubeDownloader/Bridge/Cipher/SwapCipherOperation.cs b/YoutubeDownloader/Bridge/Cipher/SwapCipherOperation.cs index 63dcfba7..ede39089 100644 --- a/YoutubeDownloader/Bridge/Cipher/SwapCipherOperation.cs +++ b/YoutubeDownloader/Bridge/Cipher/SwapCipherOperation.cs @@ -3,14 +3,10 @@ namespace YoutubeExplode.Bridge.Cipher; -internal class SwapCipherOperation : ICipherOperation +internal class SwapCipherOperation(int index) : ICipherOperation { - private readonly int _index; - - public SwapCipherOperation(int index) => _index = index; - - public string Decipher(string input) => input.SwapChars(0, _index); + public string Decipher(string input) => input.SwapChars(0, index); [ExcludeFromCodeCoverage] - public override string ToString() => $"Swap ({_index})"; + public override string ToString() => $"Swap ({index})"; } diff --git a/YoutubeDownloader/Bridge/ClosedCaptionTrackResponse.cs b/YoutubeDownloader/Bridge/ClosedCaptionTrackResponse.cs index 9b6cd1d7..f890c795 100644 --- a/YoutubeDownloader/Bridge/ClosedCaptionTrackResponse.cs +++ b/YoutubeDownloader/Bridge/ClosedCaptionTrackResponse.cs @@ -8,58 +8,46 @@ namespace YoutubeExplode.Bridge; -internal partial class ClosedCaptionTrackResponse +internal partial class ClosedCaptionTrackResponse(XElement content) { - private readonly XElement _content; - [Lazy] public IReadOnlyList Captions => - _content.Descendants("p").Select(x => new CaptionData(x)).ToArray(); - - public ClosedCaptionTrackResponse(XElement content) => _content = content; + content.Descendants("p").Select(x => new CaptionData(x)).ToArray(); } internal partial class ClosedCaptionTrackResponse { - public class CaptionData + public class CaptionData(XElement content) { - private readonly XElement _content; - [Lazy] - public string? Text => (string?)_content; + public string? Text => (string?)content; [Lazy] public TimeSpan? Offset => - ((double?)_content.Attribute("t"))?.Pipe(TimeSpan.FromMilliseconds); + ((double?)content.Attribute("t"))?.Pipe(TimeSpan.FromMilliseconds); [Lazy] public TimeSpan? Duration => - ((double?)_content.Attribute("d"))?.Pipe(TimeSpan.FromMilliseconds); + ((double?)content.Attribute("d"))?.Pipe(TimeSpan.FromMilliseconds); [Lazy] public IReadOnlyList Parts => - _content.Elements("s").Select(x => new PartData(x)).ToArray(); - - public CaptionData(XElement content) => _content = content; + content.Elements("s").Select(x => new PartData(x)).ToArray(); } } internal partial class ClosedCaptionTrackResponse { - public class PartData + public class PartData(XElement content) { - private readonly XElement _content; - [Lazy] - public string? Text => (string?)_content; + public string? Text => (string?)content; [Lazy] public TimeSpan? Offset => - ((double?)_content.Attribute("t"))?.Pipe(TimeSpan.FromMilliseconds) - ?? ((double?)_content.Attribute("ac"))?.Pipe(TimeSpan.FromMilliseconds) + ((double?)content.Attribute("t"))?.Pipe(TimeSpan.FromMilliseconds) + ?? ((double?)content.Attribute("ac"))?.Pipe(TimeSpan.FromMilliseconds) ?? TimeSpan.Zero; - - public PartData(XElement content) => _content = content; } } diff --git a/YoutubeDownloader/Bridge/DashManifest.cs b/YoutubeDownloader/Bridge/DashManifest.cs index 28be83ad..5c1c1413 100644 --- a/YoutubeDownloader/Bridge/DashManifest.cs +++ b/YoutubeDownloader/Bridge/DashManifest.cs @@ -9,13 +9,11 @@ namespace YoutubeExplode.Bridge; -internal partial class DashManifest +internal partial class DashManifest(XElement content) { - private readonly XElement _content; - [Lazy] public IReadOnlyList Streams => - _content + content .Descendants("Representation") // Skip non-media representations (like "rawcc") // https://github.com/Tyrrrz/YoutubeExplode/issues/546 @@ -34,21 +32,17 @@ internal partial class DashManifest .Where(x => !string.IsNullOrWhiteSpace(x.Attribute("codecs")?.Value)) .Select(x => new StreamData(x)) .ToArray(); - - public DashManifest(XElement content) => _content = content; } internal partial class DashManifest { - public class StreamData : IStreamData + public class StreamData(XElement content) : IStreamData { - private readonly XElement _content; - [Lazy] - public int? Itag => (int?)_content.Attribute("id"); + public int? Itag => (int?)content.Attribute("id"); [Lazy] - public string? Url => (string?)_content.Element("BaseURL"); + public string? Url => (string?)content.Element("BaseURL"); // DASH streams don't have signatures public string? Signature => null; @@ -58,13 +52,13 @@ public class StreamData : IStreamData [Lazy] public long? ContentLength => - (long?)_content.Attribute("contentLength") + (long?)content.Attribute("contentLength") ?? Url?.Pipe(s => Regex.Match(s, @"[/\?]clen[/=](\d+)").Groups[1].Value) .NullIfWhiteSpace() ?.ParseLongOrNull(); [Lazy] - public long? Bitrate => (long?)_content.Attribute("bandwidth"); + public long? Bitrate => (long?)content.Attribute("bandwidth"); [Lazy] public string? Container => @@ -72,26 +66,24 @@ public class StreamData : IStreamData .Pipe(WebUtility.UrlDecode); [Lazy] - private bool IsAudioOnly => _content.Element("AudioChannelConfiguration") is not null; + private bool IsAudioOnly => content.Element("AudioChannelConfiguration") is not null; [Lazy] - public string? AudioCodec => IsAudioOnly ? (string?)_content.Attribute("codecs") : null; + public string? AudioCodec => IsAudioOnly ? (string?)content.Attribute("codecs") : null; [Lazy] - public string? VideoCodec => IsAudioOnly ? null : (string?)_content.Attribute("codecs"); + public string? VideoCodec => IsAudioOnly ? null : (string?)content.Attribute("codecs"); public string? VideoQualityLabel => null; [Lazy] - public int? VideoWidth => (int?)_content.Attribute("width"); + public int? VideoWidth => (int?)content.Attribute("width"); [Lazy] - public int? VideoHeight => (int?)_content.Attribute("height"); + public int? VideoHeight => (int?)content.Attribute("height"); [Lazy] - public int? VideoFramerate => (int?)_content.Attribute("frameRate"); - - public StreamData(XElement content) => _content = content; + public int? VideoFramerate => (int?)content.Attribute("frameRate"); } } diff --git a/YoutubeDownloader/Bridge/PlayerResponse.cs b/YoutubeDownloader/Bridge/PlayerResponse.cs index 02ff3aad..aed07949 100644 --- a/YoutubeDownloader/Bridge/PlayerResponse.cs +++ b/YoutubeDownloader/Bridge/PlayerResponse.cs @@ -10,12 +10,10 @@ namespace YoutubeExplode.Bridge; -internal partial class PlayerResponse +internal partial class PlayerResponse(JsonElement content) { - private readonly JsonElement _content; - [Lazy] - private JsonElement? Playability => _content.GetPropertyOrNull("playabilityStatus"); + private JsonElement? Playability => content.GetPropertyOrNull("playabilityStatus"); [Lazy] private string? PlayabilityStatus => @@ -34,7 +32,7 @@ internal partial class PlayerResponse string.Equals(PlayabilityStatus, "ok", StringComparison.OrdinalIgnoreCase); [Lazy] - private JsonElement? Details => _content.GetPropertyOrNull("videoDetails"); + private JsonElement? Details => content.GetPropertyOrNull("videoDetails"); [Lazy] public string? Title => Details?.GetPropertyOrNull("title")?.GetStringOrNull(); @@ -47,7 +45,7 @@ internal partial class PlayerResponse [Lazy] public DateTimeOffset? UploadDate => - _content + content .GetPropertyOrNull("microformat") ?.GetPropertyOrNull("playerMicroformatRenderer") ?.GetPropertyOrNull("uploadDate") @@ -117,7 +115,7 @@ internal partial class PlayerResponse .NullIfWhiteSpace(); [Lazy] - private JsonElement? StreamingData => _content.GetPropertyOrNull("streamingData"); + private JsonElement? StreamingData => content.GetPropertyOrNull("streamingData"); [Lazy] public string? DashManifestUrl => @@ -156,34 +154,29 @@ public IReadOnlyList Streams [Lazy] public IReadOnlyList ClosedCaptionTracks => - _content + content .GetPropertyOrNull("captions") ?.GetPropertyOrNull("playerCaptionsTracklistRenderer") ?.GetPropertyOrNull("captionTracks") ?.EnumerateArrayOrNull() ?.Select(j => new ClosedCaptionTrackData(j)) .ToArray() ?? Array.Empty(); - - public PlayerResponse(JsonElement content) => _content = content; } internal partial class PlayerResponse { - public class ClosedCaptionTrackData + public class ClosedCaptionTrackData(JsonElement content) { - private readonly JsonElement _content; - [Lazy] - public string? Url => _content.GetPropertyOrNull("baseUrl")?.GetStringOrNull(); + public string? Url => content.GetPropertyOrNull("baseUrl")?.GetStringOrNull(); [Lazy] - public string? LanguageCode => - _content.GetPropertyOrNull("languageCode")?.GetStringOrNull(); + public string? LanguageCode => content.GetPropertyOrNull("languageCode")?.GetStringOrNull(); [Lazy] public string? LanguageName => - _content.GetPropertyOrNull("name")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() - ?? _content + content.GetPropertyOrNull("name")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() + ?? content .GetPropertyOrNull("name") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() @@ -193,35 +186,31 @@ public class ClosedCaptionTrackData [Lazy] public bool IsAutoGenerated => - _content + content .GetPropertyOrNull("vssId") ?.GetStringOrNull() ?.StartsWith("a.", StringComparison.OrdinalIgnoreCase) ?? false; - - public ClosedCaptionTrackData(JsonElement content) => _content = content; } } internal partial class PlayerResponse { - public class StreamData : IStreamData + public class StreamData(JsonElement content) : IStreamData { - private readonly JsonElement _content; - [Lazy] - public int? Itag => _content.GetPropertyOrNull("itag")?.GetInt32OrNull(); + public int? Itag => content.GetPropertyOrNull("itag")?.GetInt32OrNull(); [Lazy] private IReadOnlyDictionary? CipherData => - _content.GetPropertyOrNull("cipher")?.GetStringOrNull()?.Pipe(UrlEx.GetQueryParameters) - ?? _content + content.GetPropertyOrNull("cipher")?.GetStringOrNull()?.Pipe(UrlEx.GetQueryParameters) + ?? content .GetPropertyOrNull("signatureCipher") ?.GetStringOrNull() ?.Pipe(UrlEx.GetQueryParameters); [Lazy] public string? Url => - _content.GetPropertyOrNull("url")?.GetStringOrNull() + content.GetPropertyOrNull("url")?.GetStringOrNull() ?? CipherData?.GetValueOrDefault("url"); [Lazy] @@ -232,16 +221,16 @@ public class StreamData : IStreamData [Lazy] public long? ContentLength => - _content.GetPropertyOrNull("contentLength")?.GetStringOrNull()?.ParseLongOrNull() + content.GetPropertyOrNull("contentLength")?.GetStringOrNull()?.ParseLongOrNull() ?? Url?.Pipe(s => UrlEx.TryGetQueryParameterValue(s, "clen")) ?.NullIfWhiteSpace() ?.ParseLongOrNull(); [Lazy] - public long? Bitrate => _content.GetPropertyOrNull("bitrate")?.GetInt64OrNull(); + public long? Bitrate => content.GetPropertyOrNull("bitrate")?.GetInt64OrNull(); [Lazy] - private string? MimeType => _content.GetPropertyOrNull("mimeType")?.GetStringOrNull(); + private string? MimeType => content.GetPropertyOrNull("mimeType")?.GetStringOrNull(); [Lazy] public string? Container => MimeType?.SubstringUntil(";").SubstringAfter("/"); @@ -274,18 +263,16 @@ public string? VideoCodec [Lazy] public string? VideoQualityLabel => - _content.GetPropertyOrNull("qualityLabel")?.GetStringOrNull(); + content.GetPropertyOrNull("qualityLabel")?.GetStringOrNull(); [Lazy] - public int? VideoWidth => _content.GetPropertyOrNull("width")?.GetInt32OrNull(); + public int? VideoWidth => content.GetPropertyOrNull("width")?.GetInt32OrNull(); [Lazy] - public int? VideoHeight => _content.GetPropertyOrNull("height")?.GetInt32OrNull(); + public int? VideoHeight => content.GetPropertyOrNull("height")?.GetInt32OrNull(); [Lazy] - public int? VideoFramerate => _content.GetPropertyOrNull("fps")?.GetInt32OrNull(); - - public StreamData(JsonElement content) => _content = content; + public int? VideoFramerate => content.GetPropertyOrNull("fps")?.GetInt32OrNull(); } } diff --git a/YoutubeDownloader/Bridge/PlayerSource.cs b/YoutubeDownloader/Bridge/PlayerSource.cs index b188a143..f39e6cee 100644 --- a/YoutubeDownloader/Bridge/PlayerSource.cs +++ b/YoutubeDownloader/Bridge/PlayerSource.cs @@ -7,10 +7,8 @@ namespace YoutubeExplode.Bridge; -internal partial class PlayerSource +internal partial class PlayerSource(string content) { - private readonly string _content; - [Lazy] public CipherManifest? CipherManifest { @@ -18,10 +16,9 @@ public CipherManifest? CipherManifest { // Extract the signature timestamp var signatureTimestamp = Regex - .Match(_content, @"(?:signatureTimestamp|sts):(\d{5})") + .Match(content, @"(?:signatureTimestamp|sts):(\d{5})") .Groups[1] - .Value - .NullIfWhiteSpace(); + .Value.NullIfWhiteSpace(); if (string.IsNullOrWhiteSpace(signatureTimestamp)) return null; @@ -29,15 +26,14 @@ public CipherManifest? CipherManifest // Find where the player calls the cipher functions var cipherCallsite = Regex .Match( - _content, + content, """ - [$_\w]+=function\([$_\w]+\){([$_\w]+)=\1\.split\(['"]{2}\);.*?return \1\.join\(['"]{2}\)} - """, + [$_\w]+=function\([$_\w]+\){([$_\w]+)=\1\.split\(['"]{2}\);.*?return \1\.join\(['"]{2}\)} + """, RegexOptions.Singleline ) .Groups[0] - .Value - .NullIfWhiteSpace(); + .Value.NullIfWhiteSpace(); if (string.IsNullOrWhiteSpace(cipherCallsite)) return null; @@ -54,7 +50,7 @@ public CipherManifest? CipherManifest // Find the definition of the cipher functions var cipherDefinition = Regex .Match( - _content, + content, // lang=js $$""" var {{Regex.Escape(cipherContainerName)}}={.*?}; @@ -62,8 +58,7 @@ public CipherManifest? CipherManifest RegexOptions.Singleline ) .Groups[0] - .Value - .NullIfWhiteSpace(); + .Value.NullIfWhiteSpace(); if (string.IsNullOrWhiteSpace(cipherDefinition)) return null; @@ -76,8 +71,7 @@ public CipherManifest? CipherManifest RegexOptions.Singleline ) .Groups[1] - .Value - .NullIfWhiteSpace(); + .Value.NullIfWhiteSpace(); // Identify the splice cipher function var spliceFuncName = Regex @@ -87,8 +81,7 @@ public CipherManifest? CipherManifest RegexOptions.Singleline ) .Groups[1] - .Value - .NullIfWhiteSpace(); + .Value.NullIfWhiteSpace(); // Identify the reverse cipher function var reverseFuncName = Regex @@ -98,8 +91,7 @@ public CipherManifest? CipherManifest RegexOptions.Singleline ) .Groups[1] - .Value - .NullIfWhiteSpace(); + .Value.NullIfWhiteSpace(); var operations = new List(); @@ -117,8 +109,7 @@ public CipherManifest? CipherManifest var index = Regex .Match(statement, @"\([$_\w]+,(\d+)\)") .Groups[1] - .Value - .ParseInt(); + .Value.ParseInt(); operations.Add(new SwapCipherOperation(index)); } else if (string.Equals(calledFuncName, spliceFuncName, StringComparison.Ordinal)) @@ -126,8 +117,7 @@ public CipherManifest? CipherManifest var index = Regex .Match(statement, @"\([$_\w]+,(\d+)\)") .Groups[1] - .Value - .ParseInt(); + .Value.ParseInt(); operations.Add(new SpliceCipherOperation(index)); } else if (string.Equals(calledFuncName, reverseFuncName, StringComparison.Ordinal)) @@ -139,8 +129,6 @@ public CipherManifest? CipherManifest return new CipherManifest(signatureTimestamp, operations); } } - - public PlayerSource(string content) => _content = content; } internal partial class PlayerSource diff --git a/YoutubeDownloader/Bridge/PlaylistBrowseResponse.cs b/YoutubeDownloader/Bridge/PlaylistBrowseResponse.cs index 7ab43b9e..074c10e0 100644 --- a/YoutubeDownloader/Bridge/PlaylistBrowseResponse.cs +++ b/YoutubeDownloader/Bridge/PlaylistBrowseResponse.cs @@ -8,13 +8,11 @@ namespace YoutubeExplode.Bridge; -internal partial class PlaylistBrowseResponse : IPlaylistData +internal partial class PlaylistBrowseResponse(JsonElement content) : IPlaylistData { - private readonly JsonElement _content; - [Lazy] private JsonElement? Sidebar => - _content + content .GetPropertyOrNull("sidebar") ?.GetPropertyOrNull("playlistSidebarRenderer") ?.GetPropertyOrNull("items"); @@ -109,8 +107,6 @@ internal partial class PlaylistBrowseResponse : IPlaylistData ?.Select(j => new ThumbnailData(j)) .ToArray() ?? Array.Empty(); - - public PlaylistBrowseResponse(JsonElement content) => _content = content; } internal partial class PlaylistBrowseResponse diff --git a/YoutubeDownloader/Bridge/PlaylistNextResponse.cs b/YoutubeDownloader/Bridge/PlaylistNextResponse.cs index 3adc5be9..0334f624 100644 --- a/YoutubeDownloader/Bridge/PlaylistNextResponse.cs +++ b/YoutubeDownloader/Bridge/PlaylistNextResponse.cs @@ -8,13 +8,11 @@ namespace YoutubeExplode.Bridge; -internal partial class PlaylistNextResponse : IPlaylistData +internal partial class PlaylistNextResponse(JsonElement content) : IPlaylistData { - private readonly JsonElement _content; - [Lazy] private JsonElement? ContentRoot => - _content + content .GetPropertyOrNull("contents") ?.GetPropertyOrNull("twoColumnWatchNextResults") ?.GetPropertyOrNull("playlist") @@ -53,12 +51,10 @@ internal partial class PlaylistNextResponse : IPlaylistData [Lazy] public string? VisitorData => - _content + content .GetPropertyOrNull("responseContext") ?.GetPropertyOrNull("visitorData") ?.GetStringOrNull(); - - public PlaylistNextResponse(JsonElement content) => _content = content; } internal partial class PlaylistNextResponse diff --git a/YoutubeDownloader/Bridge/PlaylistVideoData.cs b/YoutubeDownloader/Bridge/PlaylistVideoData.cs index 8655e0ad..2c61dced 100644 --- a/YoutubeDownloader/Bridge/PlaylistVideoData.cs +++ b/YoutubeDownloader/Bridge/PlaylistVideoData.cs @@ -7,25 +7,23 @@ namespace YoutubeExplode.Bridge; -internal class PlaylistVideoData +internal class PlaylistVideoData(JsonElement content) { - private readonly JsonElement _content; - [Lazy] public int? Index => - _content + content .GetPropertyOrNull("navigationEndpoint") ?.GetPropertyOrNull("watchEndpoint") ?.GetPropertyOrNull("index") ?.GetInt32OrNull(); [Lazy] - public string? Id => _content.GetPropertyOrNull("videoId")?.GetStringOrNull(); + public string? Id => content.GetPropertyOrNull("videoId")?.GetStringOrNull(); [Lazy] public string? Title => - _content.GetPropertyOrNull("title")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() - ?? _content + content.GetPropertyOrNull("title")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() + ?? content .GetPropertyOrNull("title") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() @@ -35,12 +33,12 @@ internal class PlaylistVideoData [Lazy] private JsonElement? AuthorDetails => - _content + content .GetPropertyOrNull("longBylineText") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() ?.ElementAtOrNull(0) - ?? _content + ?? content .GetPropertyOrNull("shortBylineText") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() @@ -59,33 +57,31 @@ internal class PlaylistVideoData [Lazy] public TimeSpan? Duration => - _content + content .GetPropertyOrNull("lengthSeconds") ?.GetStringOrNull() ?.ParseDoubleOrNull() ?.Pipe(TimeSpan.FromSeconds) - ?? _content + ?? content .GetPropertyOrNull("lengthText") ?.GetPropertyOrNull("simpleText") ?.GetStringOrNull() - ?.ParseTimeSpanOrNull(new[] { @"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss" }) - ?? _content + ?.ParseTimeSpanOrNull([@"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss"]) + ?? content .GetPropertyOrNull("lengthText") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() ?.Select(j => j.GetPropertyOrNull("text")?.GetStringOrNull()) .WhereNotNull() .ConcatToString() - .ParseTimeSpanOrNull(new[] { @"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss" }); + .ParseTimeSpanOrNull([@"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss"]); [Lazy] public IReadOnlyList Thumbnails => - _content + content .GetPropertyOrNull("thumbnail") ?.GetPropertyOrNull("thumbnails") ?.EnumerateArrayOrNull() ?.Select(j => new ThumbnailData(j)) .ToArray() ?? Array.Empty(); - - public PlaylistVideoData(JsonElement content) => _content = content; } diff --git a/YoutubeDownloader/Bridge/SearchResponse.cs b/YoutubeDownloader/Bridge/SearchResponse.cs index 2582a9bd..0aa32326 100644 --- a/YoutubeDownloader/Bridge/SearchResponse.cs +++ b/YoutubeDownloader/Bridge/SearchResponse.cs @@ -8,17 +8,15 @@ namespace YoutubeExplode.Bridge; -internal partial class SearchResponse +internal partial class SearchResponse(JsonElement content) { - private readonly JsonElement _content; - // Search response is incredibly inconsistent (with at least 5 variations), // so we employ descendant searching, which is inefficient but resilient. [Lazy] private JsonElement? ContentRoot => - _content.GetPropertyOrNull("contents") - ?? _content.GetPropertyOrNull("onResponseReceivedCommands"); + content.GetPropertyOrNull("contents") + ?? content.GetPropertyOrNull("onResponseReceivedCommands"); [Lazy] public IReadOnlyList Videos => @@ -48,23 +46,19 @@ internal partial class SearchResponse .FirstOrNull() ?.GetPropertyOrNull("token") ?.GetStringOrNull(); - - public SearchResponse(JsonElement content) => _content = content; } internal partial class SearchResponse { - internal class VideoData + internal class VideoData(JsonElement content) { - private readonly JsonElement _content; - [Lazy] - public string? Id => _content.GetPropertyOrNull("videoId")?.GetStringOrNull(); + public string? Id => content.GetPropertyOrNull("videoId")?.GetStringOrNull(); [Lazy] public string? Title => - _content.GetPropertyOrNull("title")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() - ?? _content + content.GetPropertyOrNull("title")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() + ?? content .GetPropertyOrNull("title") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() @@ -74,12 +68,12 @@ internal class VideoData [Lazy] private JsonElement? AuthorDetails => - _content + content .GetPropertyOrNull("longBylineText") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() ?.ElementAtOrNull(0) - ?? _content + ?? content .GetPropertyOrNull("shortBylineText") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() @@ -91,6 +85,13 @@ internal class VideoData [Lazy] public string? ChannelId => AuthorDetails + ?.GetPropertyOrNull("navigationEndpoint") + ?.GetPropertyOrNull("browseEndpoint") + ?.GetPropertyOrNull("browseId") + ?.GetStringOrNull() + ?? content + .GetPropertyOrNull("channelThumbnailSupportedRenderers") + ?.GetPropertyOrNull("channelThumbnailWithLinkRenderer") ?.GetPropertyOrNull("navigationEndpoint") ?.GetPropertyOrNull("browseEndpoint") ?.GetPropertyOrNull("browseId") @@ -98,46 +99,42 @@ internal class VideoData [Lazy] public TimeSpan? Duration => - _content + content .GetPropertyOrNull("lengthText") ?.GetPropertyOrNull("simpleText") ?.GetStringOrNull() - ?.ParseTimeSpanOrNull(new[] { @"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss" }) - ?? _content + ?.ParseTimeSpanOrNull([@"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss"]) + ?? content .GetPropertyOrNull("lengthText") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() ?.Select(j => j.GetPropertyOrNull("text")?.GetStringOrNull()) .WhereNotNull() .ConcatToString() - .ParseTimeSpanOrNull(new[] { @"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss" }); + .ParseTimeSpanOrNull([@"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss"]); [Lazy] public IReadOnlyList Thumbnails => - _content + content .GetPropertyOrNull("thumbnail") ?.GetPropertyOrNull("thumbnails") ?.EnumerateArrayOrNull() ?.Select(j => new ThumbnailData(j)) .ToArray() ?? Array.Empty(); - - public VideoData(JsonElement content) => _content = content; } } internal partial class SearchResponse { - public class PlaylistData + public class PlaylistData(JsonElement content) { - private readonly JsonElement _content; - [Lazy] - public string? Id => _content.GetPropertyOrNull("playlistId")?.GetStringOrNull(); + public string? Id => content.GetPropertyOrNull("playlistId")?.GetStringOrNull(); [Lazy] public string? Title => - _content.GetPropertyOrNull("title")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() - ?? _content + content.GetPropertyOrNull("title")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() + ?? content .GetPropertyOrNull("title") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() @@ -147,7 +144,7 @@ public class PlaylistData [Lazy] private JsonElement? AuthorDetails => - _content + content .GetPropertyOrNull("longBylineText") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() @@ -166,30 +163,26 @@ public class PlaylistData [Lazy] public IReadOnlyList Thumbnails => - _content + content .GetPropertyOrNull("thumbnails") ?.EnumerateDescendantProperties("thumbnails") .SelectMany(j => j.EnumerateArrayOrEmpty()) .Select(j => new ThumbnailData(j)) .ToArray() ?? Array.Empty(); - - public PlaylistData(JsonElement content) => _content = content; } } internal partial class SearchResponse { - public class ChannelData + public class ChannelData(JsonElement content) { - private readonly JsonElement _content; - [Lazy] - public string? Id => _content.GetPropertyOrNull("channelId")?.GetStringOrNull(); + public string? Id => content.GetPropertyOrNull("channelId")?.GetStringOrNull(); [Lazy] public string? Title => - _content.GetPropertyOrNull("title")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() - ?? _content + content.GetPropertyOrNull("title")?.GetPropertyOrNull("simpleText")?.GetStringOrNull() + ?? content .GetPropertyOrNull("title") ?.GetPropertyOrNull("runs") ?.EnumerateArrayOrNull() @@ -199,14 +192,12 @@ public class ChannelData [Lazy] public IReadOnlyList Thumbnails => - _content + content .GetPropertyOrNull("thumbnail") ?.GetPropertyOrNull("thumbnails") ?.EnumerateArrayOrNull() ?.Select(j => new ThumbnailData(j)) .ToArray() ?? Array.Empty(); - - public ChannelData(JsonElement content) => _content = content; } } diff --git a/YoutubeDownloader/Bridge/ThumbnailData.cs b/YoutubeDownloader/Bridge/ThumbnailData.cs index 1fa6478b..a4b5feec 100644 --- a/YoutubeDownloader/Bridge/ThumbnailData.cs +++ b/YoutubeDownloader/Bridge/ThumbnailData.cs @@ -4,18 +4,14 @@ namespace YoutubeExplode.Bridge; -internal class ThumbnailData +internal class ThumbnailData(JsonElement content) { - private readonly JsonElement _content; - - public ThumbnailData(JsonElement content) => _content = content; - [Lazy] - public string? Url => _content.GetPropertyOrNull("url")?.GetStringOrNull(); + public string? Url => content.GetPropertyOrNull("url")?.GetStringOrNull(); [Lazy] - public int? Width => _content.GetPropertyOrNull("width")?.GetInt32OrNull(); + public int? Width => content.GetPropertyOrNull("width")?.GetInt32OrNull(); [Lazy] - public int? Height => _content.GetPropertyOrNull("height")?.GetInt32OrNull(); + public int? Height => content.GetPropertyOrNull("height")?.GetInt32OrNull(); } diff --git a/YoutubeDownloader/Bridge/VideoWatchPage.cs b/YoutubeDownloader/Bridge/VideoWatchPage.cs index 36ddded4..b53e9193 100644 --- a/YoutubeDownloader/Bridge/VideoWatchPage.cs +++ b/YoutubeDownloader/Bridge/VideoWatchPage.cs @@ -10,21 +10,19 @@ namespace YoutubeExplode.Bridge; -internal partial class VideoWatchPage +internal partial class VideoWatchPage(IHtmlDocument content) { - private readonly IHtmlDocument _content; - [Lazy] - public bool IsAvailable => _content.QuerySelector("meta[property=\"og:url\"]") is not null; + public bool IsAvailable => content.QuerySelector("meta[property=\"og:url\"]") is not null; [Lazy] public DateTimeOffset? UploadDate => - _content + content .QuerySelector("meta[itemprop=\"uploadDate\"]") ?.GetAttribute("content") ?.NullIfWhiteSpace() ?.ParseDateTimeOffsetOrNull() - ?? _content + ?? content .QuerySelector("meta[itemprop=\"datePublished\"]") ?.GetAttribute("content") ?.NullIfWhiteSpace() @@ -32,16 +30,30 @@ internal partial class VideoWatchPage [Lazy] public long? LikeCount => - _content - .Source - .Text - .Pipe( + content + .Source.Text.Pipe( s => Regex .Match( s, """ - "label"\s*:\s*"([\d,\.]+) likes" + "\s*:\s*"([\d,\.]+) likes" + """ + ) + .Groups[1] + .Value + ) + .NullIfWhiteSpace() + ?.StripNonDigit() + .ParseLongOrNull() + ?? content + .Source.Text.Pipe( + s => + Regex + .Match( + s, + """ + along with ([\d,\.]+) other people" """ ) .Groups[1] @@ -53,16 +65,14 @@ internal partial class VideoWatchPage [Lazy] public long? DislikeCount => - _content - .Source - .Text - .Pipe( + content + .Source.Text.Pipe( s => Regex .Match( s, """ - "label"\s*:\s*"([\d,\.]+) dislikes" + "\s*:\s*"([\d,\.]+) dislikes" """ ) .Groups[1] @@ -74,7 +84,7 @@ internal partial class VideoWatchPage [Lazy] private JsonElement? PlayerConfig => - _content + content .GetElementsByTagName("script") .Select(e => e.Text()) .Select(s => Regex.Match(s, @"ytplayer\.config\s*=\s*(\{.*\})").Groups[1].Value) @@ -85,7 +95,7 @@ internal partial class VideoWatchPage [Lazy] public PlayerResponse? PlayerResponse => - _content + content .GetElementsByTagName("script") .Select(e => e.Text()) .Select( @@ -102,8 +112,6 @@ internal partial class VideoWatchPage ?.GetStringOrNull() ?.Pipe(Json.TryParse) ?.Pipe(j => new PlayerResponse(j)); - - public VideoWatchPage(IHtmlDocument content) => _content = content; } internal partial class VideoWatchPage diff --git a/YoutubeDownloader/Channels/Channel.cs b/YoutubeDownloader/Channels/Channel.cs index 3bf472d0..cfd0f638 100644 --- a/YoutubeDownloader/Channels/Channel.cs +++ b/YoutubeDownloader/Channels/Channel.cs @@ -7,29 +7,19 @@ namespace YoutubeExplode.Channels; /// /// Metadata associated with a YouTube channel. /// -public class Channel : IChannel +public class Channel(ChannelId id, string title, IReadOnlyList thumbnails) : IChannel { /// - public ChannelId Id { get; } + public ChannelId Id { get; } = id; /// public string Url => $"https://www.youtube.com/channel/{Id}"; /// - public string Title { get; } + public string Title { get; } = title; /// - public IReadOnlyList Thumbnails { get; } - - /// - /// Initializes an instance of . - /// - public Channel(ChannelId id, string title, IReadOnlyList thumbnails) - { - Id = id; - Title = title; - Thumbnails = thumbnails; - } + public IReadOnlyList Thumbnails { get; } = thumbnails; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Channels/ChannelClient.cs b/YoutubeDownloader/Channels/ChannelClient.cs index 0b643dae..c5b757e1 100644 --- a/YoutubeDownloader/Channels/ChannelClient.cs +++ b/YoutubeDownloader/Channels/ChannelClient.cs @@ -15,32 +15,23 @@ namespace YoutubeExplode.Channels; /// /// Operations related to YouTube channels. /// -public class ChannelClient +public class ChannelClient(HttpClient http) { - private readonly HttpClient _http; - private readonly ChannelController _controller; - - /// - /// Initializes an instance of . - /// - public ChannelClient(HttpClient http) - { - _http = http; - _controller = new ChannelController(http); - } + private readonly ChannelController _controller = new(http); private Channel Get(ChannelPage channelPage) { var channelId = - channelPage.Id ?? throw new YoutubeExplodeException("Could not extract channel ID."); + channelPage.Id + ?? throw new YoutubeExplodeException("Failed to extract the channel ID."); var title = channelPage.Title - ?? throw new YoutubeExplodeException("Could not extract channel title."); + ?? throw new YoutubeExplodeException("Failed to extract the channel title."); var logoUrl = channelPage.LogoUrl - ?? throw new YoutubeExplodeException("Could not extract channel logo URL."); + ?? throw new YoutubeExplodeException("Failed to extract the channel logo URL."); var logoSize = Regex @@ -48,8 +39,7 @@ private Channel Get(ChannelPage channelPage) .ToArray() .LastOrDefault() ?.Groups[1] - .Value - .NullIfWhiteSpace() + .Value.NullIfWhiteSpace() ?.ParseIntOrNull() ?? 100; var thumbnails = new[] { new Thumbnail(logoUrl, new Resolution(logoSize, logoSize)) }; @@ -71,13 +61,12 @@ public async ValueTask GetAsync( return new Channel( "UCuVPpxrm2VAgpH3Ktln4HXg", "Movies & TV", - new[] - { + [ new Thumbnail( "https://www.gstatic.com/youtube/img/tvfilm/clapperboard_profile.png", new Resolution(1024, 1024) ) - } + ] ); } @@ -119,6 +108,6 @@ public IAsyncEnumerable GetUploadsAsync( { // Replace 'UC' in the channel ID with 'UU' var playlistId = "UU" + channelId.Value[2..]; - return new PlaylistClient(_http).GetVideosAsync(playlistId, cancellationToken); + return new PlaylistClient(http).GetVideosAsync(playlistId, cancellationToken); } } diff --git a/YoutubeDownloader/Channels/ChannelController.cs b/YoutubeDownloader/Channels/ChannelController.cs index 596bc6de..89e970d3 100644 --- a/YoutubeDownloader/Channels/ChannelController.cs +++ b/YoutubeDownloader/Channels/ChannelController.cs @@ -6,12 +6,8 @@ namespace YoutubeExplode.Channels; -internal class ChannelController +internal class ChannelController(HttpClient http) { - private readonly HttpClient _http; - - public ChannelController(HttpClient http) => _http = http; - private async ValueTask GetChannelPageAsync( string channelRoute, CancellationToken cancellationToken = default @@ -20,7 +16,7 @@ private async ValueTask GetChannelPageAsync( for (var retriesRemaining = 5; ; retriesRemaining--) { var channelPage = ChannelPage.TryParse( - await _http.GetStringAsync( + await http.GetStringAsync( "https://www.youtube.com/" + channelRoute, cancellationToken ) diff --git a/YoutubeDownloader/Channels/ChannelHandle.cs b/YoutubeDownloader/Channels/ChannelHandle.cs index 44a7d80b..839d0c23 100644 --- a/YoutubeDownloader/Channels/ChannelHandle.cs +++ b/YoutubeDownloader/Channels/ChannelHandle.cs @@ -32,21 +32,20 @@ private static bool IsValid(string channelHandle) => if (string.IsNullOrWhiteSpace(channelHandleOrUrl)) return null; - // Handle + // Check if already passed a handle // Tyrrrz if (IsValid(channelHandleOrUrl)) return channelHandleOrUrl; - // URL + // Try to extract the handle from the URL // https://www.youtube.com/@Tyrrrz - var regularMatch = Regex + var handle = Regex .Match(channelHandleOrUrl, @"youtube\..+?/@(.*?)(?:\?|&|/|$)") .Groups[1] - .Value - .Pipe(WebUtility.UrlDecode); + .Value.Pipe(WebUtility.UrlDecode); - if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) - return regularMatch; + if (!string.IsNullOrWhiteSpace(handle) && IsValid(handle)) + return handle; // Invalid input return null; diff --git a/YoutubeDownloader/Channels/ChannelId.cs b/YoutubeDownloader/Channels/ChannelId.cs index 1047ec1e..2b36ddc4 100644 --- a/YoutubeDownloader/Channels/ChannelId.cs +++ b/YoutubeDownloader/Channels/ChannelId.cs @@ -34,21 +34,20 @@ private static bool IsValid(string channelId) => if (string.IsNullOrWhiteSpace(channelIdOrUrl)) return null; - // Id + // Check if already passed an ID // UC3xnGqlcL3y-GXz5N3wiTJQ if (IsValid(channelIdOrUrl)) return channelIdOrUrl; - // URL + // Try to extract the ID from the URL // https://www.youtube.com/channel/UC3xnGqlcL3y-GXz5N3wiTJQ - var regularMatch = Regex + var id = Regex .Match(channelIdOrUrl, @"youtube\..+?/channel/(.*?)(?:\?|&|/|$)") .Groups[1] - .Value - .Pipe(WebUtility.UrlDecode); + .Value.Pipe(WebUtility.UrlDecode); - if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) - return regularMatch; + if (!string.IsNullOrWhiteSpace(id) && IsValid(id)) + return id; // Invalid input return null; diff --git a/YoutubeDownloader/Channels/ChannelSlug.cs b/YoutubeDownloader/Channels/ChannelSlug.cs index 00996d46..a26cca26 100644 --- a/YoutubeDownloader/Channels/ChannelSlug.cs +++ b/YoutubeDownloader/Channels/ChannelSlug.cs @@ -31,21 +31,20 @@ public readonly partial struct ChannelSlug if (string.IsNullOrWhiteSpace(channelSlugOrUrl)) return null; - // Slug + // Check if already passed a slug // Tyrrrz if (IsValid(channelSlugOrUrl)) return channelSlugOrUrl; - // URL + // Try to extract the slug from the URL // https://www.youtube.com/c/Tyrrrz - var regularMatch = Regex + var slug = Regex .Match(channelSlugOrUrl, @"youtube\..+?/c/(.*?)(?:\?|&|/|$)") .Groups[1] - .Value - .Pipe(WebUtility.UrlDecode); + .Value.Pipe(WebUtility.UrlDecode); - if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) - return regularMatch; + if (!string.IsNullOrWhiteSpace(slug) && IsValid(slug)) + return slug; // Invalid input return null; diff --git a/YoutubeDownloader/Channels/UserName.cs b/YoutubeDownloader/Channels/UserName.cs index 1d12407f..515b0cfe 100644 --- a/YoutubeDownloader/Channels/UserName.cs +++ b/YoutubeDownloader/Channels/UserName.cs @@ -32,21 +32,20 @@ private static bool IsValid(string userName) => if (string.IsNullOrWhiteSpace(userNameOrUrl)) return null; - // Name + // Check if already passed a user name // TheTyrrr if (IsValid(userNameOrUrl)) return userNameOrUrl; - // URL + // Try to extract the user name from the URL // https://www.youtube.com/user/TheTyrrr - var regularMatch = Regex + var userName = Regex .Match(userNameOrUrl, @"youtube\..+?/user/(.*?)(?:\?|&|/|$)") .Groups[1] - .Value - .Pipe(WebUtility.UrlDecode); + .Value.Pipe(WebUtility.UrlDecode); - if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) - return regularMatch; + if (!string.IsNullOrWhiteSpace(userName) && IsValid(userName)) + return userName; // Invalid input return null; diff --git a/YoutubeDownloader/Common/Author.cs b/YoutubeDownloader/Common/Author.cs index ecfbbc9c..559f594e 100644 --- a/YoutubeDownloader/Common/Author.cs +++ b/YoutubeDownloader/Common/Author.cs @@ -7,12 +7,12 @@ namespace YoutubeExplode.Common; /// /// Reference to a channel that owns a specific YouTube video or playlist. /// -public class Author +public class Author(ChannelId channelId, string channelTitle) { /// /// Channel ID. /// - public ChannelId ChannelId { get; } + public ChannelId ChannelId { get; } = channelId; /// /// Channel URL. @@ -22,21 +22,12 @@ public class Author /// /// Channel title. /// - public string ChannelTitle { get; } + public string ChannelTitle { get; } = channelTitle; /// [Obsolete("Use ChannelTitle instead."), ExcludeFromCodeCoverage] public string Title => ChannelTitle; - /// - /// Initializes an instance of . - /// - public Author(ChannelId channelId, string channelTitle) - { - ChannelId = channelId; - ChannelTitle = channelTitle; - } - /// [ExcludeFromCodeCoverage] public override string ToString() => ChannelTitle; diff --git a/YoutubeDownloader/Common/Batch.cs b/YoutubeDownloader/Common/Batch.cs index f6187e3e..c9645ad4 100644 --- a/YoutubeDownloader/Common/Batch.cs +++ b/YoutubeDownloader/Common/Batch.cs @@ -6,18 +6,13 @@ namespace YoutubeExplode.Common; /// /// Generic collection of items returned by a single request. /// -public class Batch +public class Batch(IReadOnlyList items) where T : IBatchItem { /// /// Items included in the batch. /// - public IReadOnlyList Items { get; } - - /// - /// Initializes an instance of . - /// - public Batch(IReadOnlyList items) => Items = items; + public IReadOnlyList Items { get; } = items; } internal static class Batch diff --git a/YoutubeDownloader/Common/Resolution.cs b/YoutubeDownloader/Common/Resolution.cs index f9429206..3dc2d5a3 100644 --- a/YoutubeDownloader/Common/Resolution.cs +++ b/YoutubeDownloader/Common/Resolution.cs @@ -6,32 +6,23 @@ namespace YoutubeExplode.Common; /// /// Resolution of an image or a video. /// -public readonly partial struct Resolution +public readonly partial struct Resolution(int width, int height) { /// /// Viewport width, measured in pixels. /// - public int Width { get; } + public int Width { get; } = width; /// /// Viewport height, measured in pixels. /// - public int Height { get; } + public int Height { get; } = height; /// /// Viewport area (i.e. width multiplied by height). /// public int Area => Width * Height; - /// - /// Initializes an instance of . - /// - public Resolution(int width, int height) - { - Width = width; - Height = height; - } - /// [ExcludeFromCodeCoverage] public override string ToString() => $"{Width}x{Height}"; diff --git a/YoutubeDownloader/Common/Thumbnail.cs b/YoutubeDownloader/Common/Thumbnail.cs index 691ad8bf..4c58498f 100644 --- a/YoutubeDownloader/Common/Thumbnail.cs +++ b/YoutubeDownloader/Common/Thumbnail.cs @@ -9,26 +9,17 @@ namespace YoutubeExplode.Common; /// /// Thumbnail image. /// -public partial class Thumbnail +public partial class Thumbnail(string url, Resolution resolution) { /// /// Thumbnail URL. /// - public string Url { get; } + public string Url { get; } = url; /// /// Thumbnail resolution. /// - public Resolution Resolution { get; } - - /// - /// Initializes an instance of . - /// - public Thumbnail(string url, Resolution resolution) - { - Url = url; - Resolution = resolution; - } + public Resolution Resolution { get; } = resolution; /// [ExcludeFromCodeCoverage] @@ -38,8 +29,7 @@ public Thumbnail(string url, Resolution resolution) public partial class Thumbnail { internal static IReadOnlyList GetDefaultSet(VideoId videoId) => - new[] - { + [ new Thumbnail( $"https://img.youtube.com/vi/{videoId}/default.jpg", new Resolution(120, 90) @@ -52,7 +42,7 @@ internal static IReadOnlyList GetDefaultSet(VideoId videoId) => $"https://img.youtube.com/vi/{videoId}/hqdefault.jpg", new Resolution(480, 360) ) - }; + ]; } /// diff --git a/YoutubeDownloader/Exceptions/PlaylistUnavailableException.cs b/YoutubeDownloader/Exceptions/PlaylistUnavailableException.cs index 696ad1b1..a7d1d8aa 100644 --- a/YoutubeDownloader/Exceptions/PlaylistUnavailableException.cs +++ b/YoutubeDownloader/Exceptions/PlaylistUnavailableException.cs @@ -3,11 +3,4 @@ namespace YoutubeExplode.Exceptions; /// /// Exception thrown when the requested playlist is unavailable. /// -public class PlaylistUnavailableException : YoutubeExplodeException -{ - /// - /// Initializes an instance of . - /// - public PlaylistUnavailableException(string message) - : base(message) { } -} +public class PlaylistUnavailableException(string message) : YoutubeExplodeException(message); diff --git a/YoutubeDownloader/Exceptions/RequestLimitExceededException.cs b/YoutubeDownloader/Exceptions/RequestLimitExceededException.cs index a52cf053..8b540ac9 100644 --- a/YoutubeDownloader/Exceptions/RequestLimitExceededException.cs +++ b/YoutubeDownloader/Exceptions/RequestLimitExceededException.cs @@ -3,11 +3,4 @@ namespace YoutubeExplode.Exceptions; /// /// Exception thrown when YouTube denies a request because the client has exceeded rate limit. /// -public class RequestLimitExceededException : YoutubeExplodeException -{ - /// - /// Initializes an instance of . - /// - public RequestLimitExceededException(string message) - : base(message) { } -} +public class RequestLimitExceededException(string message) : YoutubeExplodeException(message); diff --git a/YoutubeDownloader/Exceptions/VideoRequiresPurchaseException.cs b/YoutubeDownloader/Exceptions/VideoRequiresPurchaseException.cs index 6325c2c8..32bcc95d 100644 --- a/YoutubeDownloader/Exceptions/VideoRequiresPurchaseException.cs +++ b/YoutubeDownloader/Exceptions/VideoRequiresPurchaseException.cs @@ -5,16 +5,11 @@ namespace YoutubeExplode.Exceptions; /// /// Exception thrown when the requested video requires purchase. /// -public class VideoRequiresPurchaseException : VideoUnplayableException +public class VideoRequiresPurchaseException(string message, VideoId previewVideoId) + : VideoUnplayableException(message) { /// /// ID of a free preview video which is used as promotion for the original video. /// - public VideoId PreviewVideoId { get; } - - /// - /// Initializes an instance of - /// - public VideoRequiresPurchaseException(string message, VideoId previewVideoId) - : base(message) => PreviewVideoId = previewVideoId; + public VideoId PreviewVideoId { get; } = previewVideoId; } diff --git a/YoutubeDownloader/Exceptions/VideoUnavailableException.cs b/YoutubeDownloader/Exceptions/VideoUnavailableException.cs index af7f0e1a..1727757e 100644 --- a/YoutubeDownloader/Exceptions/VideoUnavailableException.cs +++ b/YoutubeDownloader/Exceptions/VideoUnavailableException.cs @@ -3,11 +3,4 @@ namespace YoutubeExplode.Exceptions; /// /// Exception thrown when the requested video is unavailable. /// -public class VideoUnavailableException : VideoUnplayableException -{ - /// - /// Initializes an instance of . - /// - public VideoUnavailableException(string message) - : base(message) { } -} +public class VideoUnavailableException(string message) : VideoUnplayableException(message); diff --git a/YoutubeDownloader/Exceptions/VideoUnplayableException.cs b/YoutubeDownloader/Exceptions/VideoUnplayableException.cs index c7c65f7f..3964c781 100644 --- a/YoutubeDownloader/Exceptions/VideoUnplayableException.cs +++ b/YoutubeDownloader/Exceptions/VideoUnplayableException.cs @@ -3,11 +3,4 @@ namespace YoutubeExplode.Exceptions; /// /// Exception thrown when the requested video is unplayable. /// -public class VideoUnplayableException : YoutubeExplodeException -{ - /// - /// Initializes an instance of . - /// - public VideoUnplayableException(string message) - : base(message) { } -} +public class VideoUnplayableException(string message) : YoutubeExplodeException(message); diff --git a/YoutubeDownloader/Exceptions/YoutubeExplodeException.cs b/YoutubeDownloader/Exceptions/YoutubeExplodeException.cs index e8385d66..91875d45 100644 --- a/YoutubeDownloader/Exceptions/YoutubeExplodeException.cs +++ b/YoutubeDownloader/Exceptions/YoutubeExplodeException.cs @@ -5,12 +5,4 @@ namespace YoutubeExplode.Exceptions; /// /// Exception thrown within . /// -public class YoutubeExplodeException : Exception -{ - /// - /// Initializes an instance of . - /// - /// - public YoutubeExplodeException(string message) - : base(message) { } -} +public class YoutubeExplodeException(string message) : Exception(message); diff --git a/YoutubeDownloader/Playlists/Playlist.cs b/YoutubeDownloader/Playlists/Playlist.cs index 141c9dec..37a770fd 100644 --- a/YoutubeDownloader/Playlists/Playlist.cs +++ b/YoutubeDownloader/Playlists/Playlist.cs @@ -7,45 +7,33 @@ namespace YoutubeExplode.Playlists; /// /// Metadata associated with a YouTube playlist. /// -public class Playlist : IPlaylist +public class Playlist( + PlaylistId id, + string title, + Author? author, + string description, + IReadOnlyList thumbnails +) : IPlaylist { /// - public PlaylistId Id { get; } + public PlaylistId Id { get; } = id; /// public string Url => $"https://www.youtube.com/playlist?list={Id}"; /// - public string Title { get; } + public string Title { get; } = title; /// - public Author? Author { get; } + public Author? Author { get; } = author; /// /// Playlist description. /// - public string Description { get; } + public string Description { get; } = description; /// - public IReadOnlyList Thumbnails { get; } - - /// - /// Initializes an instance of . - /// - public Playlist( - PlaylistId id, - string title, - Author? author, - string description, - IReadOnlyList thumbnails - ) - { - Id = id; - Title = title; - Author = author; - Description = description; - Thumbnails = thumbnails; - } + public IReadOnlyList Thumbnails { get; } = thumbnails; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Playlists/PlaylistClient.cs b/YoutubeDownloader/Playlists/PlaylistClient.cs index 4ef40271..35d8dcdd 100644 --- a/YoutubeDownloader/Playlists/PlaylistClient.cs +++ b/YoutubeDownloader/Playlists/PlaylistClient.cs @@ -13,14 +13,9 @@ namespace YoutubeExplode.Playlists; /// /// Operations related to YouTube playlists. /// -public class PlaylistClient +public class PlaylistClient(HttpClient http) { - private readonly PlaylistController _controller; - - /// - /// Initializes an instance of . - /// - public PlaylistClient(HttpClient http) => _controller = new PlaylistController(http); + private readonly PlaylistController _controller = new(http); /// /// Gets the metadata associated with the specified playlist. @@ -34,7 +29,7 @@ public async ValueTask GetAsync( var title = response.Title - ?? throw new YoutubeExplodeException("Could not extract playlist title."); + ?? throw new YoutubeExplodeException("Failed to extract the playlist title."); // System playlists have no author var channelId = response.ChannelId; @@ -48,19 +43,19 @@ channelId is not null && channelTitle is not null var description = response.Description ?? ""; var thumbnails = response - .Thumbnails - .Select(t => + .Thumbnails.Select(t => { var thumbnailUrl = - t.Url ?? throw new YoutubeExplodeException("Could not extract thumbnail URL."); + t.Url + ?? throw new YoutubeExplodeException("Failed to extract the thumbnail URL."); var thumbnailWidth = t.Width - ?? throw new YoutubeExplodeException("Could not extract thumbnail width."); + ?? throw new YoutubeExplodeException("Failed to extract the thumbnail width."); var thumbnailHeight = t.Height - ?? throw new YoutubeExplodeException("Could not extract thumbnail height."); + ?? throw new YoutubeExplodeException("Failed to extract the thumbnail height."); var thumbnailResolution = new Resolution(thumbnailWidth, thumbnailHeight); @@ -100,13 +95,13 @@ public async IAsyncEnumerable> GetVideoBatchesAsync( { var videoId = videoData.Id - ?? throw new YoutubeExplodeException("Could not extract video ID."); + ?? throw new YoutubeExplodeException("Failed to extract the video ID."); lastVideoId = videoId; lastVideoIndex = videoData.Index - ?? throw new YoutubeExplodeException("Could not extract video index."); + ?? throw new YoutubeExplodeException("Failed to extract the video index."); // Don't yield the same video twice if (!encounteredIds.Add(videoId)) @@ -120,32 +115,31 @@ public async IAsyncEnumerable> GetVideoBatchesAsync( var videoChannelTitle = videoData.Author - ?? throw new YoutubeExplodeException("Could not extract video author."); + ?? throw new YoutubeExplodeException("Failed to extract the video author."); var videoChannelId = videoData.ChannelId - ?? throw new YoutubeExplodeException("Could not extract video channel ID."); + ?? throw new YoutubeExplodeException("Failed to extract the video channel ID."); var videoThumbnails = videoData - .Thumbnails - .Select(t => + .Thumbnails.Select(t => { var thumbnailUrl = t.Url ?? throw new YoutubeExplodeException( - "Could not extract thumbnail URL." + "Failed to extract the thumbnail URL." ); var thumbnailWidth = t.Width ?? throw new YoutubeExplodeException( - "Could not extract thumbnail width." + "Failed to extract the thumbnail width." ); var thumbnailHeight = t.Height ?? throw new YoutubeExplodeException( - "Could not extract thumbnail height." + "Failed to extract the thumbnail height." ); var thumbnailResolution = new Resolution(thumbnailWidth, thumbnailHeight); diff --git a/YoutubeDownloader/Playlists/PlaylistController.cs b/YoutubeDownloader/Playlists/PlaylistController.cs index dd4618ab..479db400 100644 --- a/YoutubeDownloader/Playlists/PlaylistController.cs +++ b/YoutubeDownloader/Playlists/PlaylistController.cs @@ -7,12 +7,8 @@ namespace YoutubeExplode.Playlists; -internal class PlaylistController +internal class PlaylistController(HttpClient http) { - private readonly HttpClient _http; - - public PlaylistController(HttpClient http) => _http = http; - // Works only with user-made playlists public async ValueTask GetPlaylistBrowseResponseAsync( PlaylistId playlistId, @@ -43,7 +39,7 @@ public async ValueTask GetPlaylistBrowseResponseAsync( ) }; - using var response = await _http.SendAsync(request, cancellationToken); + using var response = await http.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); var playlistResponse = PlaylistBrowseResponse.Parse( @@ -94,7 +90,7 @@ public async ValueTask GetPlaylistNextResponseAsync( ) }; - using var response = await _http.SendAsync(request, cancellationToken); + using var response = await http.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); var playlistResponse = PlaylistNextResponse.Parse( diff --git a/YoutubeDownloader/Playlists/PlaylistId.cs b/YoutubeDownloader/Playlists/PlaylistId.cs index 3d765d75..f4217336 100644 --- a/YoutubeDownloader/Playlists/PlaylistId.cs +++ b/YoutubeDownloader/Playlists/PlaylistId.cs @@ -34,54 +34,58 @@ private static bool IsValid(string playlistId) => if (string.IsNullOrWhiteSpace(playlistIdOrUrl)) return null; - // Id + // Check if already passed an ID // PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H if (IsValid(playlistIdOrUrl)) return playlistIdOrUrl; - // Regular URL + // Try to extract the ID from the URL // https://www.youtube.com/playlist?list=PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H - var regularMatch = Regex - .Match(playlistIdOrUrl, @"youtube\..+?/playlist.*?list=(.*?)(?:&|/|$)") - .Groups[1] - .Value - .Pipe(WebUtility.UrlDecode); + { + var id = Regex + .Match(playlistIdOrUrl, @"youtube\..+?/playlist.*?list=(.*?)(?:&|/|$)") + .Groups[1] + .Value.Pipe(WebUtility.UrlDecode); - if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) - return regularMatch; + if (!string.IsNullOrWhiteSpace(id) && IsValid(id)) + return id; + } - // Composite URL (video + playlist) + // Try to extract the ID from the URL (playlist + video) // https://www.youtube.com/watch?v=b8m9zhNAgKs&list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr - var compositeMatch = Regex - .Match(playlistIdOrUrl, @"youtube\..+?/watch.*?list=(.*?)(?:&|/|$)") - .Groups[1] - .Value - .Pipe(WebUtility.UrlDecode); + { + var id = Regex + .Match(playlistIdOrUrl, @"youtube\..+?/watch.*?list=(.*?)(?:&|/|$)") + .Groups[1] + .Value.Pipe(WebUtility.UrlDecode); - if (!string.IsNullOrWhiteSpace(compositeMatch) && IsValid(compositeMatch)) - return compositeMatch; + if (!string.IsNullOrWhiteSpace(id) && IsValid(id)) + return id; + } - // Short composite URL (video + playlist) + // Try to extract the ID from the URL (playlist + video, shortened) // https://youtu.be/b8m9zhNAgKs/?list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr - var shortCompositeMatch = Regex - .Match(playlistIdOrUrl, @"youtu\.be/.*?/.*?list=(.*?)(?:&|/|$)") - .Groups[1] - .Value - .Pipe(WebUtility.UrlDecode); + { + var id = Regex + .Match(playlistIdOrUrl, @"youtu\.be/.*?/.*?list=(.*?)(?:&|/|$)") + .Groups[1] + .Value.Pipe(WebUtility.UrlDecode); - if (!string.IsNullOrWhiteSpace(shortCompositeMatch) && IsValid(shortCompositeMatch)) - return shortCompositeMatch; + if (!string.IsNullOrWhiteSpace(id) && IsValid(id)) + return id; + } - // Embed URL + // Try to extract the ID from the URL (playlist + video, embedded) // https://www.youtube.com/embed/b8m9zhNAgKs/?list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr - var embedCompositeMatch = Regex - .Match(playlistIdOrUrl, @"youtube\..+?/embed/.*?/.*?list=(.*?)(?:&|/|$)") - .Groups[1] - .Value - .Pipe(WebUtility.UrlDecode); - - if (!string.IsNullOrWhiteSpace(embedCompositeMatch) && IsValid(embedCompositeMatch)) - return embedCompositeMatch; + { + var id = Regex + .Match(playlistIdOrUrl, @"youtube\..+?/embed/.*?/.*?list=(.*?)(?:&|/|$)") + .Groups[1] + .Value.Pipe(WebUtility.UrlDecode); + + if (!string.IsNullOrWhiteSpace(id) && IsValid(id)) + return id; + } // Invalid input return null; diff --git a/YoutubeDownloader/Playlists/PlaylistVideo.cs b/YoutubeDownloader/Playlists/PlaylistVideo.cs index 8b50a68e..227e6b3a 100644 --- a/YoutubeDownloader/Playlists/PlaylistVideo.cs +++ b/YoutubeDownloader/Playlists/PlaylistVideo.cs @@ -9,64 +9,51 @@ namespace YoutubeExplode.Playlists; /// /// Metadata associated with a YouTube video included in a playlist. /// -public class PlaylistVideo : IVideo, IBatchItem +public class PlaylistVideo( + PlaylistId playlistId, + VideoId id, + string title, + Author author, + TimeSpan? duration, + IReadOnlyList thumbnails +) : IVideo, IBatchItem { + /// + /// Initializes an instance of . + /// + // Binary backwards compatibility (PlaylistId was added) + [Obsolete("Use the other constructor instead."), ExcludeFromCodeCoverage] + public PlaylistVideo( + VideoId id, + string title, + Author author, + TimeSpan? duration, + IReadOnlyList thumbnails + ) + : this(default, id, title, author, duration, thumbnails) { } + /// /// ID of the playlist that contains this video. /// - public PlaylistId PlaylistId { get; } + public PlaylistId PlaylistId { get; } = playlistId; /// - public VideoId Id { get; } + public VideoId Id { get; } = id; /// public string Url => $"https://www.youtube.com/watch?v={Id}&list={PlaylistId}"; /// - public string Title { get; } + public string Title { get; } = title; /// - public Author Author { get; } + public Author Author { get; } = author; /// - public TimeSpan? Duration { get; } + public TimeSpan? Duration { get; } = duration; /// - public IReadOnlyList Thumbnails { get; } - - /// - /// Initializes an instance of . - /// - public PlaylistVideo( - PlaylistId playlistId, - VideoId id, - string title, - Author author, - TimeSpan? duration, - IReadOnlyList thumbnails - ) - { - PlaylistId = playlistId; - Id = id; - Title = title; - Author = author; - Duration = duration; - Thumbnails = thumbnails; - } - - /// - /// Initializes an instance of . - /// - // Binary backwards compatibility (PlaylistId was added) - [Obsolete("Use the other constructor instead."), ExcludeFromCodeCoverage] - public PlaylistVideo( - VideoId id, - string title, - Author author, - TimeSpan? duration, - IReadOnlyList thumbnails - ) - : this(default, id, title, author, duration, thumbnails) { } + public IReadOnlyList Thumbnails { get; } = thumbnails; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Search/ChannelSearchResult.cs b/YoutubeDownloader/Search/ChannelSearchResult.cs index 8bdaf94b..7fc832b6 100644 --- a/YoutubeDownloader/Search/ChannelSearchResult.cs +++ b/YoutubeDownloader/Search/ChannelSearchResult.cs @@ -8,29 +8,21 @@ namespace YoutubeExplode.Search; /// /// Metadata associated with a YouTube channel returned by a search query. /// -public class ChannelSearchResult : ISearchResult, IChannel +public class ChannelSearchResult(ChannelId id, string title, IReadOnlyList thumbnails) + : ISearchResult, + IChannel { /// - public ChannelId Id { get; } + public ChannelId Id { get; } = id; /// public string Url => $"https://www.youtube.com/channel/{Id}"; /// - public string Title { get; } + public string Title { get; } = title; /// - public IReadOnlyList Thumbnails { get; } - - /// - /// Initializes an instance of . - /// - public ChannelSearchResult(ChannelId id, string title, IReadOnlyList thumbnails) - { - Id = id; - Title = title; - Thumbnails = thumbnails; - } + public IReadOnlyList Thumbnails { get; } = thumbnails; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Search/PlaylistSearchResult.cs b/YoutubeDownloader/Search/PlaylistSearchResult.cs index 49127f0e..eda25e5e 100644 --- a/YoutubeDownloader/Search/PlaylistSearchResult.cs +++ b/YoutubeDownloader/Search/PlaylistSearchResult.cs @@ -8,38 +8,27 @@ namespace YoutubeExplode.Search; /// /// Metadata associated with a YouTube playlist returned by a search query. /// -public class PlaylistSearchResult : ISearchResult, IPlaylist +public class PlaylistSearchResult( + PlaylistId id, + string title, + Author? author, + IReadOnlyList thumbnails +) : ISearchResult, IPlaylist { /// - public PlaylistId Id { get; } + public PlaylistId Id { get; } = id; /// public string Url => $"https://www.youtube.com/playlist?list={Id}"; /// - public string Title { get; } + public string Title { get; } = title; /// - public Author? Author { get; } + public Author? Author { get; } = author; /// - public IReadOnlyList Thumbnails { get; } - - /// - /// Initializes an instance of . - /// - public PlaylistSearchResult( - PlaylistId id, - string title, - Author? author, - IReadOnlyList thumbnails - ) - { - Id = id; - Title = title; - Author = author; - Thumbnails = thumbnails; - } + public IReadOnlyList Thumbnails { get; } = thumbnails; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Search/SearchClient.cs b/YoutubeDownloader/Search/SearchClient.cs index b896259b..521631c5 100644 --- a/YoutubeDownloader/Search/SearchClient.cs +++ b/YoutubeDownloader/Search/SearchClient.cs @@ -14,14 +14,9 @@ namespace YoutubeExplode.Search; /// /// Operations related to YouTube search. /// -public class SearchClient +public class SearchClient(HttpClient http) { - private readonly SearchController _controller; - - /// - /// Initializes an instance of . - /// - public SearchClient(HttpClient http) => _controller = new SearchController(http); + private readonly SearchController _controller = new(http); /// /// Enumerates batches of search results returned by the specified query. @@ -57,7 +52,7 @@ public async IAsyncEnumerable> GetResultBatchesAsync( var videoId = videoData.Id - ?? throw new YoutubeExplodeException("Could not extract video ID."); + ?? throw new YoutubeExplodeException("Failed to extract the video ID."); // Don't yield the same result twice if (!encounteredIds.Add(videoId)) @@ -65,36 +60,35 @@ public async IAsyncEnumerable> GetResultBatchesAsync( var videoTitle = videoData.Title - ?? throw new YoutubeExplodeException("Could not extract video title."); + ?? throw new YoutubeExplodeException("Failed to extract the video title."); var videoChannelTitle = videoData.Author - ?? throw new YoutubeExplodeException("Could not extract video author."); + ?? throw new YoutubeExplodeException("Failed to extract the video author."); var videoChannelId = videoData.ChannelId - ?? throw new YoutubeExplodeException("Could not extract video channel ID."); + ?? throw new YoutubeExplodeException("Failed to extract the video channel ID."); var videoThumbnails = videoData - .Thumbnails - .Select(t => + .Thumbnails.Select(t => { var thumbnailUrl = t.Url ?? throw new YoutubeExplodeException( - "Could not extract video thumbnail URL." + "Failed to extract the video thumbnail URL." ); var thumbnailWidth = t.Width ?? throw new YoutubeExplodeException( - "Could not extract video thumbnail width." + "Failed to extract the video thumbnail width." ); var thumbnailHeight = t.Height ?? throw new YoutubeExplodeException( - "Could not extract video thumbnail height." + "Failed to extract the video thumbnail height." ); var thumbnailResolution = new Resolution(thumbnailWidth, thumbnailHeight); @@ -126,7 +120,7 @@ public async IAsyncEnumerable> GetResultBatchesAsync( var playlistId = playlistData.Id - ?? throw new YoutubeExplodeException("Could not extract playlist ID."); + ?? throw new YoutubeExplodeException("Failed to extract the playlist ID."); // Don't yield the same result twice if (!encounteredIds.Add(playlistId)) @@ -134,7 +128,7 @@ public async IAsyncEnumerable> GetResultBatchesAsync( var playlistTitle = playlistData.Title - ?? throw new YoutubeExplodeException("Could not extract playlist title."); + ?? throw new YoutubeExplodeException("Failed to extract the playlist title."); // System playlists have no author var playlistAuthor = @@ -144,25 +138,24 @@ public async IAsyncEnumerable> GetResultBatchesAsync( : null; var playlistThumbnails = playlistData - .Thumbnails - .Select(t => + .Thumbnails.Select(t => { var thumbnailUrl = t.Url ?? throw new YoutubeExplodeException( - "Could not extract playlist thumbnail URL." + "Failed to extract the playlist thumbnail URL." ); var thumbnailWidth = t.Width ?? throw new YoutubeExplodeException( - "Could not extract playlist thumbnail width." + "Failed to extract the playlist thumbnail width." ); var thumbnailHeight = t.Height ?? throw new YoutubeExplodeException( - "Could not extract playlist thumbnail height." + "Failed to extract the playlist thumbnail height." ); var thumbnailResolution = new Resolution(thumbnailWidth, thumbnailHeight); @@ -192,32 +185,31 @@ public async IAsyncEnumerable> GetResultBatchesAsync( var channelId = channelData.Id - ?? throw new YoutubeExplodeException("Could not extract channel ID."); + ?? throw new YoutubeExplodeException("Failed to extract the channel ID."); var channelTitle = channelData.Title - ?? throw new YoutubeExplodeException("Could not extract channel title."); + ?? throw new YoutubeExplodeException("Failed to extract the channel title."); var channelThumbnails = channelData - .Thumbnails - .Select(t => + .Thumbnails.Select(t => { var thumbnailUrl = t.Url ?? throw new YoutubeExplodeException( - "Could not extract channel thumbnail URL." + "Failed to extract the channel thumbnail URL." ); var thumbnailWidth = t.Width ?? throw new YoutubeExplodeException( - "Could not extract channel thumbnail width." + "Failed to extract the channel thumbnail width." ); var thumbnailHeight = t.Height ?? throw new YoutubeExplodeException( - "Could not extract channel thumbnail height." + "Failed to extract the channel thumbnail height." ); var thumbnailResolution = new Resolution(thumbnailWidth, thumbnailHeight); diff --git a/YoutubeDownloader/Search/SearchController.cs b/YoutubeDownloader/Search/SearchController.cs index 81b13a28..2a3e5adc 100644 --- a/YoutubeDownloader/Search/SearchController.cs +++ b/YoutubeDownloader/Search/SearchController.cs @@ -5,12 +5,8 @@ namespace YoutubeExplode.Search; -internal class SearchController +internal class SearchController(HttpClient http) { - private readonly HttpClient _http; - - public SearchController(HttpClient http) => _http = http; - public async ValueTask GetSearchResponseAsync( string searchQuery, SearchFilter searchFilter, @@ -50,7 +46,7 @@ public async ValueTask GetSearchResponseAsync( ) }; - using var response = await _http.SendAsync(request, cancellationToken); + using var response = await http.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); return SearchResponse.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); diff --git a/YoutubeDownloader/Search/VideoSearchResult.cs b/YoutubeDownloader/Search/VideoSearchResult.cs index dd847578..df158bab 100644 --- a/YoutubeDownloader/Search/VideoSearchResult.cs +++ b/YoutubeDownloader/Search/VideoSearchResult.cs @@ -9,43 +9,31 @@ namespace YoutubeExplode.Search; /// /// Metadata associated with a YouTube video returned by a search query. /// -public class VideoSearchResult : ISearchResult, IVideo +public class VideoSearchResult( + VideoId id, + string title, + Author author, + TimeSpan? duration, + IReadOnlyList thumbnails +) : ISearchResult, IVideo { /// - public VideoId Id { get; } + public VideoId Id { get; } = id; /// public string Url => $"https://www.youtube.com/watch?v={Id}"; /// - public string Title { get; } + public string Title { get; } = title; /// - public Author Author { get; } + public Author Author { get; } = author; /// - public TimeSpan? Duration { get; } + public TimeSpan? Duration { get; } = duration; /// - public IReadOnlyList Thumbnails { get; } - - /// - /// Initializes an instance of . - /// - public VideoSearchResult( - VideoId id, - string title, - Author author, - TimeSpan? duration, - IReadOnlyList thumbnails - ) - { - Id = id; - Title = title; - Author = author; - Duration = duration; - Thumbnails = thumbnails; - } + public IReadOnlyList Thumbnails { get; } = thumbnails; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Utils/ClientDelegatingHandler.cs b/YoutubeDownloader/Utils/ClientDelegatingHandler.cs index 02155d1d..4cc50e6e 100644 --- a/YoutubeDownloader/Utils/ClientDelegatingHandler.cs +++ b/YoutubeDownloader/Utils/ClientDelegatingHandler.cs @@ -7,17 +7,9 @@ namespace YoutubeExplode.Utils; // Like DelegatingHandler, but wraps an HttpClient instead of an HttpMessageHandler. // Used to extend an externally provided HttpClient with additional behavior. -internal abstract class ClientDelegatingHandler : HttpMessageHandler +internal abstract class ClientDelegatingHandler(HttpClient http, bool disposeClient = false) + : HttpMessageHandler { - private readonly HttpClient _http; - private readonly bool _disposeClient; - - protected ClientDelegatingHandler(HttpClient http, bool disposeClient = false) - { - _http = http; - _disposeClient = disposeClient; - } - protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken @@ -27,7 +19,7 @@ CancellationToken cancellationToken // in order to pass the request from one HttpClient to another. using var clonedRequest = request.Clone(); - return await _http.SendAsync( + return await http.SendAsync( clonedRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken @@ -36,8 +28,8 @@ CancellationToken cancellationToken protected override void Dispose(bool disposing) { - if (disposing && _disposeClient) - _http.Dispose(); + if (disposing && disposeClient) + http.Dispose(); base.Dispose(disposing); } diff --git a/YoutubeDownloader/Utils/Extensions/HttpExtensions.cs b/YoutubeDownloader/Utils/Extensions/HttpExtensions.cs index 2bc31677..3146a053 100644 --- a/YoutubeDownloader/Utils/Extensions/HttpExtensions.cs +++ b/YoutubeDownloader/Utils/Extensions/HttpExtensions.cs @@ -8,16 +8,12 @@ namespace YoutubeExplode.Utils.Extensions; internal static class HttpExtensions { - private class NonDisposableHttpContent : HttpContent + private class NonDisposableHttpContent(HttpContent content) : HttpContent { - private readonly HttpContent _content; - - public NonDisposableHttpContent(HttpContent content) => _content = content; - protected override async Task SerializeToStreamAsync( Stream stream, TransportContext? context - ) => await _content.CopyToAsync(stream); + ) => await content.CopyToAsync(stream); protected override bool TryComputeLength(out long length) { @@ -63,19 +59,4 @@ public static async ValueTask HeadAsync( cancellationToken ); } - - public static async ValueTask TryGetContentLengthAsync( - this HttpClient http, - string requestUri, - bool ensureSuccess = true, - CancellationToken cancellationToken = default - ) - { - using var response = await http.HeadAsync(requestUri, cancellationToken); - - if (ensureSuccess) - response.EnsureSuccessStatusCode(); - - return response.Content.Headers.ContentLength; - } } diff --git a/YoutubeDownloader/Utils/Extensions/StringExtensions.cs b/YoutubeDownloader/Utils/Extensions/StringExtensions.cs index f23e6041..b067efc1 100644 --- a/YoutubeDownloader/Utils/Extensions/StringExtensions.cs +++ b/YoutubeDownloader/Utils/Extensions/StringExtensions.cs @@ -18,7 +18,6 @@ public static string SubstringUntil( ) { var index = str.IndexOf(sub, comparison); - return index < 0 ? str : str[..index]; } diff --git a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaption.cs b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaption.cs index 1b3f8a3e..fe993604 100644 --- a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaption.cs +++ b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaption.cs @@ -8,22 +8,27 @@ namespace YoutubeExplode.Videos.ClosedCaptions; /// /// Individual closed caption contained within a track. /// -public class ClosedCaption +public class ClosedCaption( + string text, + TimeSpan offset, + TimeSpan duration, + IReadOnlyList parts +) { /// /// Text displayed by the caption. /// - public string Text { get; } + public string Text { get; } = text; /// /// Time at which the caption starts displaying. /// - public TimeSpan Offset { get; } + public TimeSpan Offset { get; } = offset; /// /// Duration of time for which the caption is displayed. /// - public TimeSpan Duration { get; } + public TimeSpan Duration { get; } = duration; /// /// Caption parts, usually representing individual words. @@ -31,23 +36,7 @@ public class ClosedCaption /// /// May be empty because not all captions have parts. /// - public IReadOnlyList Parts { get; } - - /// - /// Initializes an instance of . - /// - public ClosedCaption( - string text, - TimeSpan offset, - TimeSpan duration, - IReadOnlyList parts - ) - { - Text = text; - Offset = offset; - Duration = duration; - Parts = parts; - } + public IReadOnlyList Parts { get; } = parts; /// /// Gets the caption part displayed at the specified point in time, relative to the caption's own offset. diff --git a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionClient.cs b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionClient.cs index 157b8a5a..d267144d 100644 --- a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionClient.cs +++ b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionClient.cs @@ -15,14 +15,9 @@ namespace YoutubeExplode.Videos.ClosedCaptions; /// /// Operations related to closed captions of YouTube videos. /// -public class ClosedCaptionClient +public class ClosedCaptionClient(HttpClient http) { - private readonly ClosedCaptionController _controller; - - /// - /// Initializes an instance of . - /// - public ClosedCaptionClient(HttpClient http) => _controller = new ClosedCaptionController(http); + private readonly ClosedCaptionController _controller = new(http); private async IAsyncEnumerable GetClosedCaptionTrackInfosAsync( VideoId videoId, @@ -39,15 +34,16 @@ private async IAsyncEnumerable GetClosedCaptionTrackInfo foreach (var trackData in playerResponse.ClosedCaptionTracks) { var url = - trackData.Url ?? throw new YoutubeExplodeException("Could not extract track URL."); + trackData.Url + ?? throw new YoutubeExplodeException("Failed to extract the track URL."); var languageCode = trackData.LanguageCode - ?? throw new YoutubeExplodeException("Could not extract track language code."); + ?? throw new YoutubeExplodeException("Failed to extract the track language code."); var languageName = trackData.LanguageName - ?? throw new YoutubeExplodeException("Could not extract track language name."); + ?? throw new YoutubeExplodeException("Failed to extract the track language name."); yield return new ClosedCaptionTrackInfo( url, @@ -103,7 +99,9 @@ private async IAsyncEnumerable GetClosedCaptionsAsync( var partOffset = partData.Offset - ?? throw new YoutubeExplodeException("Could not extract caption part offset."); + ?? throw new YoutubeExplodeException( + "Failed to extract the caption part offset." + ); var part = new ClosedCaptionPart(partText, partOffset); @@ -162,7 +160,19 @@ static string FormatTimestamp(TimeSpan value) => .Append(FormatTimestamp(caption.Offset + caption.Duration)) .AppendLine() // Content - .AppendLine(caption.Text); + .AppendLine( + caption.Text + // Caption text may contain valid SRT-formatted data in itself. + // This can happen, for example, if the subtitles for a YouTube video + // were imported from an SRT file, but something went wrong in the + // process, resulting in parts of the file being read as captions + // rather than control sequences. + // SRT file format does not provide any means of escaping special + // characters, so as a workaround we just replace the dashes in the + // arrow sequence with en-dashes, which look similar enough. + // https://github.com/Tyrrrz/YoutubeExplode/issues/755 + .Replace("-->", "––>", StringComparison.Ordinal) + ); await writer.WriteLineAsync(buffer.ToString()); buffer.Clear(); diff --git a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionController.cs b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionController.cs index efd3ff8b..7e67b1a6 100644 --- a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionController.cs +++ b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionController.cs @@ -7,11 +7,8 @@ namespace YoutubeExplode.Videos.ClosedCaptions; -internal class ClosedCaptionController : VideoController +internal class ClosedCaptionController(HttpClient http) : VideoController(http) { - public ClosedCaptionController(HttpClient http) - : base(http) { } - public async ValueTask GetClosedCaptionTrackResponseAsync( string url, CancellationToken cancellationToken = default diff --git a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionManifest.cs b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionManifest.cs index cd9f3521..1e2ed660 100644 --- a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionManifest.cs +++ b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionManifest.cs @@ -7,17 +7,12 @@ namespace YoutubeExplode.Videos.ClosedCaptions; /// /// Describes closed caption tracks available for a YouTube video. /// -public class ClosedCaptionManifest +public class ClosedCaptionManifest(IReadOnlyList tracks) { /// /// Available closed caption tracks. /// - public IReadOnlyList Tracks { get; } - - /// - /// Initializes an instance of . - /// - public ClosedCaptionManifest(IReadOnlyList tracks) => Tracks = tracks; + public IReadOnlyList Tracks { get; } = tracks; /// /// Gets the closed caption track in the specified language (identified by ISO-639-1 code or display name). diff --git a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionPart.cs b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionPart.cs index 31091e98..6e54c92d 100644 --- a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionPart.cs +++ b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionPart.cs @@ -1,31 +1,23 @@ using System; using System.Diagnostics.CodeAnalysis; +using AngleSharp.Media.Dom; namespace YoutubeExplode.Videos.ClosedCaptions; /// /// Individual closed caption part contained within a track. /// -public class ClosedCaptionPart +public class ClosedCaptionPart(string text, TimeSpan offset) { /// /// Text displayed by the caption part. /// - public string Text { get; } + public string Text { get; } = text; /// /// Time at which the caption part starts displaying, relative to the caption's own offset. /// - public TimeSpan Offset { get; } - - /// - /// Initializes an instance of . - /// - public ClosedCaptionPart(string text, TimeSpan offset) - { - Text = text; - Offset = offset; - } + public TimeSpan Offset { get; } = offset; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionTrack.cs b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionTrack.cs index 28319502..57966cd9 100644 --- a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionTrack.cs +++ b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionTrack.cs @@ -7,20 +7,12 @@ namespace YoutubeExplode.Videos.ClosedCaptions; /// /// Contains closed captions in a specific language. /// -public class ClosedCaptionTrack +public class ClosedCaptionTrack(IReadOnlyList captions) { /// /// Closed captions included in the track. /// - public IReadOnlyList Captions { get; } - - /// - /// Initializes an instance of . - /// - public ClosedCaptionTrack(IReadOnlyList captions) - { - Captions = captions; - } + public IReadOnlyList Captions { get; } = captions; /// /// Gets the caption displayed at the specified point in time. diff --git a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionTrackInfo.cs b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionTrackInfo.cs index 7802365a..c7120400 100644 --- a/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionTrackInfo.cs +++ b/YoutubeDownloader/Videos/ClosedCaptions/ClosedCaptionTrackInfo.cs @@ -5,32 +5,22 @@ namespace YoutubeExplode.Videos.ClosedCaptions; /// /// Metadata associated with a closed caption track of a YouTube video. /// -public class ClosedCaptionTrackInfo +public class ClosedCaptionTrackInfo(string url, Language language, bool isAutoGenerated) { /// /// Track URL. /// - public string Url { get; } + public string Url { get; } = url; /// /// Track language. /// - public Language Language { get; } + public Language Language { get; } = language; /// /// Whether the track was automatically generated. /// - public bool IsAutoGenerated { get; } - - /// - /// Initializes an instance of . - /// - public ClosedCaptionTrackInfo(string url, Language language, bool isAutoGenerated) - { - Url = url; - Language = language; - IsAutoGenerated = isAutoGenerated; - } + public bool IsAutoGenerated { get; } = isAutoGenerated; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Videos/ClosedCaptions/Language.cs b/YoutubeDownloader/Videos/ClosedCaptions/Language.cs index 0907295b..4d25af38 100644 --- a/YoutubeDownloader/Videos/ClosedCaptions/Language.cs +++ b/YoutubeDownloader/Videos/ClosedCaptions/Language.cs @@ -6,26 +6,18 @@ namespace YoutubeExplode.Videos.ClosedCaptions; /// /// Language information. /// -public readonly partial struct Language +public readonly partial struct Language(string code, string name) { /// - /// Two-letter (ISO 639-1) language code, possibly with a regional identifier (e.g. 'en' or 'en-US'). + /// Two-letter or three-letter language code, possibly with a regional identifier + /// (e.g. 'en' or 'en-US' or 'eng'). /// - public string Code { get; } + public string Code { get; } = code; /// /// Full international name of the language. /// - public string Name { get; } - - /// - /// Initializes an instance of . - /// - public Language(string code, string name) - { - Code = code; - Name = name; - } + public string Name { get; } = name; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Videos/Engagement.cs b/YoutubeDownloader/Videos/Engagement.cs index 75b89446..08da2a1c 100644 --- a/YoutubeDownloader/Videos/Engagement.cs +++ b/YoutubeDownloader/Videos/Engagement.cs @@ -5,17 +5,17 @@ namespace YoutubeExplode.Videos; /// /// Engagement statistics. /// -public class Engagement +public class Engagement(long viewCount, long likeCount, long dislikeCount) { /// /// View count. /// - public long ViewCount { get; } + public long ViewCount { get; } = viewCount; /// /// Like count. /// - public long LikeCount { get; } + public long LikeCount { get; } = likeCount; /// /// Dislike count. @@ -23,7 +23,7 @@ public class Engagement /// /// YouTube no longer shows dislikes, so this value is always 0. /// - public long DislikeCount { get; } + public long DislikeCount { get; } = dislikeCount; /// /// Average rating. @@ -34,16 +34,6 @@ public class Engagement public double AverageRating => LikeCount + DislikeCount != 0 ? 1 + 4.0 * LikeCount / (LikeCount + DislikeCount) : 0; // avoid division by 0 - /// - /// Initializes an instance of . - /// - public Engagement(long viewCount, long likeCount, long dislikeCount) - { - ViewCount = viewCount; - LikeCount = likeCount; - DislikeCount = dislikeCount; - } - /// [ExcludeFromCodeCoverage] public override string ToString() => $"Rating: {AverageRating:N1}"; diff --git a/YoutubeDownloader/Videos/Streams/AudioOnlyStreamInfo.cs b/YoutubeDownloader/Videos/Streams/AudioOnlyStreamInfo.cs index 1346d2a1..59cb8e55 100644 --- a/YoutubeDownloader/Videos/Streams/AudioOnlyStreamInfo.cs +++ b/YoutubeDownloader/Videos/Streams/AudioOnlyStreamInfo.cs @@ -5,40 +5,28 @@ namespace YoutubeExplode.Videos.Streams; /// /// Metadata associated with an audio-only YouTube media stream. /// -public class AudioOnlyStreamInfo : IAudioStreamInfo +public class AudioOnlyStreamInfo( + string url, + Container container, + FileSize size, + Bitrate bitrate, + string audioCodec +) : IAudioStreamInfo { /// - public string Url { get; } + public string Url { get; } = url; /// - public Container Container { get; } + public Container Container { get; } = container; /// - public FileSize Size { get; } + public FileSize Size { get; } = size; /// - public Bitrate Bitrate { get; } + public Bitrate Bitrate { get; } = bitrate; /// - public string AudioCodec { get; } - - /// - /// Initializes an instance of . - /// - public AudioOnlyStreamInfo( - string url, - Container container, - FileSize size, - Bitrate bitrate, - string audioCodec - ) - { - Url = url; - Container = container; - Size = size; - Bitrate = bitrate; - AudioCodec = audioCodec; - } + public string AudioCodec { get; } = audioCodec; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Videos/Streams/Bitrate.cs b/YoutubeDownloader/Videos/Streams/Bitrate.cs index 50f6a6f7..54d2fc0b 100644 --- a/YoutubeDownloader/Videos/Streams/Bitrate.cs +++ b/YoutubeDownloader/Videos/Streams/Bitrate.cs @@ -5,12 +5,12 @@ namespace YoutubeExplode.Videos.Streams; /// /// Bitrate. /// -public readonly partial struct Bitrate +public readonly partial struct Bitrate(long bitsPerSecond) { /// /// Bitrate in bits per second. /// - public long BitsPerSecond { get; } + public long BitsPerSecond { get; } = bitsPerSecond; /// /// Bitrate in kilobits per second. @@ -27,11 +27,6 @@ public readonly partial struct Bitrate /// public double GigaBitsPerSecond => MegaBitsPerSecond / 1024.0; - /// - /// Initializes an instance of . - /// - public Bitrate(long bitsPerSecond) => BitsPerSecond = bitsPerSecond; - private string GetLargestWholeNumberSymbol() { if (Math.Abs(GigaBitsPerSecond) >= 1) diff --git a/YoutubeDownloader/Videos/Streams/Container.cs b/YoutubeDownloader/Videos/Streams/Container.cs index 52a8e02a..10cef6fe 100644 --- a/YoutubeDownloader/Videos/Streams/Container.cs +++ b/YoutubeDownloader/Videos/Streams/Container.cs @@ -5,13 +5,13 @@ namespace YoutubeExplode.Videos.Streams; /// /// Stream container. /// -public readonly partial struct Container +public readonly partial struct Container(string name) { /// /// Container name (e.g. mp4, webm, etc). /// Can be used as file extension. /// - public string Name { get; } + public string Name { get; } = name; /// /// Whether this container is a known audio-only container. @@ -30,11 +30,6 @@ public readonly partial struct Container || string.Equals(Name, "aac", StringComparison.OrdinalIgnoreCase) || string.Equals(Name, "opus", StringComparison.OrdinalIgnoreCase); - /// - /// Initializes an instance of . - /// - public Container(string name) => Name = name; - /// public override string ToString() => Name; } diff --git a/YoutubeDownloader/Videos/Streams/FileSize.cs b/YoutubeDownloader/Videos/Streams/FileSize.cs index d0086284..7d236529 100644 --- a/YoutubeDownloader/Videos/Streams/FileSize.cs +++ b/YoutubeDownloader/Videos/Streams/FileSize.cs @@ -6,12 +6,12 @@ namespace YoutubeExplode.Videos.Streams; /// File size. /// // Loosely based on https://github.com/omar/ByteSize (MIT license) -public readonly partial struct FileSize +public readonly partial struct FileSize(long bytes) { /// /// Size in bytes. /// - public long Bytes { get; } + public long Bytes { get; } = bytes; /// /// Size in kilobytes. @@ -28,11 +28,6 @@ public readonly partial struct FileSize /// public double GigaBytes => MegaBytes / 1024.0; - /// - /// Initializes an instance of . - /// - public FileSize(long bytes) => Bytes = bytes; - private string GetLargestWholeNumberSymbol() { if (Math.Abs(GigaBytes) >= 1) diff --git a/YoutubeDownloader/Videos/Streams/IStreamInfo.cs b/YoutubeDownloader/Videos/Streams/IStreamInfo.cs index 7984c1b0..608a6f7c 100644 --- a/YoutubeDownloader/Videos/Streams/IStreamInfo.cs +++ b/YoutubeDownloader/Videos/Streams/IStreamInfo.cs @@ -18,7 +18,7 @@ public interface IStreamInfo /// of carefully crafted HTTP requests in order to do so. /// It's highly recommended to use /// or - /// instead, as they will all the heavy lifting for you. + /// instead, as they will perform all the heavy lifting for you. /// string Url { get; } diff --git a/YoutubeDownloader/Videos/Streams/MediaStream.cs b/YoutubeDownloader/Videos/Streams/MediaStream.cs index 33867b86..0f729aa2 100644 --- a/YoutubeDownloader/Videos/Streams/MediaStream.cs +++ b/YoutubeDownloader/Videos/Streams/MediaStream.cs @@ -9,12 +9,17 @@ namespace YoutubeExplode.Videos.Streams; // Works around YouTube's rate throttling, provides seeking support, and some resiliency -internal class MediaStream : Stream +internal partial class MediaStream(HttpClient http, IStreamInfo streamInfo) : Stream { - private readonly HttpClient _http; - private readonly IStreamInfo _streamInfo; + // For most streams, YouTube limits transfer speed to match the video playback rate. + // This helps them avoid unnecessary bandwidth, but for us it's a hindrance because + // we want to download the stream as fast as possible. + // To solve this, we divide the logical stream up into multiple segments and download + // them all separately. - private readonly long _segmentLength; + private readonly long _segmentLength = streamInfo.IsThrottled() + ? 9_898_989 + : streamInfo.Size.Bytes; private Stream? _segmentStream; private long _actualPosition; @@ -28,23 +33,10 @@ internal class MediaStream : Stream [ExcludeFromCodeCoverage] public override bool CanWrite => false; - public override long Length => _streamInfo.Size.Bytes; + public override long Length => streamInfo.Size.Bytes; public override long Position { get; set; } - public MediaStream(HttpClient http, IStreamInfo streamInfo) - { - _http = http; - _streamInfo = streamInfo; - - // For most streams, YouTube limits transfer speed to match the video playback rate. - // This helps them avoid unnecessary bandwidth, but for us it's a hindrance because - // we want to download the stream as fast as possible. - // To solve this, we divide the logical stream up into multiple segments and download - // them all separately. - _segmentLength = streamInfo.IsThrottled() ? 9_898_989 : streamInfo.Size.Bytes; - } - private void ResetSegment() { _segmentStream?.Dispose(); @@ -58,11 +50,8 @@ private async ValueTask ResolveSegmentAsync( if (_segmentStream is not null) return _segmentStream; - var from = Position; - var to = Position + _segmentLength - 1; - var url = UrlEx.SetQueryParameter(_streamInfo.Url, "range", $"{from}-{to}"); - - var stream = await _http.GetStreamAsync(url, cancellationToken); + var url = GetSegmentUrl(streamInfo.Url, Position, Position + _segmentLength - 1); + var stream = await http.GetStreamAsync(url, cancellationToken); return _segmentStream = stream; } @@ -85,7 +74,8 @@ private async ValueTask ReadSegmentAsync( return await stream.ReadAsync(buffer, offset, count, cancellationToken); } // Retry on connectivity issues - catch (IOException) when (retriesRemaining > 0) + catch (Exception ex) + when (ex is HttpRequestException or IOException && retriesRemaining > 0) { ResetSegment(); } @@ -155,3 +145,9 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } + +internal partial class MediaStream +{ + public static string GetSegmentUrl(string streamUrl, long from, long to) => + UrlEx.SetQueryParameter(streamUrl, "range", $"{from}-{to}"); +} diff --git a/YoutubeDownloader/Videos/Streams/MuxedStreamInfo.cs b/YoutubeDownloader/Videos/Streams/MuxedStreamInfo.cs index a07358c0..0983038b 100644 --- a/YoutubeDownloader/Videos/Streams/MuxedStreamInfo.cs +++ b/YoutubeDownloader/Videos/Streams/MuxedStreamInfo.cs @@ -6,55 +6,40 @@ namespace YoutubeExplode.Videos.Streams; /// /// Metadata associated with a muxed (audio + video combined) media stream. /// -public class MuxedStreamInfo : IAudioStreamInfo, IVideoStreamInfo +public class MuxedStreamInfo( + string url, + Container container, + FileSize size, + Bitrate bitrate, + string audioCodec, + string videoCodec, + VideoQuality videoQuality, + Resolution videoResolution +) : IAudioStreamInfo, IVideoStreamInfo { /// - public string Url { get; } + public string Url { get; } = url; /// - public Container Container { get; } + public Container Container { get; } = container; /// - public FileSize Size { get; } + public FileSize Size { get; } = size; /// - public Bitrate Bitrate { get; } + public Bitrate Bitrate { get; } = bitrate; /// - public string AudioCodec { get; } + public string AudioCodec { get; } = audioCodec; /// - public string VideoCodec { get; } + public string VideoCodec { get; } = videoCodec; /// - public VideoQuality VideoQuality { get; } + public VideoQuality VideoQuality { get; } = videoQuality; /// - public Resolution VideoResolution { get; } - - /// - /// Initializes an instance of . - /// - public MuxedStreamInfo( - string url, - Container container, - FileSize size, - Bitrate bitrate, - string audioCodec, - string videoCodec, - VideoQuality videoQuality, - Resolution resolution - ) - { - Url = url; - Container = container; - Size = size; - Bitrate = bitrate; - AudioCodec = audioCodec; - VideoCodec = videoCodec; - VideoQuality = videoQuality; - VideoResolution = resolution; - } + public Resolution VideoResolution { get; } = videoResolution; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Videos/Streams/StreamClient.cs b/YoutubeDownloader/Videos/Streams/StreamClient.cs index e2e19f5b..d6db8c58 100644 --- a/YoutubeDownloader/Videos/Streams/StreamClient.cs +++ b/YoutubeDownloader/Videos/Streams/StreamClient.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Net.Http; using System.Runtime.CompilerServices; using System.Threading; @@ -18,24 +19,14 @@ namespace YoutubeExplode.Videos.Streams; /// /// Operations related to media streams of YouTube videos. /// -public class StreamClient +public class StreamClient(HttpClient http) { - private readonly HttpClient _http; - private readonly StreamController _controller; + private readonly StreamController _controller = new(http); // Because we determine the player version ourselves, it's safe to cache the cipher manifest // for the entire lifetime of the client. private CipherManifest? _cipherManifest; - /// - /// Initializes an instance of . - /// - public StreamClient(HttpClient http) - { - _http = http; - _controller = new StreamController(http); - } - private async ValueTask ResolveCipherManifestAsync( CancellationToken cancellationToken ) @@ -47,7 +38,50 @@ CancellationToken cancellationToken return _cipherManifest = playerSource.CipherManifest - ?? throw new YoutubeExplodeException("Could not get cipher manifest."); + ?? throw new YoutubeExplodeException("Failed to extract the cipher manifest."); + } + + private async ValueTask TryGetContentLengthAsync( + IStreamData streamData, + string url, + CancellationToken cancellationToken = default + ) + { + var contentLength = streamData.ContentLength; + + // If content length is not available in the metadata, get it by + // sending a HEAD request and parsing the Content-Length header. + if (contentLength is null) + { + using var response = await http.HeadAsync(url, cancellationToken); + contentLength = response.Content.Headers.ContentLength; + + // 404 error indicates that the stream is not available + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + response.EnsureSuccessStatusCode(); + } + + if (contentLength is not null) + { + // Streams may have mismatched content length, so ensure that the obtained value is correct + // https://github.com/Tyrrrz/YoutubeExplode/issues/759 + using var response = await http.GetAsync( + // Try to access the last byte of the stream + MediaStream.GetSegmentUrl(url, contentLength.Value - 2, contentLength.Value - 1), + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ); + + // 404 error indicates that the stream has mismatched content length or is not available + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + response.EnsureSuccessStatusCode(); + } + + return contentLength; } private async IAsyncEnumerable GetStreamInfosAsync( @@ -59,11 +93,11 @@ private async IAsyncEnumerable GetStreamInfosAsync( { var itag = streamData.Itag - ?? throw new YoutubeExplodeException("Could not extract stream itag."); + ?? throw new YoutubeExplodeException("Failed to extract the stream itag."); var url = streamData.Url - ?? throw new YoutubeExplodeException("Could not extract stream URL."); + ?? throw new YoutubeExplodeException("Failed to extract the stream URL."); // Handle cipher-protected streams if (!string.IsNullOrWhiteSpace(streamData.Signature)) @@ -77,22 +111,17 @@ private async IAsyncEnumerable GetStreamInfosAsync( ); } - var contentLength = - streamData.ContentLength - ?? await _http.TryGetContentLengthAsync(url, false, cancellationToken) - ?? 0; - - // Stream cannot be accessed - if (contentLength <= 0) + var contentLength = await TryGetContentLengthAsync(streamData, url, cancellationToken); + if (contentLength is null) continue; var container = streamData.Container?.Pipe(s => new Container(s)) - ?? throw new YoutubeExplodeException("Could not extract stream container."); + ?? throw new YoutubeExplodeException("Failed to extract the stream container."); var bitrate = streamData.Bitrate?.Pipe(s => new Bitrate(s)) - ?? throw new YoutubeExplodeException("Could not extract stream bitrate."); + ?? throw new YoutubeExplodeException("Failed to extract the stream bitrate."); // Muxed or video-only stream if (!string.IsNullOrWhiteSpace(streamData.VideoCodec)) @@ -114,7 +143,7 @@ streamData.VideoWidth is not null && streamData.VideoHeight is not null var streamInfo = new MuxedStreamInfo( url, container, - new FileSize(contentLength), + new FileSize(contentLength.Value), bitrate, streamData.AudioCodec, streamData.VideoCodec, @@ -130,7 +159,7 @@ streamData.VideoWidth is not null && streamData.VideoHeight is not null var streamInfo = new VideoOnlyStreamInfo( url, container, - new FileSize(contentLength), + new FileSize(contentLength.Value), bitrate, streamData.VideoCodec, videoQuality, @@ -146,7 +175,7 @@ streamData.VideoWidth is not null && streamData.VideoHeight is not null var streamInfo = new AudioOnlyStreamInfo( url, container, - new FileSize(contentLength), + new FileSize(contentLength.Value), bitrate, streamData.AudioCodec ); @@ -155,19 +184,20 @@ streamData.VideoWidth is not null && streamData.VideoHeight is not null } else { - throw new YoutubeExplodeException("Could not extract stream codec."); + throw new YoutubeExplodeException("Failed to extract the stream codec."); } } } - private async IAsyncEnumerable GetStreamInfosAsync( + private async ValueTask> GetStreamInfosAsync( VideoId videoId, - [EnumeratorCancellation] CancellationToken cancellationToken = default + PlayerResponse playerResponse, + CancellationToken cancellationToken = default ) { - var playerResponse = await _controller.GetPlayerResponseAsync(videoId, cancellationToken); + var streamInfos = new List(); - // If the video is pay-to-play, error out + // Video is pay-to-play if (!string.IsNullOrWhiteSpace(playerResponse.PreviewVideoId)) { throw new VideoRequiresPurchaseException( @@ -176,56 +206,74 @@ private async IAsyncEnumerable GetStreamInfosAsync( ); } - // If the video is unplayable, try one more time by fetching the player response - // with signature deciphering. This is (only) required for age-restricted videos. - if (!playerResponse.IsPlayable) - { - var cipherManifest = await ResolveCipherManifestAsync(cancellationToken); - playerResponse = await _controller.GetPlayerResponseAsync( - videoId, - cipherManifest.SignatureTimestamp, - cancellationToken - ); - } - - // If the video is still unplayable, error out + // Video is unplayable if (!playerResponse.IsPlayable) { throw new VideoUnplayableException( - $"Video '{videoId}' is unplayable. " - + $"Reason: '{playerResponse.PlayabilityError}'." + $"Video '{videoId}' is unplayable. Reason: '{playerResponse.PlayabilityError}'." ); } // Extract streams from the player response - await foreach ( - var streamInfo in GetStreamInfosAsync(playerResponse.Streams, cancellationToken) - ) - yield return streamInfo; + streamInfos.AddRange(await GetStreamInfosAsync(playerResponse.Streams, cancellationToken)); // Extract streams from the DASH manifest if (!string.IsNullOrWhiteSpace(playerResponse.DashManifestUrl)) { - var dashManifest = default(DashManifest?); - try { - dashManifest = await _controller.GetDashManifestAsync( + var dashManifest = await _controller.GetDashManifestAsync( playerResponse.DashManifestUrl, cancellationToken ); + + streamInfos.AddRange( + await GetStreamInfosAsync(dashManifest.Streams, cancellationToken) + ); } // Some DASH manifest URLs return 404 for whatever reason // https://github.com/Tyrrrz/YoutubeExplode/issues/728 catch (HttpRequestException) { } + } - if (dashManifest is not null) - { - await foreach ( - var streamInfo in GetStreamInfosAsync(dashManifest.Streams, cancellationToken) - ) - yield return streamInfo; - } + // Error if no streams were found + if (!streamInfos.Any()) + { + throw new VideoUnplayableException( + $"Video '{videoId}' does not contain any playable streams." + ); + } + + return streamInfos; + } + + private async ValueTask> GetStreamInfosAsync( + VideoId videoId, + CancellationToken cancellationToken = default + ) + { + try + { + // Try to get player response from a cipher-less client + var playerResponse = await _controller.GetPlayerResponseAsync( + videoId, + cancellationToken + ); + + return await GetStreamInfosAsync(videoId, playerResponse, cancellationToken); + } + catch (VideoUnplayableException) + { + // Try to get player response from a client with cipher + var cipherManifest = await ResolveCipherManifestAsync(cancellationToken); + + var playerResponse = await _controller.GetPlayerResponseAsync( + videoId, + cipherManifest.SignatureTimestamp, + cancellationToken + ); + + return await GetStreamInfosAsync(videoId, playerResponse, cancellationToken); } } @@ -239,25 +287,13 @@ public async ValueTask GetManifestAsync( { for (var retriesRemaining = 5; ; retriesRemaining--) { - var streamInfos = await GetStreamInfosAsync(videoId, cancellationToken); - - if (!streamInfos.Any()) + try { - throw new VideoUnplayableException( - $"Video '{videoId}' does not contain any playable streams." - ); + return new StreamManifest(await GetStreamInfosAsync(videoId, cancellationToken)); } - - // YouTube sometimes returns stream URLs that produce 403 Forbidden errors when accessed. - // This happens for both protected and non-protected streams, so the cause is unclear. - // As a workaround, we can access one of the stream URLs and retry if it fails. - using var response = await _http.HeadAsync(streamInfos.First().Url, cancellationToken); - if ((int)response.StatusCode == 403 && retriesRemaining > 0) - continue; - - response.EnsureSuccessStatusCode(); - - return new StreamManifest(streamInfos); + // Retry on connectivity issues + catch (Exception ex) + when (ex is HttpRequestException or IOException && retriesRemaining > 0) { } } } @@ -273,16 +309,14 @@ public async ValueTask GetHttpLiveStreamUrlAsync( if (!playerResponse.IsPlayable) { throw new VideoUnplayableException( - $"Video '{videoId}' is unplayable. " - + $"Reason: '{playerResponse.PlayabilityError}'." + $"Video '{videoId}' is unplayable. Reason: '{playerResponse.PlayabilityError}'." ); } if (string.IsNullOrWhiteSpace(playerResponse.HlsManifestUrl)) { throw new YoutubeExplodeException( - "Could not extract HTTP Live Stream manifest URL. " - + $"Video '{videoId}' is likely not a live stream." + $"Failed to extract the HTTP Live Stream manifest URL. Video '{videoId}' is likely not a live stream." ); } @@ -297,7 +331,7 @@ public async ValueTask GetAsync( CancellationToken cancellationToken = default ) { - var stream = new MediaStream(_http, streamInfo); + var stream = new MediaStream(http, streamInfo); await stream.InitializeAsync(cancellationToken); return stream; diff --git a/YoutubeDownloader/Videos/Streams/StreamController.cs b/YoutubeDownloader/Videos/Streams/StreamController.cs index 454f28d7..d5e3a4f3 100644 --- a/YoutubeDownloader/Videos/Streams/StreamController.cs +++ b/YoutubeDownloader/Videos/Streams/StreamController.cs @@ -7,11 +7,8 @@ namespace YoutubeExplode.Videos.Streams; -internal class StreamController : VideoController +internal class StreamController(HttpClient http) : VideoController(http) { - public StreamController(HttpClient http) - : base(http) { } - public async ValueTask GetPlayerSourceAsync( CancellationToken cancellationToken = default ) @@ -23,7 +20,7 @@ public async ValueTask GetPlayerSourceAsync( var version = Regex.Match(iframe, @"player\\?/([0-9a-fA-F]{8})\\?/").Groups[1].Value; if (string.IsNullOrWhiteSpace(version)) - throw new YoutubeExplodeException("Could not extract player version."); + throw new YoutubeExplodeException("Failed to extract the player version."); return PlayerSource.Parse( await Http.GetStringAsync( diff --git a/YoutubeDownloader/Videos/Streams/StreamManifest.cs b/YoutubeDownloader/Videos/Streams/StreamManifest.cs index 7cb12d6e..d0a72f21 100644 --- a/YoutubeDownloader/Videos/Streams/StreamManifest.cs +++ b/YoutubeDownloader/Videos/Streams/StreamManifest.cs @@ -6,20 +6,12 @@ namespace YoutubeExplode.Videos.Streams; /// /// Describes media streams available for a YouTube video. /// -public class StreamManifest +public class StreamManifest(IReadOnlyList streams) { /// /// Available streams. /// - public IReadOnlyList Streams { get; } - - /// - /// Initializes an instance of . - /// - public StreamManifest(IReadOnlyList streams) - { - Streams = streams; - } + public IReadOnlyList Streams { get; } = streams; /// /// Gets streams that contain audio (i.e. muxed and audio-only streams). diff --git a/YoutubeDownloader/Videos/Streams/VideoOnlyStreamInfo.cs b/YoutubeDownloader/Videos/Streams/VideoOnlyStreamInfo.cs index 487d793b..b96a43f7 100644 --- a/YoutubeDownloader/Videos/Streams/VideoOnlyStreamInfo.cs +++ b/YoutubeDownloader/Videos/Streams/VideoOnlyStreamInfo.cs @@ -6,50 +6,36 @@ namespace YoutubeExplode.Videos.Streams; /// /// Metadata associated with a video-only media stream. /// -public class VideoOnlyStreamInfo : IVideoStreamInfo +public class VideoOnlyStreamInfo( + string url, + Container container, + FileSize size, + Bitrate bitrate, + string videoCodec, + VideoQuality videoQuality, + Resolution videoResolution +) : IVideoStreamInfo { /// - public string Url { get; } + public string Url { get; } = url; /// - public Container Container { get; } + public Container Container { get; } = container; /// - public FileSize Size { get; } + public FileSize Size { get; } = size; /// - public Bitrate Bitrate { get; } + public Bitrate Bitrate { get; } = bitrate; /// - public string VideoCodec { get; } + public string VideoCodec { get; } = videoCodec; /// - public VideoQuality VideoQuality { get; } + public VideoQuality VideoQuality { get; } = videoQuality; /// - public Resolution VideoResolution { get; } - - /// - /// Initializes an instance of . - /// - public VideoOnlyStreamInfo( - string url, - Container container, - FileSize size, - Bitrate bitrate, - string videoCodec, - VideoQuality videoQuality, - Resolution videoResolution - ) - { - Url = url; - Container = container; - Size = size; - Bitrate = bitrate; - VideoCodec = videoCodec; - VideoQuality = videoQuality; - VideoResolution = videoResolution; - } + public Resolution VideoResolution { get; } = videoResolution; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Videos/Streams/VideoQuality.cs b/YoutubeDownloader/Videos/Streams/VideoQuality.cs index fcf3bbd4..f52b875d 100644 --- a/YoutubeDownloader/Videos/Streams/VideoQuality.cs +++ b/YoutubeDownloader/Videos/Streams/VideoQuality.cs @@ -8,45 +8,35 @@ namespace YoutubeExplode.Videos.Streams; /// /// Video stream quality. /// -public readonly partial struct VideoQuality +public readonly partial struct VideoQuality(string label, int maxHeight, int framerate) { + /// + /// Initializes an instance of . + /// + public VideoQuality(int maxHeight, int framerate) + : this(FormatLabel(maxHeight, framerate), maxHeight, framerate) { } + /// /// Quality label, as seen on YouTube (e.g. 1080p, 720p60, etc). /// - public string Label { get; } + public string Label { get; } = label; /// /// Maximum video height allowed by this quality (e.g. 1080 for 1080p60). /// Actual video height may be lower in some cases. /// - public int MaxHeight { get; } + public int MaxHeight { get; } = maxHeight; /// /// Video framerate, measured in frames per second. /// - public int Framerate { get; } + public int Framerate { get; } = framerate; /// /// Whether this is a high definition video (i.e. 1080p or above). /// public bool IsHighDefinition => MaxHeight >= 1080; - /// - /// Initializes an instance of . - /// - public VideoQuality(string label, int maxHeight, int framerate) - { - Label = label; - MaxHeight = maxHeight; - Framerate = framerate; - } - - /// - /// Initializes an instance of . - /// - public VideoQuality(int maxHeight, int framerate) - : this(FormatLabel(maxHeight, framerate), maxHeight, framerate) { } - internal Resolution GetDefaultVideoResolution() => MaxHeight switch { diff --git a/YoutubeDownloader/Videos/Video.cs b/YoutubeDownloader/Videos/Video.cs index b2661f31..04734623 100644 --- a/YoutubeDownloader/Videos/Video.cs +++ b/YoutubeDownloader/Videos/Video.cs @@ -8,71 +8,55 @@ namespace YoutubeExplode.Videos; /// /// Metadata associated with a YouTube video. /// -public class Video : IVideo +public class Video( + VideoId id, + string title, + Author author, + DateTimeOffset uploadDate, + string description, + TimeSpan? duration, + IReadOnlyList thumbnails, + IReadOnlyList keywords, + Engagement engagement +) : IVideo { /// - public VideoId Id { get; } + public VideoId Id { get; } = id; /// public string Url => $"https://www.youtube.com/watch?v={Id}"; /// - public string Title { get; } + public string Title { get; } = title; /// - public Author Author { get; } + public Author Author { get; } = author; /// /// Video upload date. /// - public DateTimeOffset UploadDate { get; } + public DateTimeOffset UploadDate { get; } = uploadDate; /// /// Video description. /// - public string Description { get; } + public string Description { get; } = description; /// - public TimeSpan? Duration { get; } + public TimeSpan? Duration { get; } = duration; /// - public IReadOnlyList Thumbnails { get; } + public IReadOnlyList Thumbnails { get; } = thumbnails; /// /// Available search keywords for the video. /// - public IReadOnlyList Keywords { get; } + public IReadOnlyList Keywords { get; } = keywords; /// /// Engagement statistics for the video. /// - public Engagement Engagement { get; } - - /// - /// Initializes an instance of . - /// - public Video( - VideoId id, - string title, - Author author, - DateTimeOffset uploadDate, - string description, - TimeSpan? duration, - IReadOnlyList thumbnails, - IReadOnlyList keywords, - Engagement engagement - ) - { - Id = id; - Title = title; - Author = author; - UploadDate = uploadDate; - Description = description; - Duration = duration; - Thumbnails = thumbnails; - Keywords = keywords; - Engagement = engagement; - } + public Engagement Engagement { get; } = engagement; /// [ExcludeFromCodeCoverage] diff --git a/YoutubeDownloader/Videos/VideoClient.cs b/YoutubeDownloader/Videos/VideoClient.cs index 69a73a0b..b4063101 100644 --- a/YoutubeDownloader/Videos/VideoClient.cs +++ b/YoutubeDownloader/Videos/VideoClient.cs @@ -12,30 +12,19 @@ namespace YoutubeExplode.Videos; /// /// Operations related to YouTube videos. /// -public class VideoClient +public class VideoClient(HttpClient http) { - private readonly VideoController _controller; + private readonly VideoController _controller = new(http); /// /// Operations related to media streams of YouTube videos. /// - public StreamClient Streams { get; } + public StreamClient Streams { get; } = new(http); /// /// Operations related to closed captions of YouTube videos. /// - public ClosedCaptionClient ClosedCaptions { get; } - - /// - /// Initializes an instance of . - /// - public VideoClient(HttpClient http) - { - _controller = new VideoController(http); - - Streams = new StreamClient(http); - ClosedCaptions = new ClosedCaptionClient(http); - } + public ClosedCaptionClient ClosedCaptions { get; } = new(http); /// /// Gets the metadata associated with the specified video. @@ -59,31 +48,31 @@ public async ValueTask