├── favicon.ico ├── favicon.png ├── .assets └── demo.png ├── global.json ├── .gitignore ├── YoutubeExplode ├── Bridge │ ├── Cipher │ │ ├── ICipherOperation.cs │ │ ├── SpliceCipherOperation.cs │ │ ├── ReverseCipherOperation.cs │ │ ├── SwapCipherOperation.cs │ │ └── CipherManifest.cs │ ├── IPlaylistData.cs │ ├── ThumbnailData.cs │ ├── IStreamData.cs │ ├── ChannelPage.cs │ ├── ClosedCaptionTrackResponse.cs │ ├── PlaylistNextResponse.cs │ └── DashManifest.cs ├── FodyWeavers.xml ├── Exceptions │ ├── VideoUnplayableException.cs │ ├── VideoUnavailableException.cs │ ├── YoutubeExplodeException.cs │ ├── PlaylistUnavailableException.cs │ ├── RequestLimitExceededException.cs │ └── VideoRequiresPurchaseException.cs ├── Utils │ ├── Extensions │ │ ├── UriExtensions.cs │ │ ├── GenericExtensions.cs │ │ ├── StreamExtensions.cs │ │ ├── XElementExtensions.cs │ │ ├── CollectionExtensions.cs │ │ ├── AsyncCollectionExtensions.cs │ │ ├── StringExtensions.cs │ │ ├── HttpExtensions.cs │ │ └── JsonExtensions.cs │ ├── Xml.cs │ ├── Http.cs │ ├── Hash.cs │ ├── Html.cs │ ├── ClientDelegatingHandler.cs │ ├── Json.cs │ └── UrlEx.cs ├── Search │ ├── SearchFilter.cs │ ├── ISearchResult.cs │ ├── ChannelSearchResult.cs │ ├── PlaylistSearchResult.cs │ ├── VideoSearchResult.cs │ └── SearchController.cs ├── Channels │ ├── IChannel.cs │ ├── Channel.cs │ ├── ChannelController.cs │ ├── ChannelSlug.cs │ ├── ChannelHandle.cs │ ├── UserName.cs │ ├── ChannelId.cs │ └── ChannelClient.cs ├── Videos │ ├── ClosedCaptions │ │ ├── ClosedCaptionPart.cs │ │ ├── ClosedCaptionTrackInfo.cs │ │ ├── ClosedCaptionController.cs │ │ ├── ClosedCaptionTrack.cs │ │ ├── ClosedCaptionManifest.cs │ │ └── ClosedCaption.cs │ ├── Streams │ │ ├── IAudioStreamInfo.cs │ │ ├── VideoOnlyStreamInfo.cs │ │ ├── StreamController.cs │ │ ├── AudioOnlyStreamInfo.cs │ │ ├── IVideoStreamInfo.cs │ │ ├── MuxedStreamInfo.cs │ │ ├── StreamManifest.cs │ │ ├── IStreamInfo.cs │ │ ├── FileSize.cs │ │ ├── Bitrate.cs │ │ └── Container.cs │ ├── IVideo.cs │ ├── Engagement.cs │ ├── Video.cs │ └── VideoClient.cs ├── Common │ ├── Batch.cs │ ├── Author.cs │ ├── Resolution.cs │ ├── Language.cs │ ├── IBatchItem.cs │ └── Thumbnail.cs ├── Playlists │ ├── IPlaylist.cs │ ├── Playlist.cs │ └── PlaylistVideo.cs ├── FodyWeavers.xsd ├── YoutubeExplode.csproj └── YoutubeClient.cs ├── YoutubeExplode.Tests ├── TestData │ ├── UserNames.cs │ ├── ChannelHandles.cs │ ├── ChannelSlugs.cs │ ├── ChannelIds.cs │ ├── PlaylistIds.cs │ └── VideoIds.cs ├── xunit.runner.json ├── Utils │ └── TempFile.cs ├── YoutubeExplode.Tests.csproj ├── UserNameSpecs.cs ├── ChannelHandleSpecs.cs ├── ChannelSlugSpecs.cs ├── ChannelIdSpecs.cs ├── VideoIdSpecs.cs ├── PlaylistIdSpecs.cs ├── ChannelSpecs.cs ├── SearchSpecs.cs └── VideoSpecs.cs ├── YoutubeExplode.Converter.Tests ├── xunit.runner.json ├── Utils │ ├── Extensions │ │ ├── HttpExtensions.cs │ │ └── FileExtensions.cs │ ├── TempDir.cs │ ├── TempFile.cs │ ├── MediaFormat.cs │ └── FFmpeg.cs ├── YoutubeExplode.Converter.Tests.csproj ├── EnvironmentSpecs.cs └── SubtitleSpecs.cs ├── YoutubeExplode.Converter ├── Utils │ ├── Extensions │ │ ├── GenericExtensions.cs │ │ ├── StringExtensions.cs │ │ └── AsyncCollectionExtensions.cs │ └── ProgressMuxer.cs ├── ConversionFormat.cs ├── ConversionPreset.cs ├── YoutubeExplode.Converter.csproj ├── ConversionRequest.cs ├── ConversionRequestBuilder.cs └── Readme.md ├── NuGet.config ├── YoutubeExplode.Demo.Gui ├── Program.cs ├── Utils │ ├── DelegateProgress.cs │ └── Extensions │ │ ├── PathExtensions.cs │ │ └── AvaloniaExtensions.cs ├── Views │ └── MainWindow.axaml.cs ├── Converters │ ├── BoolToYesNoStringConverter.cs │ └── EnumerableToJoinedStringConverter.cs ├── App.axaml.cs ├── YoutubeExplode.Demo.Gui.csproj └── App.axaml ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ └── bug-report.yml └── workflows │ └── main.yml ├── YoutubeExplode.Demo.Cli ├── YoutubeExplode.Demo.Cli.csproj ├── Utils │ └── ConsoleProgress.cs └── Program.cs ├── License.txt ├── Directory.Build.props └── YoutubeExplode.sln /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/YoutubeExplode/HEAD/favicon.ico -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/YoutubeExplode/HEAD/favicon.png -------------------------------------------------------------------------------- /.assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/YoutubeExplode/HEAD/.assets/demo.png -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "10.0.100", 4 | "rollForward": "latestFeature" 5 | } 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific files 2 | .vs/ 3 | .idea/ 4 | *.suo 5 | *.user 6 | 7 | # Build results 8 | bin/ 9 | obj/ 10 | 11 | # Test results 12 | TestResults/ -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/Cipher/ICipherOperation.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Bridge.Cipher; 2 | 3 | internal interface ICipherOperation 4 | { 5 | string Decipher(string input); 6 | } 7 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/TestData/UserNames.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Tests.TestData; 2 | 3 | internal static class UserNames 4 | { 5 | public const string Normal = "mrbeast6000"; 6 | } 7 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "methodDisplayOptions": "all", 4 | "methodDisplay": "method" 5 | } -------------------------------------------------------------------------------- /YoutubeExplode/FodyWeavers.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/TestData/ChannelHandles.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Tests.TestData; 2 | 3 | internal static class ChannelHandles 4 | { 5 | public const string Normal = "MrBeast"; 6 | } 7 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "methodDisplayOptions": "all", 4 | "methodDisplay": "method" 5 | } -------------------------------------------------------------------------------- /YoutubeExplode.Tests/TestData/ChannelSlugs.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Tests.TestData; 2 | 3 | internal static class ChannelSlugs 4 | { 5 | public const string Normal = "МеланіяПодоляк"; 6 | } 7 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/TestData/ChannelIds.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Tests.TestData; 2 | 3 | internal static class ChannelIds 4 | { 5 | public const string Normal = "UCX6OQ3DkcsbYNE6H8uQQuVA"; 6 | public const string Movies = "UCuVPpxrm2VAgpH3Ktln4HXg"; 7 | } 8 | -------------------------------------------------------------------------------- /YoutubeExplode/Exceptions/VideoUnplayableException.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Exceptions; 2 | 3 | /// 4 | /// Exception thrown when the requested video is unplayable. 5 | /// 6 | public class VideoUnplayableException(string message) : YoutubeExplodeException(message); 7 | -------------------------------------------------------------------------------- /YoutubeExplode/Exceptions/VideoUnavailableException.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Exceptions; 2 | 3 | /// 4 | /// Exception thrown when the requested video is unavailable. 5 | /// 6 | public class VideoUnavailableException(string message) : VideoUnplayableException(message); 7 | -------------------------------------------------------------------------------- /YoutubeExplode/Exceptions/YoutubeExplodeException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace YoutubeExplode.Exceptions; 4 | 5 | /// 6 | /// Exception thrown within . 7 | /// 8 | public class YoutubeExplodeException(string message) : Exception(message); 9 | -------------------------------------------------------------------------------- /YoutubeExplode/Exceptions/PlaylistUnavailableException.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Exceptions; 2 | 3 | /// 4 | /// Exception thrown when the requested playlist is unavailable. 5 | /// 6 | public class PlaylistUnavailableException(string message) : YoutubeExplodeException(message); 7 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Extensions/UriExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace YoutubeExplode.Utils.Extensions; 4 | 5 | internal static class UriExtensions 6 | { 7 | extension(Uri uri) 8 | { 9 | public string Domain => uri.Scheme + Uri.SchemeDelimiter + uri.Host; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Extensions/GenericExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace YoutubeExplode.Utils.Extensions; 4 | 5 | internal static class GenericExtensions 6 | { 7 | extension(TIn input) 8 | { 9 | public TOut Pipe(Func transform) => transform(input); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Xml.cs: -------------------------------------------------------------------------------- 1 | using System.Xml.Linq; 2 | using YoutubeExplode.Utils.Extensions; 3 | 4 | namespace YoutubeExplode.Utils; 5 | 6 | internal static class Xml 7 | { 8 | public static XElement Parse(string source) => 9 | XElement.Parse(source, LoadOptions.PreserveWhitespace).StripNamespaces(); 10 | } 11 | -------------------------------------------------------------------------------- /YoutubeExplode/Exceptions/RequestLimitExceededException.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Exceptions; 2 | 3 | /// 4 | /// Exception thrown when YouTube denies a request because the client has exceeded rate limit. 5 | /// 6 | public class RequestLimitExceededException(string message) : YoutubeExplodeException(message); 7 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Http.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | 4 | namespace YoutubeExplode.Utils; 5 | 6 | internal static class Http 7 | { 8 | private static readonly Lazy HttpClientLazy = new(() => new HttpClient()); 9 | 10 | public static HttpClient Client => HttpClientLazy.Value; 11 | } 12 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Hash.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | 3 | namespace YoutubeExplode.Utils; 4 | 5 | internal static class Hash 6 | { 7 | public static byte[] Compute(HashAlgorithm algorithm, byte[] data) 8 | { 9 | using (algorithm) 10 | return algorithm.ComputeHash(data); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/Utils/Extensions/GenericExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace YoutubeExplode.Converter.Utils.Extensions; 4 | 5 | internal static class GenericExtensions 6 | { 7 | extension(TIn input) 8 | { 9 | public TOut Pipe(Func transform) => transform(input); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Html.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Html.Dom; 2 | using AngleSharp.Html.Parser; 3 | 4 | namespace YoutubeExplode.Utils; 5 | 6 | internal static class Html 7 | { 8 | private static readonly HtmlParser HtmlParser = new(); 9 | 10 | public static IHtmlDocument Parse(string source) => HtmlParser.ParseDocument(source); 11 | } 12 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/Cipher/SpliceCipherOperation.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace YoutubeExplode.Bridge.Cipher; 4 | 5 | internal class SpliceCipherOperation(int index) : ICipherOperation 6 | { 7 | public string Decipher(string input) => input[index..]; 8 | 9 | [ExcludeFromCodeCoverage] 10 | public override string ToString() => $"Splice ({index})"; 11 | } 12 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/IPlaylistData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace YoutubeExplode.Bridge; 4 | 5 | internal interface IPlaylistData 6 | { 7 | string? Title { get; } 8 | 9 | string? Author { get; } 10 | 11 | string? ChannelId { get; } 12 | 13 | string? Description { get; } 14 | 15 | int? Count { get; } 16 | 17 | IReadOnlyList Thumbnails { get; } 18 | } 19 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/Cipher/ReverseCipherOperation.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using YoutubeExplode.Utils.Extensions; 3 | 4 | namespace YoutubeExplode.Bridge.Cipher; 5 | 6 | internal class ReverseCipherOperation : ICipherOperation 7 | { 8 | public string Decipher(string input) => input.Reverse(); 9 | 10 | [ExcludeFromCodeCoverage] 11 | public override string ToString() => "Reverse"; 12 | } 13 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Avalonia; 3 | 4 | namespace YoutubeExplode.Demo.Gui; 5 | 6 | public static class Program 7 | { 8 | public static AppBuilder BuildAvaloniaApp() => 9 | AppBuilder.Configure().UsePlatformDetect().LogToTrace(); 10 | 11 | [STAThread] 12 | public static int Main(string[] args) => 13 | BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); 14 | } 15 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/Cipher/SwapCipherOperation.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using YoutubeExplode.Utils.Extensions; 3 | 4 | namespace YoutubeExplode.Bridge.Cipher; 5 | 6 | internal class SwapCipherOperation(int index) : ICipherOperation 7 | { 8 | public string Decipher(string input) => input.SwapChars(0, index); 9 | 10 | [ExcludeFromCodeCoverage] 11 | public override string ToString() => $"Swap ({index})"; 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | labels: 8 | - enhancement 9 | groups: 10 | actions: 11 | patterns: 12 | - "*" 13 | - package-ecosystem: nuget 14 | directory: "/" 15 | schedule: 16 | interval: monthly 17 | labels: 18 | - enhancement 19 | groups: 20 | nuget: 21 | patterns: 22 | - "*" 23 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/Utils/DelegateProgress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace YoutubeExplode.Demo.Gui.Utils; 4 | 5 | // Straightforward implementation of IProgress that simply invokes a delegate 6 | // without using any synchronization (unlike the built-in Progress class). 7 | // This is required in Avalonia because the built-in Progress class causes race conditions. 8 | internal class DelegateProgress(Action report) : IProgress 9 | { 10 | public void Report(T value) => report(value); 11 | } 12 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/ThumbnailData.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Lazy; 3 | using YoutubeExplode.Utils.Extensions; 4 | 5 | namespace YoutubeExplode.Bridge; 6 | 7 | internal class ThumbnailData(JsonElement content) 8 | { 9 | [Lazy] 10 | public string? Url => content.GetPropertyOrNull("url")?.GetStringOrNull(); 11 | 12 | [Lazy] 13 | public int? Width => content.GetPropertyOrNull("width")?.GetInt32OrNull(); 14 | 15 | [Lazy] 16 | public int? Height => content.GetPropertyOrNull("height")?.GetInt32OrNull(); 17 | } 18 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/Utils/Extensions/PathExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace YoutubeExplode.Demo.Gui.Utils.Extensions; 4 | 5 | internal static class PathExtensions 6 | { 7 | extension(Path) 8 | { 9 | public static string SanitizeFileName(string fileName, char replacement = '_') 10 | { 11 | foreach (var invalidChar in Path.GetInvalidFileNameChars()) 12 | fileName = fileName.Replace(invalidChar, replacement); 13 | 14 | return fileName; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Cli/YoutubeExplode.Demo.Cli.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | net10.0 5 | ../favicon.ico 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/Cipher/CipherManifest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace YoutubeExplode.Bridge.Cipher; 5 | 6 | internal class CipherManifest(string signatureTimestamp, IReadOnlyList operations) 7 | { 8 | public string SignatureTimestamp { get; } = signatureTimestamp; 9 | 10 | public IReadOnlyList Operations { get; } = operations; 11 | 12 | public string Decipher(string input) => 13 | Operations.Aggregate(input, (acc, op) => op.Decipher(acc)); 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ⚠ Feature request 4 | url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md 5 | about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests. 6 | - name: 🗨 Discussions 7 | url: https://github.com/Tyrrrz/YoutubeExplode/discussions/new 8 | about: Ask and answer questions. 9 | - name: 💬 Discord server 10 | url: https://discord.gg/2SUWKFnHSm 11 | about: Chat with the project community. 12 | -------------------------------------------------------------------------------- /YoutubeExplode/Exceptions/VideoRequiresPurchaseException.cs: -------------------------------------------------------------------------------- 1 | using YoutubeExplode.Videos; 2 | 3 | namespace YoutubeExplode.Exceptions; 4 | 5 | /// 6 | /// Exception thrown when the requested video requires purchase. 7 | /// 8 | public class VideoRequiresPurchaseException(string message, VideoId previewVideoId) 9 | : VideoUnplayableException(message) 10 | { 11 | /// 12 | /// ID of a free preview video which is used as promotion for the original video. 13 | /// 14 | public VideoId PreviewVideoId { get; } = previewVideoId; 15 | } 16 | -------------------------------------------------------------------------------- /YoutubeExplode/Search/SearchFilter.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Search; 2 | 3 | /// 4 | /// Filter applied to a YouTube search query. 5 | /// 6 | public enum SearchFilter 7 | { 8 | /// 9 | /// No filter applied. 10 | /// 11 | None, 12 | 13 | /// 14 | /// Only search for videos. 15 | /// 16 | Video, 17 | 18 | /// 19 | /// Only search for playlists. 20 | /// 21 | Playlist, 22 | 23 | /// 24 | /// Only search for channels. 25 | /// 26 | Channel, 27 | } 28 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/Utils/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace YoutubeExplode.Converter.Utils.Extensions; 4 | 5 | internal static class StringExtensions 6 | { 7 | extension(string s) 8 | { 9 | public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(s) ? s : null; 10 | 11 | public string SubstringUntil( 12 | string sub, 13 | StringComparison comparison = StringComparison.Ordinal 14 | ) 15 | { 16 | var index = s.IndexOf(sub, comparison); 17 | return index < 0 ? s : s[..index]; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/Utils/Extensions/AsyncCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Runtime.CompilerServices; 3 | using System.Threading.Tasks; 4 | 5 | namespace YoutubeExplode.Converter.Utils.Extensions; 6 | 7 | internal static class AsyncCollectionExtensions 8 | { 9 | extension(IAsyncEnumerable source) 10 | { 11 | public async ValueTask> ToListAsync() 12 | { 13 | var list = new List(); 14 | 15 | await foreach (var i in source) 16 | list.Add(i); 17 | 18 | return list; 19 | } 20 | 21 | public ValueTaskAwaiter> GetAwaiter() => source.ToListAsync().GetAwaiter(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/Views/MainWindow.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Avalonia.Controls; 3 | using Avalonia.Interactivity; 4 | using YoutubeExplode.Demo.Gui.ViewModels; 5 | 6 | namespace YoutubeExplode.Demo.Gui.Views; 7 | 8 | public partial class MainWindow : Window 9 | { 10 | public MainWindow() => InitializeComponent(); 11 | 12 | public new MainViewModel DataContext 13 | { 14 | get => 15 | (MainViewModel)( 16 | base.DataContext ?? throw new InvalidOperationException("DataContext is not set.") 17 | ); 18 | set => base.DataContext = value; 19 | } 20 | 21 | private void Window_OnLoaded(object? sender, RoutedEventArgs args) => QueryTextBox.Focus(); 22 | } 23 | -------------------------------------------------------------------------------- /YoutubeExplode/Channels/IChannel.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using YoutubeExplode.Common; 3 | 4 | namespace YoutubeExplode.Channels; 5 | 6 | /// 7 | /// Properties shared by channel metadata resolved from different sources. 8 | /// 9 | public interface IChannel 10 | { 11 | /// 12 | /// Channel ID. 13 | /// 14 | ChannelId Id { get; } 15 | 16 | /// 17 | /// Channel URL. 18 | /// 19 | string Url { get; } 20 | 21 | /// 22 | /// Channel title. 23 | /// 24 | string Title { get; } 25 | 26 | /// 27 | /// Channel thumbnails. 28 | /// 29 | IReadOnlyList Thumbnails { get; } 30 | } 31 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/Utils/Extensions/AvaloniaExtensions.cs: -------------------------------------------------------------------------------- 1 | using Avalonia.Controls; 2 | using Avalonia.Controls.ApplicationLifetimes; 3 | using Avalonia.VisualTree; 4 | 5 | namespace YoutubeExplode.Demo.Gui.Utils.Extensions; 6 | 7 | internal static class AvaloniaExtensions 8 | { 9 | extension(IApplicationLifetime lifetime) 10 | { 11 | public Window? TryGetMainWindow() => 12 | lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime 13 | ? desktopLifetime.MainWindow 14 | : null; 15 | 16 | public TopLevel? TryGetTopLevel() => 17 | lifetime.TryGetMainWindow() 18 | ?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/Utils/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Net.Http; 3 | using System.Threading.Tasks; 4 | 5 | namespace YoutubeExplode.Converter.Tests.Utils.Extensions; 6 | 7 | internal static class HttpExtensions 8 | { 9 | extension(HttpClient http) 10 | { 11 | public async Task DownloadAsync(string url, string filePath) 12 | { 13 | using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); 14 | response.EnsureSuccessStatusCode(); 15 | 16 | await using var source = await response.Content.ReadAsStreamAsync(); 17 | await using var destination = File.Create(filePath); 18 | 19 | await source.CopyToAsync(destination); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/IStreamData.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Bridge; 2 | 3 | internal interface IStreamData 4 | { 5 | int? Itag { get; } 6 | 7 | string? Url { get; } 8 | 9 | string? Signature { get; } 10 | 11 | string? SignatureParameter { get; } 12 | 13 | long? ContentLength { get; } 14 | 15 | long? Bitrate { get; } 16 | 17 | string? Container { get; } 18 | 19 | string? AudioCodec { get; } 20 | 21 | string? AudioLanguageCode { get; } 22 | 23 | string? AudioLanguageName { get; } 24 | 25 | bool? IsAudioLanguageDefault { get; } 26 | 27 | string? VideoCodec { get; } 28 | 29 | string? VideoQualityLabel { get; } 30 | 31 | int? VideoWidth { get; } 32 | 33 | int? VideoHeight { get; } 34 | 35 | int? VideoFramerate { get; } 36 | } 37 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/ClosedCaptions/ClosedCaptionPart.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace YoutubeExplode.Videos.ClosedCaptions; 5 | 6 | /// 7 | /// Individual closed caption part contained within a track. 8 | /// 9 | public class ClosedCaptionPart(string text, TimeSpan offset) 10 | { 11 | /// 12 | /// Text displayed by the caption part. 13 | /// 14 | public string Text { get; } = text; 15 | 16 | /// 17 | /// Time at which the caption part starts displaying, relative to the caption's own offset. 18 | /// 19 | public TimeSpan Offset { get; } = offset; 20 | 21 | /// 22 | [ExcludeFromCodeCoverage] 23 | public override string ToString() => Text; 24 | } 25 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/ConversionFormat.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using YoutubeExplode.Videos.Streams; 4 | 5 | namespace YoutubeExplode.Converter; 6 | 7 | /// 8 | /// Encapsulates conversion media format. 9 | /// 10 | [Obsolete("Use YoutubeExplode.Videos.Streams.Container instead"), ExcludeFromCodeCoverage] 11 | public readonly struct ConversionFormat(string name) 12 | { 13 | /// 14 | /// Format name. 15 | /// 16 | public string Name { get; } = name; 17 | 18 | /// 19 | /// Whether this format is a known audio-only format. 20 | /// 21 | public bool IsAudioOnly => new Container(Name).IsAudioOnly(); 22 | 23 | /// 24 | public override string ToString() => Name; 25 | } 26 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/TestData/PlaylistIds.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Tests.TestData; 2 | 3 | internal static class PlaylistIds 4 | { 5 | public const string Normal = "PLI5YfMzCfRtZ8eV576YoY3vIYrHjyVm_e"; 6 | public const string Private = "PLYjTMWc3sa4ZKheRwyA1q56xxQrfQEUBr"; 7 | public const string NonExisting = "PLYjTMWc3sa4ZKheRwyA1q56xxQrfQEUBx"; 8 | public const string Large = "PLWwAypAcFRgKFlxtLbn_u14zddtDJj3mk"; 9 | public const string VideoMix = "RDTsYhxMnGYCw"; 10 | public const string MusicMix = "RDCLAK5uy_lf8okgl2ygD075nhnJVjlfhwp8NsUgEbs"; 11 | public const string MusicAlbum = "OLAK5uy_lLeonUugocG5J0EUAEDmbskX4emejKwcM"; 12 | public const string UserUploads = "UUTMt7iMWa7jy0fNXIktwyLA"; 13 | public const string Weird = "PL601B2E69B03FAB9D"; 14 | public const string ContainsLongVideos = "PLkk2FsMngwGi9FNkWIoNZlfqglcldj_Zs"; 15 | } 16 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/Converters/BoolToYesNoStringConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Avalonia.Data.Converters; 4 | 5 | namespace YoutubeExplode.Demo.Gui.Converters; 6 | 7 | public class BoolToYesNoStringConverter : IValueConverter 8 | { 9 | public static BoolToYesNoStringConverter Instance { get; } = new(); 10 | 11 | public object? Convert( 12 | object? value, 13 | Type targetType, 14 | object? parameter, 15 | CultureInfo culture 16 | ) => 17 | value is bool boolValue 18 | ? boolValue 19 | ? "yes" 20 | : "no" 21 | : default; 22 | 23 | public object ConvertBack( 24 | object? value, 25 | Type targetType, 26 | object? parameter, 27 | CultureInfo culture 28 | ) => throw new NotSupportedException(); 29 | } 30 | -------------------------------------------------------------------------------- /YoutubeExplode/Channels/Channel.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using YoutubeExplode.Common; 4 | 5 | namespace YoutubeExplode.Channels; 6 | 7 | /// 8 | /// Metadata associated with a YouTube channel. 9 | /// 10 | public class Channel(ChannelId id, string title, IReadOnlyList thumbnails) : IChannel 11 | { 12 | /// 13 | public ChannelId Id { get; } = id; 14 | 15 | /// 16 | public string Url => $"https://www.youtube.com/channel/{Id}"; 17 | 18 | /// 19 | public string Title { get; } = title; 20 | 21 | /// 22 | public IReadOnlyList Thumbnails { get; } = thumbnails; 23 | 24 | /// 25 | [ExcludeFromCodeCoverage] 26 | public override string ToString() => $"Channel ({Title})"; 27 | } 28 | -------------------------------------------------------------------------------- /YoutubeExplode/Search/ISearchResult.cs: -------------------------------------------------------------------------------- 1 | using YoutubeExplode.Common; 2 | 3 | namespace YoutubeExplode.Search; 4 | 5 | /// 6 | /// 7 | /// Abstract result returned by a search query. 8 | /// Use pattern matching to handle specific instances of this type. 9 | /// 10 | /// 11 | /// Can be either one of the following: 12 | /// 13 | /// 14 | /// 15 | /// 16 | /// 17 | /// 18 | /// 19 | public interface ISearchResult : IBatchItem 20 | { 21 | /// 22 | /// Result URL. 23 | /// 24 | string Url { get; } 25 | 26 | /// 27 | /// Result title. 28 | /// 29 | string Title { get; } 30 | } 31 | -------------------------------------------------------------------------------- /YoutubeExplode/Common/Batch.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using YoutubeExplode.Utils.Extensions; 3 | 4 | namespace YoutubeExplode.Common; 5 | 6 | /// 7 | /// Generic collection of items returned by a single request. 8 | /// 9 | public class Batch(IReadOnlyList items) 10 | where T : IBatchItem 11 | { 12 | /// 13 | /// Items included in the batch. 14 | /// 15 | public IReadOnlyList Items { get; } = items; 16 | } 17 | 18 | internal static class Batch 19 | { 20 | public static Batch Create(IReadOnlyList items) 21 | where T : IBatchItem => new(items); 22 | } 23 | 24 | internal static class BatchExtensions 25 | { 26 | extension(IAsyncEnumerable> source) 27 | where T : IBatchItem 28 | { 29 | public IAsyncEnumerable FlattenAsync() => source.SelectManyAsync(b => b.Items); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/ClosedCaptions/ClosedCaptionTrackInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace YoutubeExplode.Videos.ClosedCaptions; 4 | 5 | /// 6 | /// Metadata associated with a closed caption track of a YouTube video. 7 | /// 8 | public class ClosedCaptionTrackInfo(string url, Language language, bool isAutoGenerated) 9 | { 10 | /// 11 | /// Track URL. 12 | /// 13 | public string Url { get; } = url; 14 | 15 | /// 16 | /// Track language. 17 | /// 18 | public Language Language { get; } = language; 19 | 20 | /// 21 | /// Whether the track was automatically generated. 22 | /// 23 | public bool IsAutoGenerated { get; } = isAutoGenerated; 24 | 25 | /// 26 | [ExcludeFromCodeCoverage] 27 | public override string ToString() => $"CC Track ({Language})"; 28 | } 29 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/ClosedCaptions/ClosedCaptionController.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using YoutubeExplode.Bridge; 5 | using YoutubeExplode.Utils; 6 | using YoutubeExplode.Utils.Extensions; 7 | 8 | namespace YoutubeExplode.Videos.ClosedCaptions; 9 | 10 | internal class ClosedCaptionController(HttpClient http) : VideoController(http) 11 | { 12 | public async ValueTask GetClosedCaptionTrackResponseAsync( 13 | string url, 14 | CancellationToken cancellationToken = default 15 | ) 16 | { 17 | // Enforce known format 18 | var urlWithFormat = url.Pipe(s => UrlEx.SetQueryParameter(s, "format", "3")) 19 | .Pipe(s => UrlEx.SetQueryParameter(s, "fmt", "3")); 20 | 21 | return ClosedCaptionTrackResponse.Parse( 22 | await Http.GetStringAsync(urlWithFormat, cancellationToken) 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/Converters/EnumerableToJoinedStringConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Globalization; 4 | using System.Linq; 5 | using Avalonia.Data.Converters; 6 | 7 | namespace YoutubeExplode.Demo.Gui.Converters; 8 | 9 | public class EnumerableToJoinedStringConverter : IValueConverter 10 | { 11 | public static EnumerableToJoinedStringConverter Instance { get; } = new(); 12 | 13 | public object? Convert( 14 | object? value, 15 | Type targetType, 16 | object? parameter, 17 | CultureInfo culture 18 | ) => 19 | value is IEnumerable enumerableValue 20 | ? string.Join(parameter as string ?? ", ", enumerableValue.Cast()) 21 | : default; 22 | 23 | public object ConvertBack( 24 | object? value, 25 | Type targetType, 26 | object? parameter, 27 | CultureInfo culture 28 | ) => throw new NotSupportedException(); 29 | } 30 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/Utils/TempDir.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | 5 | namespace YoutubeExplode.Converter.Tests.Utils; 6 | 7 | internal partial class TempDir(string path) : IDisposable 8 | { 9 | public string Path { get; } = path; 10 | 11 | public void Dispose() 12 | { 13 | try 14 | { 15 | Directory.Delete(Path, true); 16 | } 17 | catch (DirectoryNotFoundException) { } 18 | } 19 | } 20 | 21 | internal partial class TempDir 22 | { 23 | public static TempDir Create() 24 | { 25 | var dirPath = System.IO.Path.Combine( 26 | System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) 27 | ?? Directory.GetCurrentDirectory(), 28 | "Temp", 29 | Guid.NewGuid().ToString() 30 | ); 31 | 32 | Directory.CreateDirectory(dirPath); 33 | 34 | return new TempDir(dirPath); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/IAudioStreamInfo.cs: -------------------------------------------------------------------------------- 1 | using YoutubeExplode.Videos.ClosedCaptions; 2 | 3 | namespace YoutubeExplode.Videos.Streams; 4 | 5 | /// 6 | /// Metadata associated with a media stream that contains audio. 7 | /// 8 | public interface IAudioStreamInfo : IStreamInfo 9 | { 10 | /// 11 | /// Audio codec. 12 | /// 13 | string AudioCodec { get; } 14 | 15 | /// 16 | /// Audio language. 17 | /// 18 | /// 19 | /// May be null if the audio stream does not contain language information. 20 | /// 21 | Language? AudioLanguage { get; } 22 | 23 | /// 24 | /// Whether the audio stream's language corresponds to the default language of the video. 25 | /// 26 | /// 27 | /// May be null if the audio stream does not contain language information. 28 | /// 29 | bool? IsAudioLanguageDefault { get; } 30 | } 31 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/Utils/TempFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | 5 | namespace YoutubeExplode.Tests.Utils; 6 | 7 | internal partial class TempFile(string path) : IDisposable 8 | { 9 | public string Path { get; } = path; 10 | 11 | public void Dispose() 12 | { 13 | try 14 | { 15 | File.Delete(Path); 16 | } 17 | catch (FileNotFoundException) { } 18 | } 19 | } 20 | 21 | internal partial class TempFile 22 | { 23 | public static TempFile Create() 24 | { 25 | var dirPath = System.IO.Path.Combine( 26 | System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) 27 | ?? Directory.GetCurrentDirectory(), 28 | "Temp" 29 | ); 30 | 31 | Directory.CreateDirectory(dirPath); 32 | 33 | var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp"); 34 | 35 | return new TempFile(filePath); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/Utils/TempFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | 5 | namespace YoutubeExplode.Converter.Tests.Utils; 6 | 7 | internal partial class TempFile(string path) : IDisposable 8 | { 9 | public string Path { get; } = path; 10 | 11 | public void Dispose() 12 | { 13 | try 14 | { 15 | File.Delete(Path); 16 | } 17 | catch (FileNotFoundException) { } 18 | } 19 | } 20 | 21 | internal partial class TempFile 22 | { 23 | public static TempFile Create() 24 | { 25 | var dirPath = System.IO.Path.Combine( 26 | System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) 27 | ?? Directory.GetCurrentDirectory(), 28 | "Temp" 29 | ); 30 | 31 | Directory.CreateDirectory(dirPath); 32 | 33 | var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp"); 34 | 35 | return new TempFile(filePath); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /YoutubeExplode/Playlists/IPlaylist.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using YoutubeExplode.Common; 3 | 4 | namespace YoutubeExplode.Playlists; 5 | 6 | /// 7 | /// Properties shared by playlist metadata resolved from different sources. 8 | /// 9 | public interface IPlaylist 10 | { 11 | /// 12 | /// Playlist ID. 13 | /// 14 | PlaylistId Id { get; } 15 | 16 | /// 17 | /// Playlist URL. 18 | /// 19 | string Url { get; } 20 | 21 | /// 22 | /// Playlist title. 23 | /// 24 | string Title { get; } 25 | 26 | /// 27 | /// Playlist author. 28 | /// 29 | /// 30 | /// May be null in case of auto-generated playlists (e.g. mixes, topics, etc). 31 | /// 32 | Author? Author { get; } 33 | 34 | /// 35 | /// Playlist thumbnails. 36 | /// 37 | IReadOnlyList Thumbnails { get; } 38 | } 39 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/Utils/Extensions/FileExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace YoutubeExplode.Converter.Tests.Utils.Extensions; 4 | 5 | internal static class FileExtensions 6 | { 7 | extension(File) 8 | { 9 | public static bool ContainsBytes(string filePath, byte[] data) 10 | { 11 | using var stream = File.OpenRead(filePath); 12 | using var reader = new BinaryReader(stream); 13 | 14 | var referenceIndex = 0; 15 | 16 | while (stream.Position < stream.Length) 17 | { 18 | if (reader.ReadByte() == data[referenceIndex]) 19 | { 20 | referenceIndex++; 21 | } 22 | else 23 | { 24 | referenceIndex = 0; 25 | } 26 | 27 | if (referenceIndex >= data.Length) 28 | return true; 29 | } 30 | 31 | return false; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/App.axaml.cs: -------------------------------------------------------------------------------- 1 | using Avalonia; 2 | using Avalonia.Controls.ApplicationLifetimes; 3 | using Avalonia.Markup.Xaml; 4 | using Avalonia.Media; 5 | using Material.Styles.Themes; 6 | using YoutubeExplode.Demo.Gui.Views; 7 | 8 | namespace YoutubeExplode.Demo.Gui; 9 | 10 | public class App : Application 11 | { 12 | public override void Initialize() 13 | { 14 | base.Initialize(); 15 | 16 | AvaloniaXamlLoader.Load(this); 17 | } 18 | 19 | public override void OnFrameworkInitializationCompleted() 20 | { 21 | if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) 22 | desktopLifetime.MainWindow = new MainWindow(); 23 | 24 | base.OnFrameworkInitializationCompleted(); 25 | 26 | // Set up custom theme colors 27 | this.LocateMaterialTheme().CurrentTheme = Theme.Create( 28 | Theme.Light, 29 | Color.Parse("#343838"), 30 | Color.Parse("#F9A825") 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /YoutubeExplode/Search/ChannelSearchResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using YoutubeExplode.Channels; 4 | using YoutubeExplode.Common; 5 | 6 | namespace YoutubeExplode.Search; 7 | 8 | /// 9 | /// Metadata associated with a YouTube channel returned by a search query. 10 | /// 11 | public class ChannelSearchResult(ChannelId id, string title, IReadOnlyList thumbnails) 12 | : ISearchResult, 13 | IChannel 14 | { 15 | /// 16 | public ChannelId Id { get; } = id; 17 | 18 | /// 19 | public string Url => $"https://www.youtube.com/channel/{Id}"; 20 | 21 | /// 22 | public string Title { get; } = title; 23 | 24 | /// 25 | public IReadOnlyList Thumbnails { get; } = thumbnails; 26 | 27 | /// 28 | [ExcludeFromCodeCoverage] 29 | public override string ToString() => $"Channel ({Title})"; 30 | } 31 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Cli/Utils/ConsoleProgress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace YoutubeExplode.Demo.Cli.Utils; 5 | 6 | internal class ConsoleProgress(TextWriter writer) : IProgress, IDisposable 7 | { 8 | private readonly int _posX = Console.CursorLeft; 9 | private readonly int _posY = Console.CursorTop; 10 | 11 | private int _lastLength; 12 | 13 | public ConsoleProgress() 14 | : this(Console.Out) { } 15 | 16 | private void EraseLast() 17 | { 18 | if (_lastLength > 0) 19 | { 20 | Console.SetCursorPosition(_posX, _posY); 21 | writer.Write(new string(' ', _lastLength)); 22 | Console.SetCursorPosition(_posX, _posY); 23 | } 24 | } 25 | 26 | private void Write(string text) 27 | { 28 | EraseLast(); 29 | writer.Write(text); 30 | _lastLength = text.Length; 31 | } 32 | 33 | public void Report(double progress) => Write($"{progress:P1}"); 34 | 35 | public void Dispose() => EraseLast(); 36 | } 37 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/ConversionPreset.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Converter; 2 | 3 | /// 4 | /// Encoder preset. 5 | /// 6 | public enum ConversionPreset 7 | { 8 | /// 9 | /// Much slower conversion speed and smaller output file size. 10 | /// 11 | VerySlow = -2, 12 | 13 | /// 14 | /// Slightly slower conversion speed and smaller output file size. 15 | /// 16 | Slow = -1, 17 | 18 | /// 19 | /// Default preset. 20 | /// Balanced conversion speed and output file size. 21 | /// 22 | Medium = 0, 23 | 24 | /// 25 | /// Slightly faster conversion speed and bigger output file size. 26 | /// 27 | Fast = 1, 28 | 29 | /// 30 | /// Much faster conversion speed and bigger output file size. 31 | /// 32 | VeryFast = 2, 33 | 34 | /// 35 | /// Fastest conversion speed and biggest output file size. 36 | /// 37 | UltraFast = 3, 38 | } 39 | -------------------------------------------------------------------------------- /YoutubeExplode/Common/Author.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using YoutubeExplode.Channels; 4 | 5 | namespace YoutubeExplode.Common; 6 | 7 | /// 8 | /// Reference to a channel that owns a specific YouTube video or playlist. 9 | /// 10 | public class Author(ChannelId channelId, string channelTitle) 11 | { 12 | /// 13 | /// Channel ID. 14 | /// 15 | public ChannelId ChannelId { get; } = channelId; 16 | 17 | /// 18 | /// Channel URL. 19 | /// 20 | public string ChannelUrl => $"https://www.youtube.com/channel/{ChannelId}"; 21 | 22 | /// 23 | /// Channel title. 24 | /// 25 | public string ChannelTitle { get; } = channelTitle; 26 | 27 | /// 28 | [Obsolete("Use ChannelTitle instead."), ExcludeFromCodeCoverage] 29 | public string Title => ChannelTitle; 30 | 31 | /// 32 | [ExcludeFromCodeCoverage] 33 | public override string ToString() => ChannelTitle; 34 | } 35 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/IVideo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using YoutubeExplode.Common; 4 | 5 | namespace YoutubeExplode.Videos; 6 | 7 | /// 8 | /// Properties shared by video metadata resolved from different sources. 9 | /// 10 | public interface IVideo 11 | { 12 | /// 13 | /// Video ID. 14 | /// 15 | VideoId Id { get; } 16 | 17 | /// 18 | /// Video URL. 19 | /// 20 | string Url { get; } 21 | 22 | /// 23 | /// Video title. 24 | /// 25 | string Title { get; } 26 | 27 | /// 28 | /// Video author. 29 | /// 30 | Author Author { get; } 31 | 32 | /// 33 | /// Video duration. 34 | /// 35 | /// 36 | /// May be null if the video is a currently ongoing live stream. 37 | /// 38 | TimeSpan? Duration { get; } 39 | 40 | /// 41 | /// Video thumbnails. 42 | /// 43 | IReadOnlyList Thumbnails { get; } 44 | } 45 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2026 Oleksii Holub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /YoutubeExplode/Videos/ClosedCaptions/ClosedCaptionTrack.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace YoutubeExplode.Videos.ClosedCaptions; 6 | 7 | /// 8 | /// Contains closed captions in a specific language. 9 | /// 10 | public class ClosedCaptionTrack(IReadOnlyList captions) 11 | { 12 | /// 13 | /// Closed captions included in the track. 14 | /// 15 | public IReadOnlyList Captions { get; } = captions; 16 | 17 | /// 18 | /// Gets the caption displayed at the specified point in time. 19 | /// Returns null if not found. 20 | /// 21 | public ClosedCaption? TryGetByTime(TimeSpan time) => 22 | Captions.FirstOrDefault(c => time >= c.Offset && time <= c.Offset + c.Duration); 23 | 24 | /// 25 | /// Gets the caption displayed at the specified point in time. 26 | /// 27 | public ClosedCaption GetByTime(TimeSpan time) => 28 | TryGetByTime(time) 29 | ?? throw new InvalidOperationException($"No closed caption found at {time}."); 30 | } 31 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Extensions/StreamExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.IO; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace YoutubeExplode.Utils.Extensions; 8 | 9 | internal static class StreamExtensions 10 | { 11 | extension(Stream source) 12 | { 13 | public async ValueTask CopyToAsync( 14 | Stream destination, 15 | IProgress? progress = null, 16 | CancellationToken cancellationToken = default 17 | ) 18 | { 19 | using var buffer = MemoryPool.Shared.Rent(81920); 20 | 21 | var totalBytesRead = 0L; 22 | while (true) 23 | { 24 | var bytesRead = await source.ReadAsync(buffer.Memory, cancellationToken); 25 | if (bytesRead <= 0) 26 | break; 27 | 28 | await destination.WriteAsync(buffer.Memory[..bytesRead], cancellationToken); 29 | 30 | totalBytesRead += bytesRead; 31 | progress?.Report(1.0 * totalBytesRead / source.Length); 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/ChannelPage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AngleSharp.Html.Dom; 3 | using Lazy; 4 | using YoutubeExplode.Utils; 5 | using YoutubeExplode.Utils.Extensions; 6 | 7 | namespace YoutubeExplode.Bridge; 8 | 9 | internal partial class ChannelPage(IHtmlDocument content) 10 | { 11 | [Lazy] 12 | public string? Url => 13 | content.QuerySelector("meta[property=\"og:url\"]")?.GetAttribute("content"); 14 | 15 | [Lazy] 16 | public string? Id => Url?.SubstringAfter("channel/", StringComparison.OrdinalIgnoreCase); 17 | 18 | [Lazy] 19 | public string? Title => 20 | content.QuerySelector("meta[property=\"og:title\"]")?.GetAttribute("content"); 21 | 22 | [Lazy] 23 | public string? LogoUrl => 24 | content.QuerySelector("meta[property=\"og:image\"]")?.GetAttribute("content"); 25 | } 26 | 27 | internal partial class ChannelPage 28 | { 29 | public static ChannelPage? TryParse(string raw) 30 | { 31 | var content = Html.Parse(raw); 32 | 33 | if (content.QuerySelector("meta[property=\"og:url\"]") is null) 34 | return null; 35 | 36 | return new ChannelPage(content); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/YoutubeExplode.Converter.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /YoutubeExplode/Search/PlaylistSearchResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using YoutubeExplode.Common; 4 | using YoutubeExplode.Playlists; 5 | 6 | namespace YoutubeExplode.Search; 7 | 8 | /// 9 | /// Metadata associated with a YouTube playlist returned by a search query. 10 | /// 11 | public class PlaylistSearchResult( 12 | PlaylistId id, 13 | string title, 14 | Author? author, 15 | IReadOnlyList thumbnails 16 | ) : ISearchResult, IPlaylist 17 | { 18 | /// 19 | public PlaylistId Id { get; } = id; 20 | 21 | /// 22 | public string Url => $"https://www.youtube.com/playlist?list={Id}"; 23 | 24 | /// 25 | public string Title { get; } = title; 26 | 27 | /// 28 | public Author? Author { get; } = author; 29 | 30 | /// 31 | public IReadOnlyList Thumbnails { get; } = thumbnails; 32 | 33 | /// 34 | [ExcludeFromCodeCoverage] 35 | public override string ToString() => $"Playlist ({Title})"; 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | package-version: 7 | type: string 8 | description: Package version 9 | required: false 10 | deploy: 11 | type: boolean 12 | description: Deploy package 13 | required: false 14 | default: false 15 | schedule: 16 | - cron: "0 0 * * *" 17 | push: 18 | branches: 19 | - master 20 | tags: 21 | - "*" 22 | pull_request: 23 | branches: 24 | - master 25 | 26 | jobs: 27 | main: 28 | uses: Tyrrrz/.github/.github/workflows/nuget.yml@master 29 | with: 30 | # CI tests currently don't work due to YouTube's anti-bot measures 31 | # https://github.com/Tyrrrz/YoutubeExplode/issues/794 32 | skip-tests: true 33 | deploy: ${{ inputs.deploy || github.ref_type == 'tag' }} 34 | package-version: ${{ inputs.package-version || (github.ref_type == 'tag' && github.ref_name) || format('0.0.0-ci-{0}', github.sha) }} 35 | secrets: 36 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 37 | NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} 38 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} 39 | -------------------------------------------------------------------------------- /YoutubeExplode/Search/VideoSearchResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using YoutubeExplode.Common; 5 | using YoutubeExplode.Videos; 6 | 7 | namespace YoutubeExplode.Search; 8 | 9 | /// 10 | /// Metadata associated with a YouTube video returned by a search query. 11 | /// 12 | public class VideoSearchResult( 13 | VideoId id, 14 | string title, 15 | Author author, 16 | TimeSpan? duration, 17 | IReadOnlyList thumbnails 18 | ) : ISearchResult, IVideo 19 | { 20 | /// 21 | public VideoId Id { get; } = id; 22 | 23 | /// 24 | public string Url => $"https://www.youtube.com/watch?v={Id}"; 25 | 26 | /// 27 | public string Title { get; } = title; 28 | 29 | /// 30 | public Author Author { get; } = author; 31 | 32 | /// 33 | public TimeSpan? Duration { get; } = duration; 34 | 35 | /// 36 | public IReadOnlyList Thumbnails { get; } = thumbnails; 37 | 38 | /// 39 | [ExcludeFromCodeCoverage] 40 | public override string ToString() => $"Video ({Title})"; 41 | } 42 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Engagement.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace YoutubeExplode.Videos; 4 | 5 | /// 6 | /// Engagement statistics. 7 | /// 8 | public class Engagement(long viewCount, long likeCount, long dislikeCount) 9 | { 10 | /// 11 | /// View count. 12 | /// 13 | public long ViewCount { get; } = viewCount; 14 | 15 | /// 16 | /// Like count. 17 | /// 18 | public long LikeCount { get; } = likeCount; 19 | 20 | /// 21 | /// Dislike count. 22 | /// 23 | /// 24 | /// YouTube no longer shows dislikes, so this value is always 0. 25 | /// 26 | public long DislikeCount { get; } = dislikeCount; 27 | 28 | /// 29 | /// Average rating. 30 | /// 31 | /// 32 | /// YouTube no longer shows dislikes, so this value is always 5. 33 | /// 34 | public double AverageRating => 35 | LikeCount + DislikeCount != 0 ? 1 + 4.0 * LikeCount / (LikeCount + DislikeCount) : 0; // avoid division by 0 36 | 37 | /// 38 | [ExcludeFromCodeCoverage] 39 | public override string ToString() => $"Rating: {AverageRating:N1}"; 40 | } 41 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/YoutubeExplode.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | $(TargetFrameworks);net48 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/ClientDelegatingHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using YoutubeExplode.Utils.Extensions; 5 | 6 | namespace YoutubeExplode.Utils; 7 | 8 | // Like DelegatingHandler, but wraps an HttpClient instead of an HttpMessageHandler. 9 | // Used to extend an externally provided HttpClient with additional behavior. 10 | internal abstract class ClientDelegatingHandler(HttpClient http, bool disposeClient = false) 11 | : HttpMessageHandler 12 | { 13 | protected override async Task SendAsync( 14 | HttpRequestMessage request, 15 | CancellationToken cancellationToken 16 | ) 17 | { 18 | // Clone the request to reset its completion status, which is required 19 | // in order to pass the request from one HttpClient to another. 20 | using var clonedRequest = request.Clone(); 21 | 22 | return await http.SendAsync( 23 | clonedRequest, 24 | HttpCompletionOption.ResponseHeadersRead, 25 | cancellationToken 26 | ); 27 | } 28 | 29 | protected override void Dispose(bool disposing) 30 | { 31 | if (disposing && disposeClient) 32 | http.Dispose(); 33 | 34 | base.Dispose(disposing); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/VideoOnlyStreamInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using YoutubeExplode.Common; 3 | 4 | namespace YoutubeExplode.Videos.Streams; 5 | 6 | /// 7 | /// Metadata associated with a video-only media stream. 8 | /// 9 | public class VideoOnlyStreamInfo( 10 | string url, 11 | Container container, 12 | FileSize size, 13 | Bitrate bitrate, 14 | string videoCodec, 15 | VideoQuality videoQuality, 16 | Resolution videoResolution 17 | ) : IVideoStreamInfo 18 | { 19 | /// 20 | public string Url { get; } = url; 21 | 22 | /// 23 | public Container Container { get; } = container; 24 | 25 | /// 26 | public FileSize Size { get; } = size; 27 | 28 | /// 29 | public Bitrate Bitrate { get; } = bitrate; 30 | 31 | /// 32 | public string VideoCodec { get; } = videoCodec; 33 | 34 | /// 35 | public VideoQuality VideoQuality { get; } = videoQuality; 36 | 37 | /// 38 | public Resolution VideoResolution { get; } = videoResolution; 39 | 40 | /// 41 | [ExcludeFromCodeCoverage] 42 | public override string ToString() => $"Video-only ({VideoQuality} | {Container})"; 43 | } 44 | -------------------------------------------------------------------------------- /YoutubeExplode/FodyWeavers.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. 12 | 13 | 14 | 15 | 16 | A comma-separated list of error codes that can be safely ignored in assembly verification. 17 | 18 | 19 | 20 | 21 | 'false' to turn off automatic generation of the XML Schema file. 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/YoutubeExplode.Demo.Gui.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | WinExe 4 | net10.0 5 | ../favicon.ico 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Extensions/XElementExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Xml.Linq; 3 | 4 | namespace YoutubeExplode.Utils.Extensions; 5 | 6 | internal static class XElementExtensions 7 | { 8 | extension(XElement element) 9 | { 10 | public XElement StripNamespaces() 11 | { 12 | // Adapted from http://stackoverflow.com/a/1147012 13 | 14 | var result = new XElement(element); 15 | 16 | foreach (var descendantElement in result.DescendantsAndSelf()) 17 | { 18 | descendantElement.Name = XNamespace.None.GetName(descendantElement.Name.LocalName); 19 | 20 | descendantElement.ReplaceAttributes( 21 | descendantElement 22 | .Attributes() 23 | .Where(a => !a.IsNamespaceDeclaration) 24 | .Where(a => 25 | a.Name.Namespace != XNamespace.Xml 26 | && a.Name.Namespace != XNamespace.Xmlns 27 | ) 28 | .Select(a => new XAttribute( 29 | XNamespace.None.GetName(a.Name.LocalName), 30 | a.Value 31 | )) 32 | ); 33 | } 34 | 35 | return result; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/TestData/VideoIds.cs: -------------------------------------------------------------------------------- 1 | namespace YoutubeExplode.Tests.TestData; 2 | 3 | internal static class VideoIds 4 | { 5 | public const string Normal = "9bZkp7q19f0"; 6 | public const string Unlisted = "UGh4_HsibAE"; 7 | public const string Private = "pb_hHv3fByo"; 8 | public const string Deleted = "qld9w0b-1ao"; 9 | public const string EmbedRestrictedByYouTube = "_kmeFXjjGfk"; 10 | public const string EmbedRestrictedByAuthor = "MeJVWBSsPAY"; 11 | public const string ContentCheckViolent = "rXMX4YJ7Lks"; 12 | public const string ContentCheckSexual = "SkRSXFQerZs"; 13 | public const string ContentCheckSuicide = "4QXCPuwBz2E"; 14 | public const string RequiresPurchase = "p3dDcKOFXQg"; 15 | public const string RequiresPurchaseDistributed = "qs3NZHVM_Ik"; 16 | public const string LiveStream = "jfKfPfyJRdk"; 17 | public const string LiveStreamRecording = "rsAAeyAr-9Y"; 18 | public const string WithBrokenTitle = "4ZJWv6t-PfY"; 19 | public const string WithHighQualityStreams = "V5Fsj_sCKdg"; 20 | public const string WithOmnidirectionalStreams = "-xNN-bJQ4vI"; 21 | public const string WithHighDynamicRangeStreams = "vX2vsvdq8nw"; 22 | public const string WithClosedCaptions = "YltHGKX80Y8"; 23 | public const string WithBrokenClosedCaptions = "1VKIIw05JnE"; 24 | public const string WithMultipleAudioLanguages = "ngqcjXfggHQ"; 25 | } 26 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/Utils/ProgressMuxer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | 5 | namespace YoutubeExplode.Converter.Utils; 6 | 7 | internal class ProgressMuxer(IProgress target) 8 | { 9 | private readonly Lock _lock = new(); 10 | private readonly Dictionary _splitWeights = new(); 11 | private readonly Dictionary _splitValues = new(); 12 | 13 | public IProgress CreateInput(double weight = 1) 14 | { 15 | using (_lock.EnterScope()) 16 | { 17 | var index = _splitWeights.Count; 18 | 19 | _splitWeights[index] = weight; 20 | _splitValues[index] = 0; 21 | 22 | return new Progress(p => 23 | { 24 | using (_lock.EnterScope()) 25 | { 26 | _splitValues[index] = p; 27 | 28 | var weightedSum = 0.0; 29 | var weightedMax = 0.0; 30 | 31 | for (var i = 0; i < _splitWeights.Count; i++) 32 | { 33 | weightedSum += _splitWeights[i] * _splitValues[i]; 34 | weightedMax += _splitWeights[i]; 35 | } 36 | 37 | target.Report(weightedSum / weightedMax); 38 | } 39 | }); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Extensions/CollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace YoutubeExplode.Utils.Extensions; 5 | 6 | internal static class CollectionExtensions 7 | { 8 | extension(IEnumerable source) 9 | where T : class 10 | { 11 | public IEnumerable WhereNotNull() 12 | { 13 | foreach (var i in source) 14 | { 15 | if (i is not null) 16 | yield return i; 17 | } 18 | } 19 | } 20 | 21 | extension(IEnumerable source) 22 | where T : struct 23 | { 24 | public IEnumerable WhereNotNull() 25 | { 26 | foreach (var i in source) 27 | { 28 | if (i is not null) 29 | yield return i.Value; 30 | } 31 | } 32 | } 33 | 34 | extension(IEnumerable source) 35 | where T : struct 36 | { 37 | public T? ElementAtOrNull(int index) 38 | { 39 | var sourceAsList = source as IReadOnlyList ?? source.ToArray(); 40 | return index < sourceAsList.Count ? sourceAsList[index] : null; 41 | } 42 | 43 | public T? FirstOrNull() 44 | { 45 | foreach (var i in source) 46 | return i; 47 | 48 | return null; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/StreamController.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Text.RegularExpressions; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using YoutubeExplode.Bridge; 6 | using YoutubeExplode.Exceptions; 7 | 8 | namespace YoutubeExplode.Videos.Streams; 9 | 10 | internal class StreamController(HttpClient http) : VideoController(http) 11 | { 12 | public async ValueTask GetPlayerSourceAsync( 13 | CancellationToken cancellationToken = default 14 | ) 15 | { 16 | var iframe = await Http.GetStringAsync( 17 | "https://www.youtube.com/iframe_api", 18 | cancellationToken 19 | ); 20 | 21 | var version = Regex.Match(iframe, @"player\\?/([0-9a-fA-F]{8})\\?/").Groups[1].Value; 22 | if (string.IsNullOrWhiteSpace(version)) 23 | throw new YoutubeExplodeException("Failed to extract the player version."); 24 | 25 | return PlayerSource.Parse( 26 | await Http.GetStringAsync( 27 | $"https://www.youtube.com/s/player/{version}/player_ias.vflset/en_US/base.js", 28 | cancellationToken 29 | ) 30 | ); 31 | } 32 | 33 | public async ValueTask GetDashManifestAsync( 34 | string url, 35 | CancellationToken cancellationToken = default 36 | ) => DashManifest.Parse(await Http.GetStringAsync(url, cancellationToken)); 37 | } 38 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/AudioOnlyStreamInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using YoutubeExplode.Videos.ClosedCaptions; 3 | 4 | namespace YoutubeExplode.Videos.Streams; 5 | 6 | /// 7 | /// Metadata associated with an audio-only YouTube media stream. 8 | /// 9 | public class AudioOnlyStreamInfo( 10 | string url, 11 | Container container, 12 | FileSize size, 13 | Bitrate bitrate, 14 | string audioCodec, 15 | Language? audioLanguage, 16 | bool? isAudioLanguageDefault 17 | ) : IAudioStreamInfo 18 | { 19 | /// 20 | public string Url { get; } = url; 21 | 22 | /// 23 | public Container Container { get; } = container; 24 | 25 | /// 26 | public FileSize Size { get; } = size; 27 | 28 | /// 29 | public Bitrate Bitrate { get; } = bitrate; 30 | 31 | /// 32 | public string AudioCodec { get; } = audioCodec; 33 | 34 | /// 35 | public Language? AudioLanguage { get; } = audioLanguage; 36 | 37 | /// 38 | public bool? IsAudioLanguageDefault { get; } = isAudioLanguageDefault; 39 | 40 | /// 41 | [ExcludeFromCodeCoverage] 42 | public override string ToString() => 43 | AudioLanguage is not null 44 | ? $"Audio-only ({Container} | {AudioLanguage})" 45 | : $"Audio-only ({Container})"; 46 | } 47 | -------------------------------------------------------------------------------- /YoutubeExplode/Playlists/Playlist.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using YoutubeExplode.Common; 4 | 5 | namespace YoutubeExplode.Playlists; 6 | 7 | /// 8 | /// Metadata associated with a YouTube playlist. 9 | /// 10 | public class Playlist( 11 | PlaylistId id, 12 | string title, 13 | Author? author, 14 | string description, 15 | int? count, 16 | IReadOnlyList thumbnails 17 | ) : IPlaylist 18 | { 19 | /// 20 | public PlaylistId Id { get; } = id; 21 | 22 | /// 23 | public string Url => $"https://www.youtube.com/playlist?list={Id}"; 24 | 25 | /// 26 | public string Title { get; } = title; 27 | 28 | /// 29 | public Author? Author { get; } = author; 30 | 31 | /// 32 | /// Playlist description. 33 | /// 34 | public string Description { get; } = description; 35 | 36 | /// 37 | /// Total count of videos included in the playlist. 38 | /// 39 | /// 40 | /// May be null in case of infinite playlists (e.g. auto-generated mixes). 41 | /// 42 | public int? Count { get; } = count; 43 | 44 | /// 45 | public IReadOnlyList Thumbnails { get; } = thumbnails; 46 | 47 | /// 48 | [ExcludeFromCodeCoverage] 49 | public override string ToString() => $"Playlist ({Title})"; 50 | } 51 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/ClosedCaptions/ClosedCaptionManifest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace YoutubeExplode.Videos.ClosedCaptions; 6 | 7 | /// 8 | /// Describes closed caption tracks available for a YouTube video. 9 | /// 10 | public class ClosedCaptionManifest(IReadOnlyList tracks) 11 | { 12 | /// 13 | /// Available closed caption tracks. 14 | /// 15 | public IReadOnlyList Tracks { get; } = tracks; 16 | 17 | /// 18 | /// Gets the closed caption track in the specified language (identified by ISO-639-1 code or display name). 19 | /// Returns null if not found. 20 | /// 21 | public ClosedCaptionTrackInfo? TryGetByLanguage(string language) => 22 | Tracks.FirstOrDefault(t => 23 | string.Equals(t.Language.Code, language, StringComparison.OrdinalIgnoreCase) 24 | || string.Equals(t.Language.Name, language, StringComparison.OrdinalIgnoreCase) 25 | ); 26 | 27 | /// 28 | /// Gets the closed caption track in the specified language (identified by ISO-639-1 code or display name). 29 | /// 30 | public ClosedCaptionTrackInfo GetByLanguage(string language) => 31 | TryGetByLanguage(language) 32 | ?? throw new InvalidOperationException( 33 | $"No closed caption track available for language '{language}'." 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/UserNameSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FluentAssertions; 3 | using Xunit; 4 | using YoutubeExplode.Channels; 5 | 6 | namespace YoutubeExplode.Tests; 7 | 8 | public class UserNameSpecs 9 | { 10 | [Theory] 11 | [InlineData("TheTyrrr")] 12 | [InlineData("KannibalenRecords")] 13 | [InlineData("JClayton1994")] 14 | public void I_can_parse_a_user_name_from_a_user_name_string(string userName) 15 | { 16 | // Act 17 | var parsed = UserName.Parse(userName); 18 | 19 | // Assert 20 | parsed.Value.Should().Be(userName); 21 | } 22 | 23 | [Theory] 24 | [InlineData("youtube.com/user/ProZD", "ProZD")] 25 | [InlineData("youtube.com/user/TheTyrrr", "TheTyrrr")] 26 | public void I_can_parse_a_user_name_from_a_URL_string(string userUrl, string expectedUserName) 27 | { 28 | // Act 29 | var parsed = UserName.Parse(userUrl); 30 | 31 | // Assert 32 | parsed.Value.Should().Be(expectedUserName); 33 | } 34 | 35 | [Theory] 36 | [InlineData("")] 37 | [InlineData("The_Tyrrr")] 38 | [InlineData("0123456789ABCDEFGHIJK")] 39 | [InlineData("A1B2C3-")] 40 | [InlineData("=0123456789ABCDEF")] 41 | [InlineData("youtube.com/user/P_roZD")] 42 | [InlineData("example.com/user/ProZD")] 43 | public void I_can_try_to_parse_a_user_name_and_get_an_error_if_the_input_string_is_invalid( 44 | string userName 45 | ) 46 | { 47 | // Act & assert 48 | Assert.Throws(() => UserName.Parse(userName)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/YoutubeExplode.Converter.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netstandard2.0;net6.0;net7.0;net10.0 4 | true 5 | true 9 | true 13 | 14 | 15 | 16 | Extension for YoutubeExplode that provides an interface to download and convert videos using FFmpeg 17 | https://github.com/Tyrrrz/YoutubeExplode/tree/master/YoutubeExplode.Converter 18 | favicon.png 19 | true 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /YoutubeExplode/Common/Resolution.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace YoutubeExplode.Common; 5 | 6 | /// 7 | /// Resolution of an image or a video. 8 | /// 9 | public readonly partial struct Resolution(int width, int height) 10 | { 11 | /// 12 | /// Viewport width, measured in pixels. 13 | /// 14 | public int Width { get; } = width; 15 | 16 | /// 17 | /// Viewport height, measured in pixels. 18 | /// 19 | public int Height { get; } = height; 20 | 21 | /// 22 | /// Viewport area (i.e. width multiplied by height). 23 | /// 24 | public int Area => Width * Height; 25 | 26 | /// 27 | [ExcludeFromCodeCoverage] 28 | public override string ToString() => $"{Width}x{Height}"; 29 | } 30 | 31 | public partial struct Resolution : IEquatable 32 | { 33 | /// 34 | public bool Equals(Resolution other) => Width == other.Width && Height == other.Height; 35 | 36 | /// 37 | public override bool Equals(object? obj) => obj is Resolution other && Equals(other); 38 | 39 | /// 40 | public override int GetHashCode() => HashCode.Combine(Width, Height); 41 | 42 | /// 43 | /// Equality check. 44 | /// 45 | public static bool operator ==(Resolution left, Resolution right) => left.Equals(right); 46 | 47 | /// 48 | /// Equality check. 49 | /// 50 | public static bool operator !=(Resolution left, Resolution right) => !(left == right); 51 | } 52 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/IVideoStreamInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using YoutubeExplode.Common; 5 | 6 | namespace YoutubeExplode.Videos.Streams; 7 | 8 | /// 9 | /// Metadata associated with a media stream that contains video. 10 | /// 11 | public interface IVideoStreamInfo : IStreamInfo 12 | { 13 | /// 14 | /// Video codec. 15 | /// 16 | string VideoCodec { get; } 17 | 18 | /// 19 | /// Video quality. 20 | /// 21 | VideoQuality VideoQuality { get; } 22 | 23 | /// 24 | /// Video resolution. 25 | /// 26 | Resolution VideoResolution { get; } 27 | } 28 | 29 | /// 30 | /// Extensions for . 31 | /// 32 | public static class VideoStreamInfoExtensions 33 | { 34 | /// 35 | /// Gets the video stream with the highest video quality (including framerate). 36 | /// Returns null if the sequence is empty. 37 | /// 38 | public static IVideoStreamInfo? TryGetWithHighestVideoQuality( 39 | this IEnumerable streamInfos 40 | ) => streamInfos.MaxBy(s => s.VideoQuality); 41 | 42 | /// 43 | /// Gets the video stream with the highest video quality (including framerate). 44 | /// 45 | public static IVideoStreamInfo GetWithHighestVideoQuality( 46 | this IEnumerable streamInfos 47 | ) => 48 | streamInfos.TryGetWithHighestVideoQuality() 49 | ?? throw new InvalidOperationException("Input stream collection is empty."); 50 | } 51 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/ChannelHandleSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FluentAssertions; 3 | using Xunit; 4 | using YoutubeExplode.Channels; 5 | 6 | namespace YoutubeExplode.Tests; 7 | 8 | public class ChannelHandleSpecs 9 | { 10 | [Theory] 11 | [InlineData("BeauMiles")] 12 | [InlineData("a-z.0_9")] 13 | public void I_can_parse_a_channel_handle_from_a_handle_string(string channelHandle) 14 | { 15 | // Act 16 | var parsed = ChannelHandle.Parse(channelHandle); 17 | 18 | // Assert 19 | parsed.Value.Should().Be(channelHandle); 20 | } 21 | 22 | [Theory] 23 | [InlineData("youtube.com/@BeauMiles", "BeauMiles")] 24 | [InlineData("youtube.com/@a-z.0_9", "a-z.0_9")] 25 | public void I_can_parse_a_channel_handle_from_a_URL_string( 26 | string channelUrl, 27 | string expectedChannelHandle 28 | ) 29 | { 30 | // Act 31 | var parsed = ChannelHandle.Parse(channelUrl); 32 | 33 | // Assert 34 | parsed.Value.Should().Be(expectedChannelHandle); 35 | } 36 | 37 | [Theory] 38 | [InlineData("")] 39 | [InlineData("foo bar")] 40 | [InlineData("youtube.com/")] 41 | [InlineData("youtube.com@BeauMiles")] 42 | [InlineData("youtube.com/@=BeauMiles")] 43 | [InlineData("youtube.com/@BeauMile$")] 44 | [InlineData("youtube.com/@Beau+Miles")] 45 | [InlineData("youtube.com/?@BeauMiles")] 46 | public void I_can_try_to_parse_a_channel_handle_and_get_an_error_if_the_input_string_is_invalid( 47 | string channelHandleOrUrl 48 | ) 49 | { 50 | // Act & assert 51 | Assert.Throws(() => ChannelHandle.Parse(channelHandleOrUrl)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /YoutubeExplode/Common/Language.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | // TODO: breaking change: update the namespace 5 | // ReSharper disable once CheckNamespace 6 | namespace YoutubeExplode.Videos.ClosedCaptions; 7 | 8 | /// 9 | /// Language information. 10 | /// 11 | public readonly partial struct Language(string code, string name) 12 | { 13 | /// 14 | /// Two-letter or three-letter language code, possibly with a regional identifier 15 | /// (e.g. 'en' or 'en-US' or 'eng'). 16 | /// 17 | public string Code { get; } = code; 18 | 19 | /// 20 | /// Full international name of the language. 21 | /// 22 | public string Name { get; } = name; 23 | 24 | /// 25 | [ExcludeFromCodeCoverage] 26 | public override string ToString() => $"{Code} ({Name})"; 27 | } 28 | 29 | public partial struct Language : IEquatable 30 | { 31 | /// 32 | public bool Equals(Language other) => 33 | string.Equals(Code, other.Code, StringComparison.OrdinalIgnoreCase); 34 | 35 | /// 36 | public override bool Equals(object? obj) => obj is Language other && Equals(other); 37 | 38 | /// 39 | public override int GetHashCode() => Code.GetHashCode(StringComparison.OrdinalIgnoreCase); 40 | 41 | /// 42 | /// Equality check. 43 | /// 44 | public static bool operator ==(Language left, Language right) => left.Equals(right); 45 | 46 | /// 47 | /// Equality check. 48 | /// 49 | public static bool operator !=(Language left, Language right) => !(left == right); 50 | } 51 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/ChannelSlugSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FluentAssertions; 3 | using Xunit; 4 | using YoutubeExplode.Channels; 5 | 6 | namespace YoutubeExplode.Tests; 7 | 8 | public class ChannelSlugSpecs 9 | { 10 | [Theory] 11 | [InlineData("Tyrrrz")] 12 | [InlineData("BlenderFoundation")] 13 | public void I_can_parse_a_channel_slug_from_a_slug_string(string channelSlug) 14 | { 15 | // Act 16 | var parsed = ChannelSlug.Parse(channelSlug); 17 | 18 | // Assert 19 | parsed.Value.Should().Be(channelSlug); 20 | } 21 | 22 | [Theory] 23 | [InlineData("youtube.com/c/Tyrrrz", "Tyrrrz")] 24 | [InlineData("youtube.com/c/BlenderFoundation", "BlenderFoundation")] 25 | [InlineData( 26 | "youtube.com/c/%D0%9C%D0%B5%D0%BB%D0%B0%D0%BD%D1%96%D1%8F%D0%9F%D0%BE%D0%B4%D0%BE%D0%BB%D1%8F%D0%BA", 27 | "МеланіяПодоляк" 28 | )] 29 | public void I_can_parse_a_channel_slug_from_a_URL_string( 30 | string channelUrl, 31 | string expectedChannelSlug 32 | ) 33 | { 34 | // Act 35 | var parsed = ChannelSlug.Parse(channelUrl); 36 | 37 | // Assert 38 | parsed.Value.Should().Be(expectedChannelSlug); 39 | } 40 | 41 | [Theory] 42 | [InlineData("")] 43 | [InlineData("foo bar")] 44 | [InlineData("youtube.com/?c=Tyrrrz")] 45 | [InlineData("youtube.com/channel/Tyrrrz")] 46 | [InlineData("youtube.com/")] 47 | public void I_can_try_to_parse_a_channel_slug_and_get_an_error_if_the_input_string_is_invalid( 48 | string channelSlugOrUrl 49 | ) 50 | { 51 | // Act & assert 52 | Assert.Throws(() => ChannelSlug.Parse(channelSlugOrUrl)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Extensions/AsyncCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Runtime.CompilerServices; 4 | using System.Threading.Tasks; 5 | 6 | namespace YoutubeExplode.Utils.Extensions; 7 | 8 | internal static class AsyncCollectionExtensions 9 | { 10 | extension(IAsyncEnumerable source) 11 | { 12 | public async IAsyncEnumerable TakeAsync(int count) 13 | { 14 | var currentCount = 0; 15 | 16 | await foreach (var i in source) 17 | { 18 | if (currentCount >= count) 19 | yield break; 20 | 21 | yield return i; 22 | currentCount++; 23 | } 24 | } 25 | 26 | public async IAsyncEnumerable SelectManyAsync(Func> transform) 27 | { 28 | await foreach (var i in source) 29 | { 30 | foreach (var j in transform(i)) 31 | yield return j; 32 | } 33 | } 34 | 35 | public async ValueTask> ToListAsync() 36 | { 37 | var list = new List(); 38 | 39 | await foreach (var i in source) 40 | list.Add(i); 41 | 42 | return list; 43 | } 44 | 45 | public ValueTaskAwaiter> GetAwaiter() => source.ToListAsync().GetAwaiter(); 46 | } 47 | 48 | extension(IAsyncEnumerable source) 49 | { 50 | public async IAsyncEnumerable OfTypeAsync() 51 | { 52 | await foreach (var i in source) 53 | { 54 | if (i is T match) 55 | yield return match; 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/EnvironmentSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using FluentAssertions; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | using YoutubeExplode.Converter.Tests.Utils; 7 | 8 | namespace YoutubeExplode.Converter.Tests; 9 | 10 | public class EnvironmentSpecs(ITestOutputHelper testOutput) : IAsyncLifetime 11 | { 12 | public async Task InitializeAsync() => await FFmpeg.InitializeAsync(); 13 | 14 | public Task DisposeAsync() => Task.CompletedTask; 15 | 16 | [Fact] 17 | public async Task I_can_download_a_video_with_custom_environment_variables_passed_to_FFmpeg() 18 | { 19 | // Arrange 20 | using var youtube = new YoutubeClient(); 21 | 22 | using var dir = TempDir.Create(); 23 | var filePath = Path.Combine(dir.Path, "video.mp4"); 24 | 25 | var logFilePath = Path.Combine(dir.Path, "ffreport.log"); 26 | 27 | // Act 28 | await youtube.Videos.DownloadAsync( 29 | "9bZkp7q19f0", 30 | filePath, 31 | o => 32 | { 33 | // FFREPORT file path must be relative to the current working directory 34 | var logFilePathFormatted = Path.GetRelativePath( 35 | Directory.GetCurrentDirectory(), 36 | logFilePath 37 | ) 38 | .Replace('\\', '/'); 39 | 40 | o.SetFFmpegPath(FFmpeg.FilePath) 41 | .SetEnvironmentVariable("FFREPORT", $"file={logFilePathFormatted}:level=32"); 42 | } 43 | ); 44 | 45 | // Assert 46 | File.Exists(logFilePath).Should().BeTrue(); 47 | 48 | testOutput.WriteLine(await File.ReadAllTextAsync(logFilePath)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using YoutubeExplode.Common; 3 | using YoutubeExplode.Videos.ClosedCaptions; 4 | 5 | namespace YoutubeExplode.Videos.Streams; 6 | 7 | /// 8 | /// Metadata associated with a muxed (audio + video combined) media stream. 9 | /// 10 | public class MuxedStreamInfo( 11 | string url, 12 | Container container, 13 | FileSize size, 14 | Bitrate bitrate, 15 | string audioCodec, 16 | Language? audioLanguage, 17 | bool? isAudioLanguageDefault, 18 | string videoCodec, 19 | VideoQuality videoQuality, 20 | Resolution videoResolution 21 | ) : IAudioStreamInfo, IVideoStreamInfo 22 | { 23 | /// 24 | public string Url { get; } = url; 25 | 26 | /// 27 | public Container Container { get; } = container; 28 | 29 | /// 30 | public FileSize Size { get; } = size; 31 | 32 | /// 33 | public Bitrate Bitrate { get; } = bitrate; 34 | 35 | /// 36 | public string AudioCodec { get; } = audioCodec; 37 | 38 | /// 39 | public Language? AudioLanguage { get; } = audioLanguage; 40 | 41 | /// 42 | public bool? IsAudioLanguageDefault { get; } = isAudioLanguageDefault; 43 | 44 | /// 45 | public string VideoCodec { get; } = videoCodec; 46 | 47 | /// 48 | public VideoQuality VideoQuality { get; } = videoQuality; 49 | 50 | /// 51 | public Resolution VideoResolution { get; } = videoResolution; 52 | 53 | /// 54 | [ExcludeFromCodeCoverage] 55 | public override string ToString() => $"Muxed ({VideoQuality} | {Container})"; 56 | } 57 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/ChannelIdSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FluentAssertions; 3 | using Xunit; 4 | using YoutubeExplode.Channels; 5 | 6 | namespace YoutubeExplode.Tests; 7 | 8 | public class ChannelIdSpecs 9 | { 10 | [Theory] 11 | [InlineData("UCEnBXANsKmyj2r9xVyKoDiQ")] 12 | [InlineData("UC46807r_RiRjH8IU-h_DrDQ")] 13 | public void I_can_parse_a_channel_ID_from_an_ID_string(string channelId) 14 | { 15 | // Act 16 | var parsed = ChannelId.Parse(channelId); 17 | 18 | // Assert 19 | parsed.Value.Should().Be(channelId); 20 | } 21 | 22 | [Theory] 23 | [InlineData("youtube.com/channel/UC3xnGqlcL3y-GXz5N3wiTJQ", "UC3xnGqlcL3y-GXz5N3wiTJQ")] 24 | [InlineData("youtube.com/channel/UCkQO3QsgTpNTsOw6ujimT5Q", "UCkQO3QsgTpNTsOw6ujimT5Q")] 25 | [InlineData("youtube.com/channel/UCQtjJDOYluum87LA4sI6xcg", "UCQtjJDOYluum87LA4sI6xcg")] 26 | public void I_can_parse_a_channel_ID_from_a_URL_string( 27 | string channelUrl, 28 | string expectedChannelId 29 | ) 30 | { 31 | // Act 32 | var parsed = ChannelId.Parse(channelUrl); 33 | 34 | // Assert 35 | parsed.Value.Should().Be(expectedChannelId); 36 | } 37 | 38 | [Theory] 39 | [InlineData("")] 40 | [InlineData("UC3xnGqlcL3y-GXz5N3wiTJ")] 41 | [InlineData("UC3xnGqlcL y-GXz5N3wiTJQ")] 42 | [InlineData("youtube.com/?channel=UCUC3xnGqlcL3y-GXz5N3wiTJQ")] 43 | [InlineData("youtube.com/channel/asd")] 44 | [InlineData("youtube.com/")] 45 | public void I_can_try_to_parse_a_channel_ID_and_get_an_error_if_the_input_string_is_invalid( 46 | string channelIdOrUrl 47 | ) 48 | { 49 | // Act & assert 50 | Assert.Throws(() => ChannelId.Parse(channelIdOrUrl)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 0.0.0-dev 5 | Tyrrrz 6 | Copyright (C) Oleksii Holub 7 | preview 8 | enable 9 | true 10 | false 11 | true 12 | false 13 | 14 | 15 | 16 | 17 | annotations 18 | 19 | 20 | 21 | 22 | false 23 | false 24 | 25 | 26 | 27 | $(Company) 28 | Abstraction layer over YouTube's internal API. Note: this package has limited availability in Russia and Belarus. 29 | youtube video download playlist user channel closed caption tracks subtitles parse extract metadata info net core standard 30 | https://github.com/Tyrrrz/YoutubeExplode 31 | https://github.com/Tyrrrz/YoutubeExplode/releases 32 | MIT 33 | 34 | 35 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/ClosedCaptionTrackResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Xml.Linq; 5 | using Lazy; 6 | using YoutubeExplode.Utils; 7 | using YoutubeExplode.Utils.Extensions; 8 | 9 | namespace YoutubeExplode.Bridge; 10 | 11 | internal partial class ClosedCaptionTrackResponse(XElement content) 12 | { 13 | [Lazy] 14 | public IReadOnlyList Captions => 15 | content.Descendants("p").Select(x => new CaptionData(x)).ToArray(); 16 | } 17 | 18 | internal partial class ClosedCaptionTrackResponse 19 | { 20 | public class CaptionData(XElement content) 21 | { 22 | [Lazy] 23 | public string? Text => (string?)content; 24 | 25 | [Lazy] 26 | public TimeSpan? Offset => 27 | ((double?)content.Attribute("t"))?.Pipe(TimeSpan.FromMilliseconds); 28 | 29 | [Lazy] 30 | public TimeSpan? Duration => 31 | ((double?)content.Attribute("d"))?.Pipe(TimeSpan.FromMilliseconds); 32 | 33 | [Lazy] 34 | public IReadOnlyList Parts => 35 | content.Elements("s").Select(x => new PartData(x)).ToArray(); 36 | } 37 | } 38 | 39 | internal partial class ClosedCaptionTrackResponse 40 | { 41 | public class PartData(XElement content) 42 | { 43 | [Lazy] 44 | public string? Text => (string?)content; 45 | 46 | [Lazy] 47 | public TimeSpan? Offset => 48 | ((double?)content.Attribute("t"))?.Pipe(TimeSpan.FromMilliseconds) 49 | ?? ((double?)content.Attribute("ac"))?.Pipe(TimeSpan.FromMilliseconds) 50 | ?? TimeSpan.Zero; 51 | } 52 | } 53 | 54 | internal partial class ClosedCaptionTrackResponse 55 | { 56 | public static ClosedCaptionTrackResponse Parse(string raw) => new(Xml.Parse(raw)); 57 | } 58 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/VideoIdSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FluentAssertions; 3 | using Xunit; 4 | using YoutubeExplode.Videos; 5 | 6 | namespace YoutubeExplode.Tests; 7 | 8 | public class VideoIdSpecs 9 | { 10 | [Theory] 11 | [InlineData("9bZkp7q19f0")] 12 | [InlineData("_kmeFXjjGfk")] 13 | [InlineData("AI7ULzgf8RU")] 14 | public void I_can_parse_a_video_ID_from_an_ID_string(string videoId) 15 | { 16 | // Act 17 | var parsed = VideoId.Parse(videoId); 18 | 19 | // Assert 20 | parsed.Value.Should().Be(videoId); 21 | } 22 | 23 | [Theory] 24 | [InlineData("youtube.com/watch?v=yIVRs6YSbOM", "yIVRs6YSbOM")] 25 | [InlineData("youtu.be/watch?v=Fcds0_MrgNU", "Fcds0_MrgNU")] 26 | [InlineData("youtu.be/yIVRs6YSbOM", "yIVRs6YSbOM")] 27 | [InlineData("youtube.com/embed/yIVRs6YSbOM", "yIVRs6YSbOM")] 28 | [InlineData("youtube.com/shorts/sKL1vjP0tIo", "sKL1vjP0tIo")] 29 | [InlineData("youtube.com/live/jfKfPfyJRdk", "jfKfPfyJRdk")] 30 | public void I_can_parse_a_video_ID_from_a_URL_string(string videoUrl, string expectedVideoId) 31 | { 32 | // Act 33 | var parsed = VideoId.Parse(videoUrl); 34 | 35 | // Assert 36 | parsed.Value.Should().Be(expectedVideoId); 37 | } 38 | 39 | [Theory] 40 | [InlineData("")] 41 | [InlineData("pI2I2zqzeK")] 42 | [InlineData("pI2I2z zeKg")] 43 | [InlineData("youtube.com/xxx?v=pI2I2zqzeKg")] 44 | [InlineData("youtu.be/watch?v=xxx")] 45 | [InlineData("youtube.com/embed/")] 46 | [InlineData("youtube.com/live/")] 47 | public void I_can_try_to_parse_a_video_ID_and_get_an_error_if_the_input_string_is_invalid( 48 | string videoId 49 | ) 50 | { 51 | // Act & assert 52 | Assert.Throws(() => VideoId.Parse(videoId)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Video.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using YoutubeExplode.Common; 5 | 6 | namespace YoutubeExplode.Videos; 7 | 8 | /// 9 | /// Metadata associated with a YouTube video. 10 | /// 11 | public class Video( 12 | VideoId id, 13 | string title, 14 | Author author, 15 | DateTimeOffset uploadDate, 16 | string description, 17 | TimeSpan? duration, 18 | IReadOnlyList thumbnails, 19 | IReadOnlyList keywords, 20 | Engagement engagement 21 | ) : IVideo 22 | { 23 | /// 24 | public VideoId Id { get; } = id; 25 | 26 | /// 27 | public string Url => $"https://www.youtube.com/watch?v={Id}"; 28 | 29 | /// 30 | public string Title { get; } = title; 31 | 32 | /// 33 | public Author Author { get; } = author; 34 | 35 | /// 36 | /// Video upload date. 37 | /// 38 | public DateTimeOffset UploadDate { get; } = uploadDate; 39 | 40 | /// 41 | /// Video description. 42 | /// 43 | public string Description { get; } = description; 44 | 45 | /// 46 | public TimeSpan? Duration { get; } = duration; 47 | 48 | /// 49 | public IReadOnlyList Thumbnails { get; } = thumbnails; 50 | 51 | /// 52 | /// Available search keywords for the video. 53 | /// 54 | public IReadOnlyList Keywords { get; } = keywords; 55 | 56 | /// 57 | /// Engagement statistics for the video. 58 | /// 59 | public Engagement Engagement { get; } = engagement; 60 | 61 | /// 62 | [ExcludeFromCodeCoverage] 63 | public override string ToString() => $"Video ({Title})"; 64 | } 65 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Gui/App.axaml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | 20 | 26 | 27 | 28 | 29 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/StreamManifest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace YoutubeExplode.Videos.Streams; 5 | 6 | /// 7 | /// Describes media streams available for a YouTube video. 8 | /// 9 | public class StreamManifest(IReadOnlyList streams) 10 | { 11 | /// 12 | /// Available streams. 13 | /// 14 | public IReadOnlyList Streams { get; } = streams; 15 | 16 | /// 17 | /// Gets streams that contain audio (i.e. muxed and audio-only streams). 18 | /// 19 | public IEnumerable GetAudioStreams() => Streams.OfType(); 20 | 21 | /// 22 | /// Gets streams that contain video (i.e. muxed and video-only streams). 23 | /// 24 | public IEnumerable GetVideoStreams() => Streams.OfType(); 25 | 26 | /// 27 | /// Gets muxed streams (i.e. streams containing both audio and video). 28 | /// 29 | /// 30 | /// These streams are generally deprecated by YouTube and may not be available 31 | /// for every video. If needed, use the YoutubeExplode.Converter package to 32 | /// manually mux audio-only and video-only streams into a single container. 33 | /// 34 | public IEnumerable GetMuxedStreams() => Streams.OfType(); 35 | 36 | /// 37 | /// Gets audio-only streams. 38 | /// 39 | public IEnumerable GetAudioOnlyStreams() => 40 | GetAudioStreams().OfType(); 41 | 42 | /// 43 | /// Gets video-only streams. 44 | /// 45 | public IEnumerable GetVideoOnlyStreams() => 46 | GetVideoStreams().OfType(); 47 | } 48 | -------------------------------------------------------------------------------- /YoutubeExplode/Search/SearchController.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using YoutubeExplode.Bridge; 5 | using YoutubeExplode.Utils; 6 | 7 | namespace YoutubeExplode.Search; 8 | 9 | internal class SearchController(HttpClient http) 10 | { 11 | public async ValueTask GetSearchResponseAsync( 12 | string searchQuery, 13 | SearchFilter searchFilter, 14 | string? continuationToken, 15 | CancellationToken cancellationToken = default 16 | ) 17 | { 18 | using var request = new HttpRequestMessage( 19 | HttpMethod.Post, 20 | "https://www.youtube.com/youtubei/v1/search" 21 | ); 22 | 23 | request.Content = new StringContent( 24 | // lang=json 25 | $$""" 26 | { 27 | "query": {{Json.Serialize(searchQuery)}}, 28 | "params": {{Json.Serialize(searchFilter switch 29 | { 30 | SearchFilter.Video => "EgIQAQ%3D%3D", 31 | SearchFilter.Playlist => "EgIQAw%3D%3D", 32 | SearchFilter.Channel => "EgIQAg%3D%3D", 33 | _ => null 34 | })}}, 35 | "continuation": {{Json.Serialize(continuationToken)}}, 36 | "context": { 37 | "client": { 38 | "clientName": "WEB", 39 | "clientVersion": "2.20210408.08.00", 40 | "hl": "en", 41 | "gl": "US", 42 | "utcOffsetMinutes": 0 43 | } 44 | } 45 | } 46 | """ 47 | ); 48 | 49 | using var response = await http.SendAsync(request, cancellationToken); 50 | response.EnsureSuccessStatusCode(); 51 | 52 | return SearchResponse.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Text; 4 | 5 | namespace YoutubeExplode.Utils.Extensions; 6 | 7 | internal static class StringExtensions 8 | { 9 | extension(string str) 10 | { 11 | public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null; 12 | 13 | public string SubstringUntil( 14 | string sub, 15 | StringComparison comparison = StringComparison.Ordinal 16 | ) 17 | { 18 | var index = str.IndexOf(sub, comparison); 19 | return index < 0 ? str : str[..index]; 20 | } 21 | 22 | public string SubstringAfter( 23 | string sub, 24 | StringComparison comparison = StringComparison.Ordinal 25 | ) 26 | { 27 | var index = str.IndexOf(sub, comparison); 28 | 29 | return index < 0 30 | ? string.Empty 31 | : str.Substring(index + sub.Length, str.Length - index - sub.Length); 32 | } 33 | 34 | public string StripNonDigit() 35 | { 36 | var buffer = new StringBuilder(); 37 | 38 | foreach (var c in str.Where(char.IsDigit)) 39 | buffer.Append(c); 40 | 41 | return buffer.ToString(); 42 | } 43 | 44 | public string Reverse() 45 | { 46 | var buffer = new StringBuilder(str.Length); 47 | 48 | for (var i = str.Length - 1; i >= 0; i--) 49 | buffer.Append(str[i]); 50 | 51 | return buffer.ToString(); 52 | } 53 | 54 | public string SwapChars(int firstCharIndex, int secondCharIndex) => 55 | new StringBuilder(str) 56 | { 57 | [firstCharIndex] = str[secondCharIndex], 58 | [secondCharIndex] = str[firstCharIndex], 59 | }.ToString(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /YoutubeExplode/Common/IBatchItem.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Runtime.CompilerServices; 3 | using System.Threading.Tasks; 4 | using YoutubeExplode.Utils.Extensions; 5 | 6 | namespace YoutubeExplode.Common; 7 | 8 | /// 9 | /// Represents an item that can be included in . 10 | /// This interface is used as a marker to enable extension methods. 11 | /// 12 | public interface IBatchItem { } 13 | 14 | /// 15 | /// Extensions for . 16 | /// 17 | public static class BatchItemExtensions 18 | { 19 | // We want to enable some convenience methods on instances of IAsyncEnumerable 20 | // exposed by the library. 21 | // However, we don't want these extensions to apply to other IAsyncEnumerable 22 | // as that could cause unwanted noise for the user. 23 | // To that end, we use a marker interface and a generic constraint to limit the 24 | // set of types that these extension methods can be used on. 25 | 26 | /// 27 | extension(IAsyncEnumerable source) 28 | where T : IBatchItem 29 | { 30 | /// 31 | /// Enumerates all items in the sequence and buffers them in memory. 32 | /// 33 | public async ValueTask> CollectAsync() => await source.ToListAsync(); 34 | 35 | /// 36 | /// Enumerates a subset of items in the sequence and buffers them in memory. 37 | /// 38 | public async ValueTask> CollectAsync(int count) => 39 | await source.TakeAsync(count).ToListAsync(); 40 | 41 | /// 42 | public ValueTaskAwaiter> GetAwaiter() => 43 | source.CollectAsync().GetAwaiter(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /YoutubeExplode/Playlists/PlaylistVideo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using YoutubeExplode.Common; 5 | using YoutubeExplode.Videos; 6 | 7 | namespace YoutubeExplode.Playlists; 8 | 9 | /// 10 | /// Metadata associated with a YouTube video included in a playlist. 11 | /// 12 | public class PlaylistVideo( 13 | PlaylistId playlistId, 14 | VideoId id, 15 | string title, 16 | Author author, 17 | TimeSpan? duration, 18 | IReadOnlyList thumbnails 19 | ) : IVideo, IBatchItem 20 | { 21 | /// 22 | /// Initializes an instance of . 23 | /// 24 | // Binary backwards compatibility (PlaylistId was added) 25 | [Obsolete("Use the other constructor instead."), ExcludeFromCodeCoverage] 26 | public PlaylistVideo( 27 | VideoId id, 28 | string title, 29 | Author author, 30 | TimeSpan? duration, 31 | IReadOnlyList thumbnails 32 | ) 33 | : this(default, id, title, author, duration, thumbnails) { } 34 | 35 | /// 36 | /// ID of the playlist that contains this video. 37 | /// 38 | public PlaylistId PlaylistId { get; } = playlistId; 39 | 40 | /// 41 | public VideoId Id { get; } = id; 42 | 43 | /// 44 | public string Url => $"https://www.youtube.com/watch?v={Id}&list={PlaylistId}"; 45 | 46 | /// 47 | public string Title { get; } = title; 48 | 49 | /// 50 | public Author Author { get; } = author; 51 | 52 | /// 53 | public TimeSpan? Duration { get; } = duration; 54 | 55 | /// 56 | public IReadOnlyList Thumbnails { get; } = thumbnails; 57 | 58 | /// 59 | [ExcludeFromCodeCoverage] 60 | public override string ToString() => $"Video ({Title})"; 61 | } 62 | -------------------------------------------------------------------------------- /YoutubeExplode.Demo.Cli/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using YoutubeExplode.Demo.Cli.Utils; 4 | using YoutubeExplode.Videos; 5 | using YoutubeExplode.Videos.Streams; 6 | 7 | namespace YoutubeExplode.Demo.Cli; 8 | 9 | // This demo prompts for video ID and downloads one media stream. 10 | // It's intended to be very simple and straight to the point. 11 | // For a more involved example - check out the WPF demo. 12 | public static class Program 13 | { 14 | public static async Task Main() 15 | { 16 | Console.Title = "YoutubeExplode Demo"; 17 | 18 | using var youtube = new YoutubeClient(); 19 | 20 | // Get the video ID 21 | Console.Write("Enter YouTube video ID or URL: "); 22 | var videoId = VideoId.Parse(Console.ReadLine() ?? ""); 23 | 24 | // Get available streams and choose the best muxed (audio + video) stream 25 | var streamManifest = await youtube.Videos.Streams.GetManifestAsync(videoId); 26 | var streamInfo = streamManifest.GetMuxedStreams().TryGetWithHighestVideoQuality(); 27 | if (streamInfo is null) 28 | { 29 | // Available streams vary depending on the video and it's possible 30 | // there may not be any muxed streams at all. 31 | // See the readme to learn how to handle adaptive streams. 32 | Console.Error.WriteLine("This video has no muxed streams."); 33 | return; 34 | } 35 | 36 | // Download the stream 37 | var fileName = $"{videoId}.{streamInfo.Container.Name}"; 38 | 39 | Console.Write( 40 | $"Downloading stream: {streamInfo.VideoQuality.Label} / {streamInfo.Container.Name}... " 41 | ); 42 | 43 | using (var progress = new ConsoleProgress()) 44 | await youtube.Videos.Streams.DownloadAsync(streamInfo, fileName, progress); 45 | 46 | Console.WriteLine("Done"); 47 | Console.WriteLine($"Video saved to '{fileName}'"); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/ClosedCaptions/ClosedCaption.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | 6 | namespace YoutubeExplode.Videos.ClosedCaptions; 7 | 8 | /// 9 | /// Individual closed caption contained within a track. 10 | /// 11 | public class ClosedCaption( 12 | string text, 13 | TimeSpan offset, 14 | TimeSpan duration, 15 | IReadOnlyList parts 16 | ) 17 | { 18 | /// 19 | /// Text displayed by the caption. 20 | /// 21 | public string Text { get; } = text; 22 | 23 | /// 24 | /// Time at which the caption starts displaying. 25 | /// 26 | public TimeSpan Offset { get; } = offset; 27 | 28 | /// 29 | /// Duration of time for which the caption is displayed. 30 | /// 31 | public TimeSpan Duration { get; } = duration; 32 | 33 | /// 34 | /// Caption parts, usually representing individual words. 35 | /// 36 | /// 37 | /// May be empty because not all captions have parts. 38 | /// 39 | public IReadOnlyList Parts { get; } = parts; 40 | 41 | /// 42 | /// Gets the caption part displayed at the specified point in time, relative to the caption's own offset. 43 | /// Returns null if not found. 44 | /// 45 | public ClosedCaptionPart? TryGetPartByTime(TimeSpan time) => 46 | Parts.FirstOrDefault(p => p.Offset >= time); 47 | 48 | /// 49 | /// Gets the caption part displayed at the specified point in time, relative to the caption's own offset. 50 | /// 51 | public ClosedCaptionPart GetPartByTime(TimeSpan time) => 52 | TryGetPartByTime(time) 53 | ?? throw new InvalidOperationException($"No closed caption part found at {time}."); 54 | 55 | /// 56 | [ExcludeFromCodeCoverage] 57 | public override string ToString() => Text; 58 | } 59 | -------------------------------------------------------------------------------- /YoutubeExplode/Common/Thumbnail.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | using YoutubeExplode.Videos; 6 | 7 | namespace YoutubeExplode.Common; 8 | 9 | /// 10 | /// Thumbnail image. 11 | /// 12 | public partial class Thumbnail(string url, Resolution resolution) 13 | { 14 | /// 15 | /// Thumbnail URL. 16 | /// 17 | public string Url { get; } = url; 18 | 19 | /// 20 | /// Thumbnail resolution. 21 | /// 22 | public Resolution Resolution { get; } = resolution; 23 | 24 | /// 25 | [ExcludeFromCodeCoverage] 26 | public override string ToString() => $"Thumbnail ({Resolution})"; 27 | } 28 | 29 | public partial class Thumbnail 30 | { 31 | internal static IReadOnlyList GetDefaultSet(VideoId videoId) => 32 | [ 33 | new($"https://img.youtube.com/vi/{videoId}/default.jpg", new Resolution(120, 90)), 34 | new($"https://img.youtube.com/vi/{videoId}/mqdefault.jpg", new Resolution(320, 180)), 35 | new($"https://img.youtube.com/vi/{videoId}/hqdefault.jpg", new Resolution(480, 360)), 36 | ]; 37 | } 38 | 39 | /// 40 | /// Extensions for . 41 | /// 42 | public static class ThumbnailExtensions 43 | { 44 | /// 45 | extension(IEnumerable thumbnails) 46 | { 47 | /// 48 | /// Gets the thumbnail with the highest resolution (by area). 49 | /// Returns null if the sequence is empty. 50 | /// 51 | public Thumbnail? TryGetWithHighestResolution() => thumbnails.MaxBy(t => t.Resolution.Area); 52 | 53 | /// 54 | /// Gets the thumbnail with the highest resolution (by area). 55 | /// 56 | public Thumbnail GetWithHighestResolution() => 57 | thumbnails.TryGetWithHighestResolution() 58 | ?? throw new InvalidOperationException("Input thumbnail collection is empty."); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/ConversionRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using YoutubeExplode.Videos.Streams; 5 | 6 | namespace YoutubeExplode.Converter; 7 | 8 | /// 9 | /// Conversion options. 10 | /// 11 | public class ConversionRequest( 12 | string ffmpegCliFilePath, 13 | string outputFilePath, 14 | Container container, 15 | ConversionPreset preset, 16 | IReadOnlyDictionary environmentVariables 17 | ) 18 | { 19 | /// 20 | /// Initializes an instance of . 21 | /// 22 | [Obsolete("Use the other constructor overload"), ExcludeFromCodeCoverage] 23 | public ConversionRequest( 24 | string ffmpegCliFilePath, 25 | string outputFilePath, 26 | ConversionFormat format, 27 | ConversionPreset preset 28 | ) 29 | : this( 30 | ffmpegCliFilePath, 31 | outputFilePath, 32 | new Container(format.Name), 33 | preset, 34 | new Dictionary() 35 | ) { } 36 | 37 | /// 38 | /// Path to the FFmpeg CLI. 39 | /// 40 | public string FFmpegCliFilePath { get; } = ffmpegCliFilePath; 41 | 42 | /// 43 | /// Output file path. 44 | /// 45 | public string OutputFilePath { get; } = outputFilePath; 46 | 47 | /// 48 | /// Output container. 49 | /// 50 | public Container Container { get; } = container; 51 | 52 | /// 53 | /// Output format. 54 | /// 55 | [Obsolete("Use the Container property instead."), ExcludeFromCodeCoverage] 56 | public ConversionFormat Format => new(Container.Name); 57 | 58 | /// 59 | /// Encoder preset. 60 | /// 61 | public ConversionPreset Preset { get; } = preset; 62 | 63 | /// 64 | /// Environment variables to set for the FFmpeg process. 65 | /// 66 | public IReadOnlyDictionary EnvironmentVariables { get; } = 67 | environmentVariables; 68 | } 69 | -------------------------------------------------------------------------------- /YoutubeExplode/Channels/ChannelController.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using YoutubeExplode.Bridge; 5 | using YoutubeExplode.Exceptions; 6 | 7 | namespace YoutubeExplode.Channels; 8 | 9 | internal class ChannelController(HttpClient http) 10 | { 11 | private async ValueTask GetChannelPageAsync( 12 | string channelRoute, 13 | CancellationToken cancellationToken = default 14 | ) 15 | { 16 | for (var retriesRemaining = 5; ; retriesRemaining--) 17 | { 18 | var channelPage = ChannelPage.TryParse( 19 | await http.GetStringAsync( 20 | "https://www.youtube.com/" + channelRoute, 21 | cancellationToken 22 | ) 23 | ); 24 | 25 | if (channelPage is null) 26 | { 27 | if (retriesRemaining > 0) 28 | continue; 29 | 30 | throw new YoutubeExplodeException( 31 | "Channel page is broken. Please try again in a few minutes." 32 | ); 33 | } 34 | 35 | return channelPage; 36 | } 37 | } 38 | 39 | public async ValueTask GetChannelPageAsync( 40 | ChannelId channelId, 41 | CancellationToken cancellationToken = default 42 | ) => await GetChannelPageAsync("channel/" + channelId, cancellationToken); 43 | 44 | public async ValueTask GetChannelPageAsync( 45 | UserName userName, 46 | CancellationToken cancellationToken = default 47 | ) => await GetChannelPageAsync("user/" + userName, cancellationToken); 48 | 49 | public async ValueTask GetChannelPageAsync( 50 | ChannelSlug channelSlug, 51 | CancellationToken cancellationToken = default 52 | ) => await GetChannelPageAsync("c/" + channelSlug, cancellationToken); 53 | 54 | public async ValueTask GetChannelPageAsync( 55 | ChannelHandle channelHandle, 56 | CancellationToken cancellationToken = default 57 | ) => await GetChannelPageAsync("@" + channelHandle, cancellationToken); 58 | } 59 | -------------------------------------------------------------------------------- /YoutubeExplode/YoutubeExplode.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netstandard2.0;net6.0;net7.0;net10.0 4 | true 5 | true 9 | true 13 | 14 | 15 | 16 | favicon.png 17 | true 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 34 | 35 | 36 | 41 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace YoutubeExplode.Utils.Extensions; 8 | 9 | internal static class HttpExtensions 10 | { 11 | private class NonDisposableHttpContent(HttpContent content) : HttpContent 12 | { 13 | protected override async Task SerializeToStreamAsync( 14 | Stream stream, 15 | TransportContext? context 16 | ) => await content.CopyToAsync(stream); 17 | 18 | protected override bool TryComputeLength(out long length) 19 | { 20 | length = 0; 21 | return false; 22 | } 23 | } 24 | 25 | extension(HttpRequestMessage request) 26 | { 27 | public HttpRequestMessage Clone() 28 | { 29 | var clonedRequest = new HttpRequestMessage(request.Method, request.RequestUri) 30 | { 31 | Version = request.Version, 32 | // Don't dispose the original request's content 33 | Content = request.Content is not null 34 | ? new NonDisposableHttpContent(request.Content) 35 | : null, 36 | }; 37 | 38 | foreach (var (key, value) in request.Headers) 39 | clonedRequest.Headers.TryAddWithoutValidation(key, value); 40 | 41 | if (request.Content is not null && clonedRequest.Content is not null) 42 | { 43 | foreach (var (key, value) in request.Content.Headers) 44 | clonedRequest.Content.Headers.TryAddWithoutValidation(key, value); 45 | } 46 | 47 | return clonedRequest; 48 | } 49 | } 50 | 51 | extension(HttpClient http) 52 | { 53 | public async ValueTask HeadAsync( 54 | string requestUri, 55 | CancellationToken cancellationToken = default 56 | ) 57 | { 58 | using var request = new HttpRequestMessage(HttpMethod.Head, requestUri); 59 | 60 | return await http.SendAsync( 61 | request, 62 | HttpCompletionOption.ResponseHeadersRead, 63 | cancellationToken 64 | ); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /YoutubeExplode/YoutubeClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Net.Http; 5 | using YoutubeExplode.Channels; 6 | using YoutubeExplode.Playlists; 7 | using YoutubeExplode.Search; 8 | using YoutubeExplode.Utils; 9 | using YoutubeExplode.Videos; 10 | 11 | namespace YoutubeExplode; 12 | 13 | /// 14 | /// Client for interacting with YouTube. 15 | /// 16 | public class YoutubeClient : IDisposable 17 | { 18 | private readonly HttpClient _youtubeHttp; 19 | 20 | /// 21 | /// Initializes an instance of . 22 | /// 23 | public YoutubeClient(HttpClient http, IReadOnlyList initialCookies) 24 | { 25 | _youtubeHttp = new HttpClient(new YoutubeHttpHandler(http, initialCookies), true); 26 | 27 | Videos = new VideoClient(_youtubeHttp); 28 | Playlists = new PlaylistClient(_youtubeHttp); 29 | Channels = new ChannelClient(_youtubeHttp); 30 | Search = new SearchClient(_youtubeHttp); 31 | } 32 | 33 | /// 34 | /// Initializes an instance of . 35 | /// 36 | public YoutubeClient(HttpClient http) 37 | : this(http, []) { } 38 | 39 | /// 40 | /// Initializes an instance of . 41 | /// 42 | public YoutubeClient(IReadOnlyList initialCookies) 43 | : this(Http.Client, initialCookies) { } 44 | 45 | /// 46 | /// Initializes an instance of . 47 | /// 48 | public YoutubeClient() 49 | : this(Http.Client) { } 50 | 51 | /// 52 | /// Operations related to YouTube videos. 53 | /// 54 | public VideoClient Videos { get; } 55 | 56 | /// 57 | /// Operations related to YouTube playlists. 58 | /// 59 | public PlaylistClient Playlists { get; } 60 | 61 | /// 62 | /// Operations related to YouTube channels. 63 | /// 64 | public ChannelClient Channels { get; } 65 | 66 | /// 67 | /// Operations related to YouTube search. 68 | /// 69 | public SearchClient Search { get; } 70 | 71 | /// 72 | public void Dispose() => _youtubeHttp.Dispose(); 73 | } 74 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/IStreamInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using YoutubeExplode.Utils; 5 | 6 | namespace YoutubeExplode.Videos.Streams; 7 | 8 | /// 9 | /// Metadata associated with a media stream of a YouTube video. 10 | /// 11 | public interface IStreamInfo 12 | { 13 | /// 14 | /// Stream URL. 15 | /// 16 | /// 17 | /// While this URL can be used to access the underlying stream, you need a series 18 | /// of carefully crafted HTTP requests in order to do so. 19 | /// It's highly recommended to use 20 | /// or 21 | /// instead, as they will perform all the heavy lifting for you. 22 | /// 23 | string Url { get; } 24 | 25 | /// 26 | /// Stream container. 27 | /// 28 | Container Container { get; } 29 | 30 | /// 31 | /// Stream size. 32 | /// 33 | FileSize Size { get; } 34 | 35 | /// 36 | /// Stream bitrate. 37 | /// 38 | Bitrate Bitrate { get; } 39 | } 40 | 41 | /// 42 | /// Extensions for . 43 | /// 44 | public static class StreamInfoExtensions 45 | { 46 | /// 47 | extension(IStreamInfo streamInfo) 48 | { 49 | internal bool IsThrottled() => 50 | !string.Equals( 51 | UrlEx.TryGetQueryParameterValue(streamInfo.Url, "ratebypass"), 52 | "yes", 53 | StringComparison.OrdinalIgnoreCase 54 | ); 55 | } 56 | 57 | /// 58 | extension(IEnumerable streamInfos) 59 | { 60 | /// 61 | /// Gets the stream with the highest bitrate. 62 | /// Returns null if the sequence is empty. 63 | /// 64 | public IStreamInfo? TryGetWithHighestBitrate() => streamInfos.MaxBy(s => s.Bitrate); 65 | 66 | /// 67 | /// Gets the stream with the highest bitrate. 68 | /// 69 | public IStreamInfo GetWithHighestBitrate() => 70 | streamInfos.TryGetWithHighestBitrate() 71 | ?? throw new InvalidOperationException("Input stream collection is empty."); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/Utils/MediaFormat.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace YoutubeExplode.Converter.Tests.Utils; 4 | 5 | internal static class MediaFormat 6 | { 7 | public static bool IsMp4File(string filePath) 8 | { 9 | using var stream = File.OpenRead(filePath); 10 | using var reader = new BinaryReader(stream); 11 | 12 | // Skip 4 bytes 13 | stream.Seek(4, SeekOrigin.Current); 14 | 15 | // Expect: 66 74 79 70 16 | 17 | if (reader.ReadByte() != 0x66) 18 | return false; 19 | 20 | if (reader.ReadByte() != 0x74) 21 | return false; 22 | 23 | if (reader.ReadByte() != 0x79) 24 | return false; 25 | 26 | if (reader.ReadByte() != 0x70) 27 | return false; 28 | 29 | return true; 30 | } 31 | 32 | public static bool IsWebMFile(string filePath) 33 | { 34 | using var stream = File.OpenRead(filePath); 35 | using var reader = new BinaryReader(stream); 36 | 37 | // Expect: 1A 45 DF A3 38 | 39 | if (reader.ReadByte() != 0x1A) 40 | return false; 41 | 42 | if (reader.ReadByte() != 0x45) 43 | return false; 44 | 45 | if (reader.ReadByte() != 0xDF) 46 | return false; 47 | 48 | if (reader.ReadByte() != 0xA3) 49 | return false; 50 | 51 | return true; 52 | } 53 | 54 | public static bool IsMp3File(string filePath) 55 | { 56 | using var stream = File.OpenRead(filePath); 57 | using var reader = new BinaryReader(stream); 58 | 59 | // Assume ID3 container is present 60 | // Expect: 49 44 33 61 | 62 | if (reader.ReadByte() != 0x49) 63 | return false; 64 | 65 | if (reader.ReadByte() != 0x44) 66 | return false; 67 | 68 | if (reader.ReadByte() != 0x33) 69 | return false; 70 | 71 | return true; 72 | } 73 | 74 | public static bool IsOggFile(string filePath) 75 | { 76 | using var stream = File.OpenRead(filePath); 77 | using var reader = new BinaryReader(stream); 78 | 79 | // Expect: 4F 67 67 53 80 | 81 | if (reader.ReadByte() != 0x4F) 82 | return false; 83 | 84 | if (reader.ReadByte() != 0x67) 85 | return false; 86 | 87 | if (reader.ReadByte() != 0x67) 88 | return false; 89 | 90 | if (reader.ReadByte() != 0x53) 91 | return false; 92 | 93 | return true; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /YoutubeExplode/Channels/ChannelSlug.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net; 4 | using System.Text.RegularExpressions; 5 | using YoutubeExplode.Utils.Extensions; 6 | 7 | namespace YoutubeExplode.Channels; 8 | 9 | /// 10 | /// Represents a syntactically valid YouTube channel slug. 11 | /// 12 | public readonly partial struct ChannelSlug(string value) 13 | { 14 | /// 15 | /// Raw slug value. 16 | /// 17 | public string Value { get; } = value; 18 | 19 | /// 20 | public override string ToString() => Value; 21 | } 22 | 23 | public readonly partial struct ChannelSlug 24 | { 25 | private static bool IsValid(string channelSlug) => channelSlug.All(char.IsLetterOrDigit); 26 | 27 | private static string? TryNormalize(string? channelSlugOrUrl) 28 | { 29 | if (string.IsNullOrWhiteSpace(channelSlugOrUrl)) 30 | return null; 31 | 32 | // Check if already passed a slug 33 | // Tyrrrz 34 | if (IsValid(channelSlugOrUrl)) 35 | return channelSlugOrUrl; 36 | 37 | // Try to extract the slug from the URL 38 | // https://www.youtube.com/c/Tyrrrz 39 | var slug = Regex 40 | .Match(channelSlugOrUrl, @"youtube\..+?/c/(.*?)(?:\?|&|/|$)") 41 | .Groups[1] 42 | .Value.Pipe(WebUtility.UrlDecode); 43 | 44 | if (!string.IsNullOrWhiteSpace(slug) && IsValid(slug)) 45 | return slug; 46 | 47 | // Invalid input 48 | return null; 49 | } 50 | 51 | /// 52 | /// Attempts to parse the specified string as a YouTube channel slug or legacy custom URL. 53 | /// Returns null in case of failure. 54 | /// 55 | public static ChannelSlug? TryParse(string? channelSlugOrUrl) => 56 | TryNormalize(channelSlugOrUrl)?.Pipe(slug => new ChannelSlug(slug)); 57 | 58 | /// 59 | /// Parses the specified string as a YouTube channel slug or legacy custom url. 60 | /// 61 | public static ChannelSlug Parse(string channelSlugOrUrl) => 62 | TryParse(channelSlugOrUrl) 63 | ?? throw new ArgumentException( 64 | $"Invalid YouTube channel slug or legacy custom URL '{channelSlugOrUrl}'." 65 | ); 66 | 67 | /// 68 | /// Converts string to channel slug. 69 | /// 70 | public static implicit operator ChannelSlug(string channelSlugOrUrl) => Parse(channelSlugOrUrl); 71 | 72 | /// 73 | /// Converts channel slug to string. 74 | /// 75 | public static implicit operator string(ChannelSlug channelSlug) => channelSlug.ToString(); 76 | } 77 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Json.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Linq; 3 | using System.Text; 4 | using System.Text.Json; 5 | 6 | namespace YoutubeExplode.Utils; 7 | 8 | internal static class Json 9 | { 10 | public static string Extract(string source) 11 | { 12 | var buffer = new StringBuilder(); 13 | 14 | var depth = 0; 15 | var isInsideString = false; 16 | 17 | // We trust that the source contains valid json, we just need to extract it. 18 | // To do it, we will be matching curly braces until we even out. 19 | foreach (var (i, ch) in source.Index()) 20 | { 21 | var prev = i > 0 ? source[i - 1] : default; 22 | 23 | buffer.Append(ch); 24 | 25 | // Detect if inside a string 26 | if (ch == '"' && prev != '\\') 27 | isInsideString = !isInsideString; 28 | // Opening brace 29 | else if (ch == '{' && !isInsideString) 30 | depth++; 31 | // Closing brace 32 | else if (ch == '}' && !isInsideString) 33 | depth--; 34 | 35 | // Break when evened out 36 | if (depth == 0) 37 | break; 38 | } 39 | 40 | return buffer.ToString(); 41 | } 42 | 43 | public static JsonElement Parse(string source) 44 | { 45 | using var document = JsonDocument.Parse(source); 46 | return document.RootElement.Clone(); 47 | } 48 | 49 | public static JsonElement? TryParse(string source) 50 | { 51 | try 52 | { 53 | return Parse(source); 54 | } 55 | catch (JsonException) 56 | { 57 | return null; 58 | } 59 | } 60 | 61 | public static string Encode(string value) 62 | { 63 | var buffer = new StringBuilder(value.Length); 64 | 65 | foreach (var c in value) 66 | { 67 | if (c == '\n') 68 | buffer.Append("\\n"); 69 | else if (c == '\r') 70 | buffer.Append("\\r"); 71 | else if (c == '\t') 72 | buffer.Append("\\t"); 73 | else if (c == '\\') 74 | buffer.Append("\\\\"); 75 | else if (c == '"') 76 | buffer.Append("\\\""); 77 | else 78 | buffer.Append(c); 79 | } 80 | 81 | return buffer.ToString(); 82 | } 83 | 84 | // AOT-compatible serialization 85 | public static string Serialize(string? value) => 86 | value is not null ? '"' + Encode(value) + '"' : "null"; 87 | 88 | // AOT-compatible serialization 89 | public static string Serialize(int? value) => 90 | value is not null ? value.Value.ToString(CultureInfo.InvariantCulture) : "null"; 91 | } 92 | -------------------------------------------------------------------------------- /YoutubeExplode/Channels/ChannelHandle.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net; 4 | using System.Text.RegularExpressions; 5 | using YoutubeExplode.Utils.Extensions; 6 | 7 | namespace YoutubeExplode.Channels; 8 | 9 | /// 10 | /// Represents a syntactically valid YouTube channel handle. 11 | /// 12 | public readonly partial struct ChannelHandle(string value) 13 | { 14 | /// 15 | /// Raw handle value. 16 | /// 17 | public string Value { get; } = value; 18 | 19 | /// 20 | public override string ToString() => Value; 21 | } 22 | 23 | public readonly partial struct ChannelHandle 24 | { 25 | private static bool IsValid(string channelHandle) => 26 | channelHandle.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.'); 27 | 28 | private static string? TryNormalize(string? channelHandleOrUrl) 29 | { 30 | if (string.IsNullOrWhiteSpace(channelHandleOrUrl)) 31 | return null; 32 | 33 | // Check if already passed a handle 34 | // Tyrrrz 35 | if (IsValid(channelHandleOrUrl)) 36 | return channelHandleOrUrl; 37 | 38 | // Try to extract the handle from the URL 39 | // https://www.youtube.com/@Tyrrrz 40 | var handle = Regex 41 | .Match(channelHandleOrUrl, @"youtube\..+?/@(.*?)(?:\?|&|/|$)") 42 | .Groups[1] 43 | .Value.Pipe(WebUtility.UrlDecode); 44 | 45 | if (!string.IsNullOrWhiteSpace(handle) && IsValid(handle)) 46 | return handle; 47 | 48 | // Invalid input 49 | return null; 50 | } 51 | 52 | /// 53 | /// Attempts to parse the specified string as a YouTube channel handle or custom URL. 54 | /// Returns null in case of failure. 55 | /// 56 | public static ChannelHandle? TryParse(string? channelHandleOrUrl) => 57 | TryNormalize(channelHandleOrUrl)?.Pipe(handle => new ChannelHandle(handle)); 58 | 59 | /// 60 | /// Parses the specified string as a YouTube channel handle or custom URL. 61 | /// 62 | public static ChannelHandle Parse(string channelHandleOrUrl) => 63 | TryParse(channelHandleOrUrl) 64 | ?? throw new ArgumentException( 65 | $"Invalid YouTube channel handle or custom URL '{channelHandleOrUrl}'." 66 | ); 67 | 68 | /// 69 | /// Converts string to channel handle. 70 | /// 71 | public static implicit operator ChannelHandle(string channelHandleOrUrl) => 72 | Parse(channelHandleOrUrl); 73 | 74 | /// 75 | /// Converts channel handle to string. 76 | /// 77 | public static implicit operator string(ChannelHandle channelHandle) => channelHandle.ToString(); 78 | } 79 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/PlaylistIdSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FluentAssertions; 3 | using Xunit; 4 | using YoutubeExplode.Playlists; 5 | 6 | namespace YoutubeExplode.Tests; 7 | 8 | public class PlaylistIdSpecs 9 | { 10 | [Theory] 11 | [InlineData("WL")] 12 | [InlineData("LL")] 13 | [InlineData("RDMM")] 14 | [InlineData("PL601B2E69B03FAB9D")] 15 | [InlineData("PLI5YfMzCfRtZ8eV576YoY3vIYrHjyVm_e")] 16 | [InlineData("PLWwAypAcFRgKFlxtLbn_u14zddtDJj3mk")] 17 | [InlineData("OLAK5uy_mtOdjCW76nDvf5yOzgcAVMYpJ5gcW5uKU")] 18 | [InlineData("RD1hu8-y6fKg0")] 19 | [InlineData("RDMMU-ty-2B02VY")] 20 | [InlineData("RDCLAK5uy_lf8okgl2ygD075nhnJVjlfhwp8NsUgEbs")] 21 | [InlineData("ULl6WWX-BgIiE")] 22 | [InlineData("UUTMt7iMWa7jy0fNXIktwyLA")] 23 | [InlineData("OLAK5uy_lLeonUugocG5J0EUAEDmbskX4emejKwcM")] 24 | [InlineData("FLEnBXANsKmyj2r9xVyKoDiQ")] 25 | public void I_can_parse_a_playlist_ID_from_an_ID_string(string playlistId) 26 | { 27 | // Act 28 | var parsed = PlaylistId.Parse(playlistId); 29 | 30 | // Assert 31 | parsed.Value.Should().Be(playlistId); 32 | } 33 | 34 | [Theory] 35 | [InlineData( 36 | "youtube.com/playlist?list=PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H", 37 | "PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H" 38 | )] 39 | [InlineData( 40 | "youtube.com/watch?v=b8m9zhNAgKs&list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr", 41 | "PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr" 42 | )] 43 | [InlineData( 44 | "youtu.be/b8m9zhNAgKs/?list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr", 45 | "PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr" 46 | )] 47 | [InlineData( 48 | "youtube.com/embed/b8m9zhNAgKs/?list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr", 49 | "PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr" 50 | )] 51 | [InlineData( 52 | "youtube.com/watch?v=x2ZRoWQ0grU&list=RDEMNJhLy4rECJ_fG8NL-joqsg", 53 | "RDEMNJhLy4rECJ_fG8NL-joqsg" 54 | )] 55 | public void I_can_parse_a_playlist_ID_from_a_URL_string( 56 | string playlistUrl, 57 | string expectedPlaylistId 58 | ) 59 | { 60 | // Act 61 | var parsed = PlaylistId.Parse(playlistUrl); 62 | 63 | // Assert 64 | parsed.Value.Should().Be(expectedPlaylistId); 65 | } 66 | 67 | [Theory] 68 | [InlineData("")] 69 | [InlineData("PLm_3vnTS-pvmZFuF L1Pyhqf8kTTYVKjW")] 70 | [InlineData("PLm_3vnTS-pvmZFuF3L=Pyhqf8kTTYVKjW")] 71 | [InlineData("youtube.com/playlist?lisp=PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H")] 72 | [InlineData("youtube.com/")] 73 | public void I_can_try_to_parse_a_playlist_ID_and_get_an_error_if_the_input_string_is_invalid( 74 | string playlistIdOrUrl 75 | ) 76 | { 77 | // Act & assert 78 | Assert.Throws(() => PlaylistId.Parse(playlistIdOrUrl)); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/FileSize.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace YoutubeExplode.Videos.Streams; 4 | 5 | /// 6 | /// File size. 7 | /// 8 | // Loosely based on https://github.com/omar/ByteSize (MIT license) 9 | public readonly partial struct FileSize(long bytes) 10 | { 11 | /// 12 | /// Size in bytes. 13 | /// 14 | public long Bytes { get; } = bytes; 15 | 16 | /// 17 | /// Size in kilobytes. 18 | /// 19 | public double KiloBytes => Bytes / 1024.0; 20 | 21 | /// 22 | /// Size in megabytes. 23 | /// 24 | public double MegaBytes => KiloBytes / 1024.0; 25 | 26 | /// 27 | /// Size in gigabytes. 28 | /// 29 | public double GigaBytes => MegaBytes / 1024.0; 30 | 31 | private string GetLargestWholeNumberSymbol() 32 | { 33 | if (Math.Abs(GigaBytes) >= 1) 34 | return "GB"; 35 | 36 | if (Math.Abs(MegaBytes) >= 1) 37 | return "MB"; 38 | 39 | if (Math.Abs(KiloBytes) >= 1) 40 | return "KB"; 41 | 42 | return "B"; 43 | } 44 | 45 | private double GetLargestWholeNumberValue() 46 | { 47 | if (Math.Abs(GigaBytes) >= 1) 48 | return GigaBytes; 49 | 50 | if (Math.Abs(MegaBytes) >= 1) 51 | return MegaBytes; 52 | 53 | if (Math.Abs(KiloBytes) >= 1) 54 | return KiloBytes; 55 | 56 | return Bytes; 57 | } 58 | 59 | /// 60 | public override string ToString() => 61 | $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}"; 62 | } 63 | 64 | public partial struct FileSize : IComparable, IEquatable 65 | { 66 | /// 67 | public int CompareTo(FileSize other) => Bytes.CompareTo(other.Bytes); 68 | 69 | /// 70 | public bool Equals(FileSize other) => CompareTo(other) == 0; 71 | 72 | /// 73 | public override bool Equals(object? obj) => obj is FileSize other && Equals(other); 74 | 75 | /// 76 | public override int GetHashCode() => HashCode.Combine(Bytes); 77 | 78 | /// 79 | /// Equality check. 80 | /// 81 | public static bool operator ==(FileSize left, FileSize right) => left.Equals(right); 82 | 83 | /// 84 | /// Equality check. 85 | /// 86 | public static bool operator !=(FileSize left, FileSize right) => !(left == right); 87 | 88 | /// 89 | /// Comparison. 90 | /// 91 | public static bool operator >(FileSize left, FileSize right) => left.CompareTo(right) > 0; 92 | 93 | /// 94 | /// Comparison. 95 | /// 96 | public static bool operator <(FileSize left, FileSize right) => left.CompareTo(right) < 0; 97 | } 98 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/PlaylistNextResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Globalization; 3 | using System.Linq; 4 | using System.Text.Json; 5 | using Lazy; 6 | using YoutubeExplode.Utils; 7 | using YoutubeExplode.Utils.Extensions; 8 | 9 | namespace YoutubeExplode.Bridge; 10 | 11 | internal partial class PlaylistNextResponse(JsonElement content) : IPlaylistData 12 | { 13 | [Lazy] 14 | private JsonElement? ContentRoot => 15 | content 16 | .GetPropertyOrNull("contents") 17 | ?.GetPropertyOrNull("twoColumnWatchNextResults") 18 | ?.GetPropertyOrNull("playlist") 19 | ?.GetPropertyOrNull("playlist"); 20 | 21 | [Lazy] 22 | public bool IsAvailable => ContentRoot is not null; 23 | 24 | [Lazy] 25 | public string? Title => ContentRoot?.GetPropertyOrNull("title")?.GetStringOrNull(); 26 | 27 | [Lazy] 28 | public string? Author => 29 | ContentRoot 30 | ?.GetPropertyOrNull("ownerName") 31 | ?.GetPropertyOrNull("simpleText") 32 | ?.GetStringOrNull(); 33 | 34 | public string? ChannelId => null; 35 | 36 | public string? Description => null; 37 | 38 | [Lazy] 39 | public int? Count => 40 | ContentRoot 41 | ?.GetPropertyOrNull("totalVideosText") 42 | ?.GetPropertyOrNull("runs") 43 | ?.EnumerateArrayOrNull() 44 | ?.FirstOrNull() 45 | ?.GetPropertyOrNull("text") 46 | ?.GetStringOrNull() 47 | ?.Pipe(s => 48 | int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : (int?)null 49 | ) 50 | ?? ContentRoot 51 | ?.GetPropertyOrNull("videoCountText") 52 | ?.GetPropertyOrNull("runs") 53 | ?.EnumerateArrayOrNull() 54 | ?.ElementAtOrNull(2) 55 | ?.GetPropertyOrNull("text") 56 | ?.GetStringOrNull() 57 | ?.Pipe(s => 58 | int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : (int?)null 59 | ); 60 | 61 | [Lazy] 62 | public IReadOnlyList Thumbnails => Videos.FirstOrDefault()?.Thumbnails ?? []; 63 | 64 | [Lazy] 65 | public IReadOnlyList Videos => 66 | ContentRoot 67 | ?.GetPropertyOrNull("contents") 68 | ?.EnumerateArrayOrNull() 69 | ?.Select(j => j.GetPropertyOrNull("playlistPanelVideoRenderer")) 70 | .WhereNotNull() 71 | .Select(j => new PlaylistVideoData(j)) 72 | .ToArray() ?? []; 73 | 74 | [Lazy] 75 | public string? VisitorData => 76 | content 77 | .GetPropertyOrNull("responseContext") 78 | ?.GetPropertyOrNull("visitorData") 79 | ?.GetStringOrNull(); 80 | } 81 | 82 | internal partial class PlaylistNextResponse 83 | { 84 | public static PlaylistNextResponse Parse(string raw) => new(Json.Parse(raw)); 85 | } 86 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/Bitrate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace YoutubeExplode.Videos.Streams; 4 | 5 | /// 6 | /// Bitrate. 7 | /// 8 | public readonly partial struct Bitrate(long bitsPerSecond) 9 | { 10 | /// 11 | /// Bitrate in bits per second. 12 | /// 13 | public long BitsPerSecond { get; } = bitsPerSecond; 14 | 15 | /// 16 | /// Bitrate in kilobits per second. 17 | /// 18 | public double KiloBitsPerSecond => BitsPerSecond / 1024.0; 19 | 20 | /// 21 | /// Bitrate in megabits per second. 22 | /// 23 | public double MegaBitsPerSecond => KiloBitsPerSecond / 1024.0; 24 | 25 | /// 26 | /// Bitrate in gigabits per second 27 | /// 28 | public double GigaBitsPerSecond => MegaBitsPerSecond / 1024.0; 29 | 30 | private string GetLargestWholeNumberSymbol() 31 | { 32 | if (Math.Abs(GigaBitsPerSecond) >= 1) 33 | return "Gbit/s"; 34 | 35 | if (Math.Abs(MegaBitsPerSecond) >= 1) 36 | return "Mbit/s"; 37 | 38 | if (Math.Abs(KiloBitsPerSecond) >= 1) 39 | return "Kbit/s"; 40 | 41 | return "Bit/s"; 42 | } 43 | 44 | private double GetLargestWholeNumberValue() 45 | { 46 | if (Math.Abs(GigaBitsPerSecond) >= 1) 47 | return GigaBitsPerSecond; 48 | 49 | if (Math.Abs(MegaBitsPerSecond) >= 1) 50 | return MegaBitsPerSecond; 51 | 52 | if (Math.Abs(KiloBitsPerSecond) >= 1) 53 | return KiloBitsPerSecond; 54 | 55 | return BitsPerSecond; 56 | } 57 | 58 | /// 59 | public override string ToString() => 60 | $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}"; 61 | } 62 | 63 | public partial struct Bitrate : IComparable, IEquatable 64 | { 65 | /// 66 | public int CompareTo(Bitrate other) => BitsPerSecond.CompareTo(other.BitsPerSecond); 67 | 68 | /// 69 | public bool Equals(Bitrate other) => CompareTo(other) == 0; 70 | 71 | /// 72 | public override bool Equals(object? obj) => obj is Bitrate other && Equals(other); 73 | 74 | /// 75 | public override int GetHashCode() => HashCode.Combine(BitsPerSecond); 76 | 77 | /// 78 | /// Equality check. 79 | /// 80 | public static bool operator ==(Bitrate left, Bitrate right) => left.Equals(right); 81 | 82 | /// 83 | /// Equality check. 84 | /// 85 | public static bool operator !=(Bitrate left, Bitrate right) => !(left == right); 86 | 87 | /// 88 | /// Comparison. 89 | /// 90 | public static bool operator >(Bitrate left, Bitrate right) => left.CompareTo(right) > 0; 91 | 92 | /// 93 | /// Comparison. 94 | /// 95 | public static bool operator <(Bitrate left, Bitrate right) => left.CompareTo(right) < 0; 96 | } 97 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/ConversionRequestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.IO; 5 | using YoutubeExplode.Converter.Utils.Extensions; 6 | using YoutubeExplode.Videos.Streams; 7 | 8 | namespace YoutubeExplode.Converter; 9 | 10 | /// 11 | /// Builder for . 12 | /// 13 | public class ConversionRequestBuilder(string outputFilePath) 14 | { 15 | private readonly Dictionary _environmentVariables = new( 16 | StringComparer.Ordinal 17 | ); 18 | 19 | private string? _ffmpegCliFilePath; 20 | private Container? _container; 21 | private ConversionPreset _preset; 22 | 23 | private Container GetDefaultContainer() => 24 | new(Path.GetExtension(outputFilePath).TrimStart('.').NullIfWhiteSpace() ?? "mp4"); 25 | 26 | /// 27 | /// Sets the path to the FFmpeg CLI. 28 | /// 29 | public ConversionRequestBuilder SetFFmpegPath(string path) 30 | { 31 | _ffmpegCliFilePath = path; 32 | return this; 33 | } 34 | 35 | /// 36 | /// Sets the output container. 37 | /// 38 | public ConversionRequestBuilder SetContainer(Container container) 39 | { 40 | _container = container; 41 | return this; 42 | } 43 | 44 | /// 45 | /// Sets the output container. 46 | /// 47 | public ConversionRequestBuilder SetContainer(string container) => 48 | SetContainer(new Container(container)); 49 | 50 | /// 51 | /// Sets the conversion format. 52 | /// 53 | [Obsolete("Use SetContainer instead."), ExcludeFromCodeCoverage] 54 | public ConversionRequestBuilder SetFormat(ConversionFormat format) => 55 | SetContainer(new Container(format.Name)); 56 | 57 | /// 58 | /// Sets the conversion format. 59 | /// 60 | [Obsolete("Use SetContainer instead."), ExcludeFromCodeCoverage] 61 | public ConversionRequestBuilder SetFormat(string format) => SetContainer(format); 62 | 63 | /// 64 | /// Sets the conversion preset. 65 | /// 66 | public ConversionRequestBuilder SetPreset(ConversionPreset preset) 67 | { 68 | _preset = preset; 69 | return this; 70 | } 71 | 72 | /// 73 | /// Sets an environment variable for the FFmpeg process. 74 | /// 75 | public ConversionRequestBuilder SetEnvironmentVariable(string name, string? value) 76 | { 77 | _environmentVariables[name] = value; 78 | return this; 79 | } 80 | 81 | /// 82 | /// Builds the resulting request. 83 | /// 84 | public ConversionRequest Build() => 85 | new( 86 | _ffmpegCliFilePath ?? FFmpeg.GetFilePath(), 87 | outputFilePath, 88 | _container ?? GetDefaultContainer(), 89 | _preset, 90 | _environmentVariables 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/UrlEx.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Text; 6 | using YoutubeExplode.Utils.Extensions; 7 | 8 | namespace YoutubeExplode.Utils; 9 | 10 | internal static class UrlEx 11 | { 12 | private static IEnumerable> EnumerateQueryParameters(string url) 13 | { 14 | var query = url.Contains('?') ? url.SubstringAfter("?") : url; 15 | 16 | foreach (var parameter in query.Split('&')) 17 | { 18 | var key = WebUtility.UrlDecode(parameter.SubstringUntil("=")); 19 | var value = WebUtility.UrlDecode(parameter.SubstringAfter("=")); 20 | 21 | if (string.IsNullOrWhiteSpace(key)) 22 | continue; 23 | 24 | yield return new KeyValuePair(key, value); 25 | } 26 | } 27 | 28 | public static IReadOnlyDictionary GetQueryParameters(string url) => 29 | EnumerateQueryParameters(url).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); 30 | 31 | private static KeyValuePair? TryGetQueryParameter(string url, string key) 32 | { 33 | foreach (var parameter in EnumerateQueryParameters(url)) 34 | { 35 | if (string.Equals(parameter.Key, key, StringComparison.Ordinal)) 36 | return parameter; 37 | } 38 | 39 | return null; 40 | } 41 | 42 | public static string? TryGetQueryParameterValue(string url, string key) => 43 | TryGetQueryParameter(url, key)?.Value; 44 | 45 | public static bool ContainsQueryParameter(string url, string key) => 46 | TryGetQueryParameterValue(url, key) is not null; 47 | 48 | public static string RemoveQueryParameter(string url, string key) 49 | { 50 | if (!ContainsQueryParameter(url, key)) 51 | return url; 52 | 53 | var urlBuilder = new UriBuilder(url); 54 | var queryBuilder = new StringBuilder(); 55 | 56 | foreach (var parameter in EnumerateQueryParameters(url)) 57 | { 58 | if (string.Equals(parameter.Key, key, StringComparison.Ordinal)) 59 | continue; 60 | 61 | queryBuilder.Append(queryBuilder.Length > 0 ? '&' : '?'); 62 | 63 | queryBuilder.Append(Uri.EscapeDataString(parameter.Key)); 64 | queryBuilder.Append('='); 65 | queryBuilder.Append(Uri.EscapeDataString(parameter.Value)); 66 | } 67 | 68 | urlBuilder.Query = queryBuilder.ToString(); 69 | 70 | return urlBuilder.ToString(); 71 | } 72 | 73 | public static string SetQueryParameter(string url, string key, string value) 74 | { 75 | var urlWithoutParameter = RemoveQueryParameter(url, key); 76 | var hasOtherParameters = urlWithoutParameter.Contains('?'); 77 | 78 | return urlWithoutParameter 79 | + (hasOtherParameters ? '&' : '?') 80 | + Uri.EscapeDataString(key) 81 | + '=' 82 | + Uri.EscapeDataString(value); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/Streams/Container.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace YoutubeExplode.Videos.Streams; 4 | 5 | /// 6 | /// Stream container. 7 | /// 8 | public readonly partial struct Container(string name) 9 | { 10 | /// 11 | /// Container name (e.g. mp4, webm, etc). 12 | /// Can be used as file extension. 13 | /// 14 | public string Name { get; } = name; 15 | 16 | /// 17 | /// Whether this container is a known audio-only container. 18 | /// 19 | /// 20 | /// This property only refers to the container's capabilities and not its actual contents. 21 | /// If the container IS audio-only, it DOES NOT contain any video streams. 22 | /// If the container IS NOT audio-only, it MAY contain video streams, but is not required to. 23 | /// 24 | public bool IsAudioOnly => 25 | string.Equals(Name, "mp3", StringComparison.OrdinalIgnoreCase) 26 | || string.Equals(Name, "m4a", StringComparison.OrdinalIgnoreCase) 27 | || string.Equals(Name, "wav", StringComparison.OrdinalIgnoreCase) 28 | || string.Equals(Name, "wma", StringComparison.OrdinalIgnoreCase) 29 | || string.Equals(Name, "ogg", StringComparison.OrdinalIgnoreCase) 30 | || string.Equals(Name, "aac", StringComparison.OrdinalIgnoreCase) 31 | || string.Equals(Name, "opus", StringComparison.OrdinalIgnoreCase); 32 | 33 | /// 34 | public override string ToString() => Name; 35 | } 36 | 37 | public partial struct Container 38 | { 39 | /// 40 | /// MPEG-2 Audio Layer III (mp3). 41 | /// 42 | /// 43 | /// YouTube does not natively provide streams in this container. 44 | /// 45 | public static Container Mp3 { get; } = new("mp3"); 46 | 47 | /// 48 | /// MPEG-4 Part 14 (mp4). 49 | /// 50 | public static Container Mp4 { get; } = new("mp4"); 51 | 52 | /// 53 | /// Web Media (webm). 54 | /// 55 | public static Container WebM { get; } = new("webm"); 56 | 57 | /// 58 | /// 3rd Generation Partnership Project (3gpp). 59 | /// 60 | public static Container Tgpp { get; } = new("3gpp"); 61 | } 62 | 63 | public partial struct Container : IEquatable 64 | { 65 | /// 66 | public bool Equals(Container other) => 67 | string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); 68 | 69 | /// 70 | public override bool Equals(object? obj) => obj is Container other && Equals(other); 71 | 72 | /// 73 | public override int GetHashCode() => Name.GetHashCode(StringComparison.OrdinalIgnoreCase); 74 | 75 | /// 76 | /// Equality check. 77 | /// 78 | public static bool operator ==(Container left, Container right) => left.Equals(right); 79 | 80 | /// 81 | /// Equality check. 82 | /// 83 | public static bool operator !=(Container left, Container right) => !(left == right); 84 | } 85 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/SubtitleSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Linq; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using FluentAssertions; 6 | using Xunit; 7 | using YoutubeExplode.Converter.Tests.Utils; 8 | using YoutubeExplode.Converter.Tests.Utils.Extensions; 9 | using YoutubeExplode.Videos.Streams; 10 | 11 | namespace YoutubeExplode.Converter.Tests; 12 | 13 | public class SubtitleSpecs : IAsyncLifetime 14 | { 15 | public async Task InitializeAsync() => await FFmpeg.InitializeAsync(); 16 | 17 | public Task DisposeAsync() => Task.CompletedTask; 18 | 19 | [Fact] 20 | public async Task I_can_download_a_video_as_a_single_mp4_file_with_subtitles() 21 | { 22 | // Arrange 23 | using var youtube = new YoutubeClient(); 24 | 25 | using var dir = TempDir.Create(); 26 | var filePath = Path.Combine(dir.Path, "video.mp4"); 27 | 28 | var streamManifest = await youtube.Videos.Streams.GetManifestAsync("NtQkz0aRDe8"); 29 | var streamInfos = streamManifest 30 | .GetVideoStreams() 31 | .Where(s => s.Container == Container.Mp4) 32 | .OrderBy(s => s.Size) 33 | .Take(1) 34 | .ToArray(); 35 | 36 | var trackManifest = await youtube.Videos.ClosedCaptions.GetManifestAsync("NtQkz0aRDe8"); 37 | var trackInfos = trackManifest.Tracks; 38 | 39 | // Act 40 | await youtube.Videos.DownloadAsync( 41 | streamInfos, 42 | trackInfos, 43 | new ConversionRequestBuilder(filePath).Build() 44 | ); 45 | 46 | // Assert 47 | MediaFormat.IsMp4File(filePath).Should().BeTrue(); 48 | 49 | foreach (var trackInfo in trackInfos) 50 | { 51 | File.ContainsBytes(filePath, Encoding.ASCII.GetBytes(trackInfo.Language.Name)) 52 | .Should() 53 | .BeTrue(); 54 | } 55 | } 56 | 57 | [Fact] 58 | public async Task I_can_download_a_video_as_a_single_webm_file_with_subtitles() 59 | { 60 | // Arrange 61 | using var youtube = new YoutubeClient(); 62 | 63 | using var dir = TempDir.Create(); 64 | var filePath = Path.Combine(dir.Path, "video.webm"); 65 | 66 | var streamManifest = await youtube.Videos.Streams.GetManifestAsync("NtQkz0aRDe8"); 67 | var streamInfos = streamManifest 68 | .GetVideoStreams() 69 | .Where(s => s.Container == Container.WebM) 70 | .OrderBy(s => s.Size) 71 | .Take(1) 72 | .ToArray(); 73 | 74 | var trackManifest = await youtube.Videos.ClosedCaptions.GetManifestAsync("NtQkz0aRDe8"); 75 | var trackInfos = trackManifest.Tracks; 76 | 77 | // Act 78 | await youtube.Videos.DownloadAsync( 79 | streamInfos, 80 | trackInfos, 81 | new ConversionRequestBuilder(filePath).Build() 82 | ); 83 | 84 | // Assert 85 | MediaFormat.IsWebMFile(filePath).Should().BeTrue(); 86 | 87 | foreach (var trackInfo in trackInfos) 88 | { 89 | File.ContainsBytes(filePath, Encoding.ASCII.GetBytes(trackInfo.Language.Name)) 90 | .Should() 91 | .BeTrue(); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /YoutubeExplode/Utils/Extensions/JsonExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Text.Json; 4 | 5 | namespace YoutubeExplode.Utils.Extensions; 6 | 7 | internal static class JsonExtensions 8 | { 9 | extension(JsonElement element) 10 | { 11 | public JsonElement? GetPropertyOrNull(string propertyName) 12 | { 13 | if (element.ValueKind != JsonValueKind.Object) 14 | { 15 | return null; 16 | } 17 | 18 | if ( 19 | element.TryGetProperty(propertyName, out var result) 20 | && result.ValueKind != JsonValueKind.Null 21 | && result.ValueKind != JsonValueKind.Undefined 22 | ) 23 | { 24 | return result; 25 | } 26 | 27 | return null; 28 | } 29 | 30 | public bool? GetBooleanOrNull() => 31 | element.ValueKind switch 32 | { 33 | JsonValueKind.True => true, 34 | JsonValueKind.False => false, 35 | _ => null, 36 | }; 37 | 38 | public string? GetStringOrNull() => 39 | element.ValueKind == JsonValueKind.String ? element.GetString() : null; 40 | 41 | public int? GetInt32OrNull() => 42 | element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out var result) 43 | ? result 44 | : null; 45 | 46 | public long? GetInt64OrNull() => 47 | element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var result) 48 | ? result 49 | : null; 50 | 51 | public JsonElement.ArrayEnumerator? EnumerateArrayOrNull() => 52 | element.ValueKind == JsonValueKind.Array ? element.EnumerateArray() : null; 53 | 54 | public JsonElement.ArrayEnumerator EnumerateArrayOrEmpty() => 55 | element.EnumerateArrayOrNull() ?? default; 56 | 57 | public JsonElement.ObjectEnumerator? EnumerateObjectOrNull() => 58 | element.ValueKind == JsonValueKind.Object ? element.EnumerateObject() : null; 59 | 60 | public JsonElement.ObjectEnumerator EnumerateObjectOrEmpty() => 61 | element.EnumerateObjectOrNull() ?? default; 62 | 63 | public IEnumerable EnumerateDescendantProperties(string propertyName) 64 | { 65 | // Check if this property exists on the current object 66 | var property = element.GetPropertyOrNull(propertyName); 67 | if (property is not null) 68 | yield return property.Value; 69 | 70 | // Recursively check on all array children (if current element is an array) 71 | var deepArrayDescendants = element 72 | .EnumerateArrayOrEmpty() 73 | .SelectMany(j => j.EnumerateDescendantProperties(propertyName)); 74 | 75 | foreach (var deepDescendant in deepArrayDescendants) 76 | yield return deepDescendant; 77 | 78 | // Recursively check on all object children (if current element is an object) 79 | var deepObjectDescendants = element 80 | .EnumerateObjectOrEmpty() 81 | .SelectMany(j => j.Value.EnumerateDescendantProperties(propertyName)); 82 | 83 | foreach (var deepDescendant in deepObjectDescendants) 84 | yield return deepDescendant; 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /YoutubeExplode/Channels/UserName.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net; 4 | using System.Text.RegularExpressions; 5 | using YoutubeExplode.Utils.Extensions; 6 | 7 | namespace YoutubeExplode.Channels; 8 | 9 | /// 10 | /// Represents a syntactically valid YouTube user name. 11 | /// 12 | public readonly partial struct UserName(string value) 13 | { 14 | /// 15 | /// Raw user name value. 16 | /// 17 | public string Value { get; } = value; 18 | 19 | /// 20 | public override string ToString() => Value; 21 | } 22 | 23 | public partial struct UserName 24 | { 25 | private static bool IsValid(string userName) => 26 | userName.Length <= 20 && userName.All(char.IsLetterOrDigit); 27 | 28 | private static string? TryNormalize(string? userNameOrUrl) 29 | { 30 | if (string.IsNullOrWhiteSpace(userNameOrUrl)) 31 | return null; 32 | 33 | // Check if already passed a user name 34 | // TheTyrrr 35 | if (IsValid(userNameOrUrl)) 36 | return userNameOrUrl; 37 | 38 | // Try to extract the user name from the URL 39 | // https://www.youtube.com/user/TheTyrrr 40 | var userName = Regex 41 | .Match(userNameOrUrl, @"youtube\..+?/user/(.*?)(?:\?|&|/|$)") 42 | .Groups[1] 43 | .Value.Pipe(WebUtility.UrlDecode); 44 | 45 | if (!string.IsNullOrWhiteSpace(userName) && IsValid(userName)) 46 | return userName; 47 | 48 | // Invalid input 49 | return null; 50 | } 51 | 52 | /// 53 | /// Attempts to parse the specified string as a YouTube user name or profile URL. 54 | /// Returns null in case of failure. 55 | /// 56 | public static UserName? TryParse(string? userNameOrUrl) => 57 | TryNormalize(userNameOrUrl)?.Pipe(name => new UserName(name)); 58 | 59 | /// 60 | /// Parses the specified string as a YouTube user name or profile URL. 61 | /// 62 | public static UserName Parse(string userNameOrUrl) => 63 | TryParse(userNameOrUrl) 64 | ?? throw new ArgumentException( 65 | $"Invalid YouTube user name or profile URL '{userNameOrUrl}'." 66 | ); 67 | 68 | /// 69 | /// Converts string to user name. 70 | /// 71 | public static implicit operator UserName(string userNameOrUrl) => Parse(userNameOrUrl); 72 | 73 | /// 74 | /// Converts user name to string. 75 | /// 76 | public static implicit operator string(UserName userName) => userName.ToString(); 77 | } 78 | 79 | public partial struct UserName : IEquatable 80 | { 81 | /// 82 | public bool Equals(UserName other) => 83 | string.Equals(Value, other.Value, StringComparison.Ordinal); 84 | 85 | /// 86 | public override bool Equals(object? obj) => obj is UserName other && Equals(other); 87 | 88 | /// 89 | public override int GetHashCode() => Value.GetHashCode(StringComparison.Ordinal); 90 | 91 | /// 92 | /// Equality check. 93 | /// 94 | public static bool operator ==(UserName left, UserName right) => left.Equals(right); 95 | 96 | /// 97 | /// Equality check. 98 | /// 99 | public static bool operator !=(UserName left, UserName right) => !(left == right); 100 | } 101 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Report broken functionality. 3 | labels: [bug] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | - Avoid generic or vague titles such as "Something's not working" or "A couple of problems" — be as descriptive as possible. 10 | - Keep your issue focused on one single problem. If you have multiple bug reports, please create a separate issue for each of them. 11 | - Issues should represent **complete and actionable** work items. If you are unsure about something or have a question, please start a [discussion](https://github.com/Tyrrrz/YoutubeExplode/discussions/new) instead. 12 | - Remember that **YoutubeExplode** is an open-source project funded by the community. If you find it useful, **please consider [donating](https://tyrrrz.me/donate) to support its development**. 13 | 14 | ___ 15 | 16 | - type: input 17 | attributes: 18 | label: Version 19 | description: Which version of the package does this bug affect? Make sure you're not using an outdated version. 20 | placeholder: v1.0.0 21 | validations: 22 | required: true 23 | 24 | - type: input 25 | attributes: 26 | label: Platform 27 | description: Which platform do you experience this bug on? 28 | placeholder: .NET 7.0 / Windows 11 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | attributes: 34 | label: Steps to reproduce 35 | description: > 36 | Minimum steps required to reproduce the bug, including prerequisites, code snippets, or other relevant items. 37 | The information provided in this field must be readily actionable, meaning that anyone should be able to reproduce the bug by following these steps. 38 | If the reproduction steps are too complex to fit in this field, please provide a link to a repository instead. 39 | placeholder: | 40 | - Step 1 41 | - Step 2 42 | - Step 3 43 | validations: 44 | required: true 45 | 46 | - type: textarea 47 | attributes: 48 | label: Details 49 | description: Clear and thorough explanation of the bug, including any additional information you may find relevant. 50 | placeholder: | 51 | - Expected behavior: ... 52 | - Actual behavior: ... 53 | validations: 54 | required: true 55 | 56 | - type: checkboxes 57 | attributes: 58 | label: Checklist 59 | description: Quick list of checks to ensure that everything is in order. 60 | options: 61 | - label: I have looked through existing issues to make sure that this bug has not been reported before 62 | required: true 63 | - label: I have provided a descriptive title for this issue 64 | required: true 65 | - label: I have made sure that this bug is reproducible on the latest version of the package 66 | required: true 67 | - label: I have provided all the information needed to reproduce this bug as efficiently as possible 68 | required: true 69 | - label: I have sponsored this project 70 | required: false 71 | - label: I have not read any of the above and just checked all the boxes to submit the issue 72 | required: false 73 | 74 | - type: markdown 75 | attributes: 76 | value: | 77 | If you are struggling to provide actionable reproduction steps, or if something else is preventing you from creating a complete bug report, please start a [discussion](https://github.com/Tyrrrz/YoutubeExplode/discussions/new) instead. 78 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter.Tests/Utils/FFmpeg.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.IO.Compression; 4 | using System.Net.Http; 5 | using System.Reflection; 6 | using System.Runtime.InteropServices; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using YoutubeExplode.Converter.Tests.Utils.Extensions; 10 | 11 | namespace YoutubeExplode.Converter.Tests.Utils; 12 | 13 | public static class FFmpeg 14 | { 15 | private static readonly SemaphoreSlim Lock = new(1, 1); 16 | 17 | public static Version Version { get; } = new(7, 1, 1); 18 | 19 | private static string FileName { get; } = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg"; 20 | 21 | public static string FilePath { get; } = 22 | Path.Combine( 23 | Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) 24 | ?? Directory.GetCurrentDirectory(), 25 | FileName 26 | ); 27 | 28 | private static string GetDownloadUrl() 29 | { 30 | static string GetSystemMoniker() 31 | { 32 | if (OperatingSystem.IsWindows()) 33 | return "windows"; 34 | 35 | if (OperatingSystem.IsLinux()) 36 | return "linux"; 37 | 38 | if (OperatingSystem.IsMacOS()) 39 | return "osx"; 40 | 41 | throw new NotSupportedException("Unsupported operating system."); 42 | } 43 | 44 | static string GetArchitectureMoniker() 45 | { 46 | if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) 47 | return "arm64"; 48 | 49 | if (RuntimeInformation.ProcessArchitecture == Architecture.X64) 50 | return "x64"; 51 | 52 | if (RuntimeInformation.ProcessArchitecture == Architecture.X86) 53 | return "x86"; 54 | 55 | throw new NotSupportedException("Unsupported architecture."); 56 | } 57 | 58 | var sys = GetSystemMoniker(); 59 | var arch = GetArchitectureMoniker(); 60 | 61 | return $"https://github.com/Tyrrrz/FFmpegBin/releases/download/{Version}/ffmpeg-{sys}-{arch}.zip"; 62 | } 63 | 64 | private static async ValueTask DownloadAsync() 65 | { 66 | using var archiveFile = TempFile.Create(); 67 | using var http = new HttpClient(); 68 | 69 | // Download the archive 70 | await http.DownloadAsync(GetDownloadUrl(), archiveFile.Path); 71 | 72 | // Extract the executable 73 | using (var zip = ZipFile.OpenRead(archiveFile.Path)) 74 | { 75 | var entry = 76 | zip.GetEntry(FileName) 77 | ?? throw new FileNotFoundException( 78 | "Downloaded archive doesn't contain the FFmpeg executable." 79 | ); 80 | 81 | entry.ExtractToFile(FilePath, true); 82 | } 83 | 84 | // Add the execute permission on Unix 85 | if (!OperatingSystem.IsWindows()) 86 | { 87 | File.SetUnixFileMode( 88 | FilePath, 89 | File.GetUnixFileMode(FilePath) | UnixFileMode.UserExecute 90 | ); 91 | } 92 | } 93 | 94 | public static async ValueTask InitializeAsync() 95 | { 96 | await Lock.WaitAsync(); 97 | 98 | try 99 | { 100 | if (!File.Exists(FilePath)) 101 | await DownloadAsync(); 102 | } 103 | finally 104 | { 105 | Lock.Release(); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /YoutubeExplode/Channels/ChannelId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net; 4 | using System.Text.RegularExpressions; 5 | using YoutubeExplode.Utils.Extensions; 6 | 7 | namespace YoutubeExplode.Channels; 8 | 9 | /// 10 | /// Represents a syntactically valid YouTube channel ID. 11 | /// 12 | public readonly partial struct ChannelId(string value) 13 | { 14 | /// 15 | /// Raw ID value. 16 | /// 17 | public string Value { get; } = value; 18 | 19 | /// 20 | public override string ToString() => Value; 21 | } 22 | 23 | public partial struct ChannelId 24 | { 25 | private static bool IsValid(string channelId) => 26 | channelId.StartsWith("UC", StringComparison.Ordinal) 27 | && channelId.Length == 24 28 | && channelId.All(c => char.IsLetterOrDigit(c) || c is '_' or '-'); 29 | 30 | private static string? TryNormalize(string? channelIdOrUrl) 31 | { 32 | if (string.IsNullOrWhiteSpace(channelIdOrUrl)) 33 | return null; 34 | 35 | // Check if already passed an ID 36 | // UC3xnGqlcL3y-GXz5N3wiTJQ 37 | if (IsValid(channelIdOrUrl)) 38 | return channelIdOrUrl; 39 | 40 | // Try to extract the ID from the URL 41 | // https://www.youtube.com/channel/UC3xnGqlcL3y-GXz5N3wiTJQ 42 | var id = Regex 43 | .Match(channelIdOrUrl, @"youtube\..+?/channel/(.*?)(?:\?|&|/|$)") 44 | .Groups[1] 45 | .Value.Pipe(WebUtility.UrlDecode); 46 | 47 | if (!string.IsNullOrWhiteSpace(id) && IsValid(id)) 48 | return id; 49 | 50 | // Invalid input 51 | return null; 52 | } 53 | 54 | /// 55 | /// Attempts to parse the specified string as a YouTube channel ID or URL. 56 | /// Returns null in case of failure. 57 | /// 58 | public static ChannelId? TryParse(string? channelIdOrUrl) => 59 | TryNormalize(channelIdOrUrl)?.Pipe(id => new ChannelId(id)); 60 | 61 | /// 62 | /// Parses the specified string as a YouTube channel ID or URL. 63 | /// 64 | public static ChannelId Parse(string channelIdOrUrl) => 65 | TryParse(channelIdOrUrl) 66 | ?? throw new ArgumentException($"Invalid YouTube channel ID or URL '{channelIdOrUrl}'."); 67 | 68 | /// 69 | /// Converts string to ID. 70 | /// 71 | public static implicit operator ChannelId(string channelIdOrUrl) => Parse(channelIdOrUrl); 72 | 73 | /// 74 | /// Converts ID to string. 75 | /// 76 | public static implicit operator string(ChannelId channelId) => channelId.ToString(); 77 | } 78 | 79 | public partial struct ChannelId : IEquatable 80 | { 81 | /// 82 | public bool Equals(ChannelId other) => 83 | string.Equals(Value, other.Value, StringComparison.Ordinal); 84 | 85 | /// 86 | public override bool Equals(object? obj) => obj is ChannelId other && Equals(other); 87 | 88 | /// 89 | public override int GetHashCode() => Value.GetHashCode(StringComparison.Ordinal); 90 | 91 | /// 92 | /// Equality check. 93 | /// 94 | public static bool operator ==(ChannelId left, ChannelId right) => left.Equals(right); 95 | 96 | /// 97 | /// Equality check. 98 | /// 99 | public static bool operator !=(ChannelId left, ChannelId right) => !(left == right); 100 | } 101 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/ChannelSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using FluentAssertions; 4 | using Xunit; 5 | using YoutubeExplode.Common; 6 | using YoutubeExplode.Tests.TestData; 7 | 8 | namespace YoutubeExplode.Tests; 9 | 10 | public class ChannelSpecs 11 | { 12 | [Fact] 13 | public async Task I_can_get_the_metadata_of_a_channel() 14 | { 15 | // Arrange 16 | using var youtube = new YoutubeClient(); 17 | 18 | // Act 19 | var channel = await youtube.Channels.GetAsync(ChannelIds.Normal); 20 | 21 | // Assert 22 | channel.Id.Value.Should().Be(ChannelIds.Normal); 23 | channel.Url.Should().NotBeNullOrWhiteSpace(); 24 | channel.Title.Should().Be("MrBeast"); 25 | channel.Thumbnails.Should().NotBeEmpty(); 26 | } 27 | 28 | [Fact] 29 | public async Task I_can_get_the_metadata_of_a_channel_by_user_name() 30 | { 31 | // Arrange 32 | using var youtube = new YoutubeClient(); 33 | 34 | // Act 35 | var channel = await youtube.Channels.GetByUserAsync(UserNames.Normal); 36 | 37 | // Assert 38 | channel.Id.Value.Should().Be("UCX6OQ3DkcsbYNE6H8uQQuVA"); 39 | channel.Url.Should().NotBeNullOrWhiteSpace(); 40 | channel.Title.Should().Be("MrBeast"); 41 | channel.Thumbnails.Should().NotBeEmpty(); 42 | } 43 | 44 | [Fact] 45 | public async Task I_can_get_the_metadata_of_a_channel_by_slug() 46 | { 47 | // Arrange 48 | using var youtube = new YoutubeClient(); 49 | 50 | // Act 51 | var channel = await youtube.Channels.GetBySlugAsync(ChannelSlugs.Normal); 52 | 53 | // Assert 54 | channel.Id.Value.Should().Be("UCSli-_XJrdRwRoPw8DXRiyw"); 55 | channel.Url.Should().NotBeNullOrWhiteSpace(); 56 | channel.Title.Should().Be("Меланія Подоляк"); 57 | channel.Thumbnails.Should().NotBeEmpty(); 58 | } 59 | 60 | [Fact] 61 | public async Task I_can_get_the_metadata_of_a_channel_by_handle() 62 | { 63 | // Arrange 64 | using var youtube = new YoutubeClient(); 65 | 66 | // Act 67 | var channel = await youtube.Channels.GetByHandleAsync(ChannelHandles.Normal); 68 | 69 | // Assert 70 | channel.Id.Value.Should().Be("UCX6OQ3DkcsbYNE6H8uQQuVA"); 71 | channel.Url.Should().NotBeNullOrWhiteSpace(); 72 | channel.Title.Should().Be("MrBeast"); 73 | channel.Thumbnails.Should().NotBeEmpty(); 74 | } 75 | 76 | [Theory] 77 | [InlineData(ChannelIds.Normal)] 78 | [InlineData(ChannelIds.Movies)] 79 | public async Task I_can_get_the_metadata_of_any_available_channel(string channelId) 80 | { 81 | // Arrange 82 | using var youtube = new YoutubeClient(); 83 | 84 | // Act 85 | var channel = await youtube.Channels.GetAsync(channelId); 86 | 87 | // Assert 88 | channel.Id.Value.Should().Be(channelId); 89 | channel.Url.Should().NotBeNullOrWhiteSpace(); 90 | channel.Title.Should().NotBeNullOrWhiteSpace(); 91 | channel.Thumbnails.Should().NotBeEmpty(); 92 | } 93 | 94 | [Fact] 95 | public async Task I_can_get_videos_uploaded_by_a_channel() 96 | { 97 | // Arrange 98 | using var youtube = new YoutubeClient(); 99 | 100 | // Act 101 | var videos = await youtube.Channels.GetUploadsAsync(ChannelIds.Normal); 102 | 103 | // Assert 104 | videos.Should().HaveCountGreaterThanOrEqualTo(730); 105 | videos.Select(v => v.Author.ChannelId).Should().OnlyContain(i => i == ChannelIds.Normal); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /YoutubeExplode/Bridge/DashManifest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Globalization; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Text.RegularExpressions; 6 | using System.Xml.Linq; 7 | using Lazy; 8 | using YoutubeExplode.Utils; 9 | using YoutubeExplode.Utils.Extensions; 10 | 11 | namespace YoutubeExplode.Bridge; 12 | 13 | internal partial class DashManifest(XElement content) 14 | { 15 | [Lazy] 16 | public IReadOnlyList Streams => 17 | content 18 | .Descendants("Representation") 19 | // Skip non-media representations (like "rawcc") 20 | // https://github.com/Tyrrrz/YoutubeExplode/issues/546 21 | .Where(x => x.Attribute("id")?.Value.All(char.IsDigit) == true) 22 | // Skip segmented streams 23 | // https://github.com/Tyrrrz/YoutubeExplode/issues/159 24 | .Where(x => 25 | x.Descendants("Initialization") 26 | .FirstOrDefault() 27 | ?.Attribute("sourceURL") 28 | ?.Value.Contains("sq/") != true 29 | ) 30 | // Skip streams without codecs 31 | .Where(x => !string.IsNullOrWhiteSpace(x.Attribute("codecs")?.Value)) 32 | .Select(x => new StreamData(x)) 33 | .ToArray(); 34 | } 35 | 36 | internal partial class DashManifest 37 | { 38 | public class StreamData(XElement content) : IStreamData 39 | { 40 | [Lazy] 41 | public int? Itag => (int?)content.Attribute("id"); 42 | 43 | [Lazy] 44 | public string? Url => (string?)content.Element("BaseURL"); 45 | 46 | // DASH streams don't have signatures 47 | public string? Signature => null; 48 | 49 | // DASH streams don't have signatures 50 | public string? SignatureParameter => null; 51 | 52 | [Lazy] 53 | public long? ContentLength => 54 | (long?)content.Attribute("contentLength") 55 | ?? Url?.Pipe(s => Regex.Match(s, @"[/\?]clen[/=](\d+)").Groups[1].Value) 56 | .NullIfWhiteSpace() 57 | ?.Pipe(s => 58 | long.TryParse(s, CultureInfo.InvariantCulture, out var result) 59 | ? result 60 | : (long?)null 61 | ); 62 | 63 | [Lazy] 64 | public long? Bitrate => (long?)content.Attribute("bandwidth"); 65 | 66 | [Lazy] 67 | public string? Container => 68 | Url 69 | ?.Pipe(s => Regex.Match(s, @"mime[/=]\w*%2F([\w\d]*)").Groups[1].Value) 70 | .Pipe(WebUtility.UrlDecode); 71 | 72 | [Lazy] 73 | private bool IsAudioOnly => content.Element("AudioChannelConfiguration") is not null; 74 | 75 | [Lazy] 76 | public string? AudioCodec => IsAudioOnly ? (string?)content.Attribute("codecs") : null; 77 | 78 | public string? AudioLanguageCode => null; 79 | 80 | public string? AudioLanguageName => null; 81 | 82 | public bool? IsAudioLanguageDefault => null; 83 | 84 | [Lazy] 85 | public string? VideoCodec => IsAudioOnly ? null : (string?)content.Attribute("codecs"); 86 | 87 | public string? VideoQualityLabel => null; 88 | 89 | [Lazy] 90 | public int? VideoWidth => (int?)content.Attribute("width"); 91 | 92 | [Lazy] 93 | public int? VideoHeight => (int?)content.Attribute("height"); 94 | 95 | [Lazy] 96 | public int? VideoFramerate => (int?)content.Attribute("frameRate"); 97 | } 98 | } 99 | 100 | internal partial class DashManifest 101 | { 102 | public static DashManifest Parse(string raw) => new(Xml.Parse(raw)); 103 | } 104 | -------------------------------------------------------------------------------- /YoutubeExplode/Videos/VideoClient.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Net.Http; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using YoutubeExplode.Common; 6 | using YoutubeExplode.Exceptions; 7 | using YoutubeExplode.Videos.ClosedCaptions; 8 | using YoutubeExplode.Videos.Streams; 9 | 10 | namespace YoutubeExplode.Videos; 11 | 12 | /// 13 | /// Operations related to YouTube videos. 14 | /// 15 | public class VideoClient(HttpClient http) 16 | { 17 | private readonly VideoController _controller = new(http); 18 | 19 | /// 20 | /// Operations related to media streams of YouTube videos. 21 | /// 22 | public StreamClient Streams { get; } = new(http); 23 | 24 | /// 25 | /// Operations related to closed captions of YouTube videos. 26 | /// 27 | public ClosedCaptionClient ClosedCaptions { get; } = new(http); 28 | 29 | /// 30 | /// Gets the metadata associated with the specified video. 31 | /// 32 | public async ValueTask GetAsync( 33 | VideoId videoId, 34 | CancellationToken cancellationToken = default 35 | ) 36 | { 37 | var watchPage = await _controller.GetVideoWatchPageAsync(videoId, cancellationToken); 38 | 39 | var playerResponse = 40 | watchPage.PlayerResponse 41 | ?? await _controller.GetPlayerResponseAsync(videoId, cancellationToken); 42 | 43 | var title = 44 | playerResponse.Title 45 | // Videos without title are legal 46 | // https://github.com/Tyrrrz/YoutubeExplode/issues/700 47 | ?? ""; 48 | 49 | var channelTitle = 50 | playerResponse.Author 51 | ?? throw new YoutubeExplodeException("Failed to extract the video author."); 52 | 53 | var channelId = 54 | playerResponse.ChannelId 55 | ?? throw new YoutubeExplodeException("Failed to extract the video channel ID."); 56 | 57 | var uploadDate = 58 | playerResponse.UploadDate 59 | ?? watchPage.UploadDate 60 | ?? throw new YoutubeExplodeException("Failed to extract the video upload date."); 61 | 62 | var thumbnails = playerResponse 63 | .Thumbnails.Select(t => 64 | { 65 | var thumbnailUrl = 66 | t.Url 67 | ?? throw new YoutubeExplodeException("Failed to extract the thumbnail URL."); 68 | 69 | var thumbnailWidth = 70 | t.Width 71 | ?? throw new YoutubeExplodeException("Failed to extract the thumbnail width."); 72 | 73 | var thumbnailHeight = 74 | t.Height 75 | ?? throw new YoutubeExplodeException("Failed to extract the thumbnail height."); 76 | 77 | var thumbnailResolution = new Resolution(thumbnailWidth, thumbnailHeight); 78 | 79 | return new Thumbnail(thumbnailUrl, thumbnailResolution); 80 | }) 81 | .Concat(Thumbnail.GetDefaultSet(videoId)) 82 | .ToArray(); 83 | 84 | return new Video( 85 | videoId, 86 | title, 87 | new Author(channelId, channelTitle), 88 | uploadDate, 89 | playerResponse.Description ?? "", 90 | playerResponse.Duration, 91 | thumbnails, 92 | playerResponse.Keywords, 93 | // Engagement statistics may be hidden 94 | new Engagement( 95 | playerResponse.ViewCount ?? 0, 96 | watchPage.LikeCount ?? 0, 97 | watchPage.DislikeCount ?? 0 98 | ) 99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /YoutubeExplode.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 16 3 | VisualStudioVersion = 16.0.28803.352 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{2D3632A9-3DE4-4E64-AC3E-94810F2F55D0}" 6 | ProjectSection(SolutionItems) = preProject 7 | License.txt = License.txt 8 | Readme.md = Readme.md 9 | Directory.Build.props = Directory.Build.props 10 | global.json = global.json 11 | NuGet.config = NuGet.config 12 | EndProjectSection 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YoutubeExplode.Demo.Cli", "YoutubeExplode.Demo.Cli\YoutubeExplode.Demo.Cli.csproj", "{6983B0A4-44B0-4FAF-81C7-94CC784570F0}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YoutubeExplode.Tests", "YoutubeExplode.Tests\YoutubeExplode.Tests.csproj", "{5E5CDC04-EA05-46CC-8D72-970F0F0E2CD1}" 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YoutubeExplode", "YoutubeExplode\YoutubeExplode.csproj", "{2019E360-0E05-4824-9EDE-7723AF45E10C}" 19 | EndProject 20 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YoutubeExplode.Demo.Gui", "YoutubeExplode.Demo.Gui\YoutubeExplode.Demo.Gui.csproj", "{21F838B4-11B6-4C54-957A-9B7EFDE8F19E}" 21 | EndProject 22 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YoutubeExplode.Converter", "YoutubeExplode.Converter\YoutubeExplode.Converter.csproj", "{CF1CA267-32A2-4E91-8207-8FA9C697BC8B}" 23 | EndProject 24 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YoutubeExplode.Converter.Tests", "YoutubeExplode.Converter.Tests\YoutubeExplode.Converter.Tests.csproj", "{DF44A908-CC76-406C-AA7B-9E9C03FE97D2}" 25 | EndProject 26 | Global 27 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 28 | Debug|Any CPU = Debug|Any CPU 29 | Release|Any CPU = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 32 | {6983B0A4-44B0-4FAF-81C7-94CC784570F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {6983B0A4-44B0-4FAF-81C7-94CC784570F0}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {6983B0A4-44B0-4FAF-81C7-94CC784570F0}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {6983B0A4-44B0-4FAF-81C7-94CC784570F0}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {5E5CDC04-EA05-46CC-8D72-970F0F0E2CD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {5E5CDC04-EA05-46CC-8D72-970F0F0E2CD1}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {5E5CDC04-EA05-46CC-8D72-970F0F0E2CD1}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {5E5CDC04-EA05-46CC-8D72-970F0F0E2CD1}.Release|Any CPU.Build.0 = Release|Any CPU 40 | {2019E360-0E05-4824-9EDE-7723AF45E10C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {2019E360-0E05-4824-9EDE-7723AF45E10C}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {2019E360-0E05-4824-9EDE-7723AF45E10C}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {2019E360-0E05-4824-9EDE-7723AF45E10C}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {21F838B4-11B6-4C54-957A-9B7EFDE8F19E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {21F838B4-11B6-4C54-957A-9B7EFDE8F19E}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {21F838B4-11B6-4C54-957A-9B7EFDE8F19E}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {21F838B4-11B6-4C54-957A-9B7EFDE8F19E}.Release|Any CPU.Build.0 = Release|Any CPU 48 | {CF1CA267-32A2-4E91-8207-8FA9C697BC8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 49 | {CF1CA267-32A2-4E91-8207-8FA9C697BC8B}.Debug|Any CPU.Build.0 = Debug|Any CPU 50 | {CF1CA267-32A2-4E91-8207-8FA9C697BC8B}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {CF1CA267-32A2-4E91-8207-8FA9C697BC8B}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {DF44A908-CC76-406C-AA7B-9E9C03FE97D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 53 | {DF44A908-CC76-406C-AA7B-9E9C03FE97D2}.Debug|Any CPU.Build.0 = Debug|Any CPU 54 | {DF44A908-CC76-406C-AA7B-9E9C03FE97D2}.Release|Any CPU.ActiveCfg = Release|Any CPU 55 | {DF44A908-CC76-406C-AA7B-9E9C03FE97D2}.Release|Any CPU.Build.0 = Release|Any CPU 56 | EndGlobalSection 57 | GlobalSection(SolutionProperties) = preSolution 58 | HideSolutionNode = FALSE 59 | EndGlobalSection 60 | GlobalSection(ExtensibilityGlobals) = postSolution 61 | SolutionGuid = {DEA5BDC6-C56D-4AC1-A8C6-2C64A5A75FDD} 62 | EndGlobalSection 63 | EndGlobal 64 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/SearchSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using FluentAssertions; 5 | using Xunit; 6 | using YoutubeExplode.Common; 7 | 8 | namespace YoutubeExplode.Tests; 9 | 10 | public class SearchSpecs 11 | { 12 | [Fact] 13 | public async Task I_can_get_results_from_a_search_query() 14 | { 15 | // Arrange 16 | using var youtube = new YoutubeClient(); 17 | 18 | // Act 19 | var results = await youtube.Search.GetResultsAsync("undead corporation"); 20 | 21 | // Assert 22 | results.Should().HaveCountGreaterThanOrEqualTo(50); 23 | results 24 | .Should() 25 | .Contain(r => 26 | r.Title.Contains("undead corporation", StringComparison.OrdinalIgnoreCase) 27 | ); 28 | } 29 | 30 | [Fact] 31 | public async Task I_can_get_results_from_a_search_query_that_contains_special_characters() 32 | { 33 | // Arrange 34 | using var youtube = new YoutubeClient(); 35 | 36 | // Act 37 | var results = await youtube.Search.GetResultsAsync("\"dune 2\" ending"); 38 | 39 | // Assert 40 | results.Should().HaveCountGreaterThanOrEqualTo(50); 41 | results.Should().Contain(r => r.Title.Contains("dune", StringComparison.OrdinalIgnoreCase)); 42 | } 43 | 44 | [Fact] 45 | public async Task I_can_get_results_from_a_search_query_that_contains_non_ascii_characters() 46 | { 47 | // https://github.com/Tyrrrz/YoutubeExplode/issues/787 48 | 49 | // Arrange 50 | using var youtube = new YoutubeClient(); 51 | 52 | // Act 53 | var results = await youtube.Search.GetResultsAsync("נועה קירל"); 54 | 55 | // Assert 56 | results.Should().HaveCountGreaterThanOrEqualTo(50); 57 | results 58 | .Should() 59 | .Contain(r => r.Title.Contains("נועה קירל", StringComparison.OrdinalIgnoreCase)); 60 | } 61 | 62 | [Fact] 63 | public async Task I_can_get_results_from_a_search_query_that_contains_non_ascii_characters_and_special_characters() 64 | { 65 | // https://github.com/Tyrrrz/YoutubeExplode/issues/787 66 | 67 | // Arrange 68 | using var youtube = new YoutubeClient(); 69 | 70 | // Act 71 | var results = await youtube.Search.GetResultsAsync("\"נועה קירל\""); 72 | 73 | // Assert 74 | results.Should().HaveCountGreaterThanOrEqualTo(50); 75 | results 76 | .Should() 77 | .Contain(r => r.Title.Contains("נועה קירל", StringComparison.OrdinalIgnoreCase)); 78 | } 79 | 80 | [Fact] 81 | public async Task I_can_get_video_results_from_a_search_query() 82 | { 83 | // Arrange 84 | using var youtube = new YoutubeClient(); 85 | 86 | // Act 87 | var videos = await youtube.Search.GetVideosAsync("undead corporation"); 88 | 89 | // Assert 90 | videos.Should().HaveCountGreaterThanOrEqualTo(50); 91 | } 92 | 93 | [Fact] 94 | public async Task I_can_get_playlist_results_from_a_search_query() 95 | { 96 | // Arrange 97 | using var youtube = new YoutubeClient(); 98 | 99 | // Act 100 | var playlists = await youtube.Search.GetPlaylistsAsync("undead corporation"); 101 | 102 | // Assert 103 | playlists.Should().NotBeEmpty(); 104 | 105 | var last = playlists.Last(); 106 | 107 | last.Title.Should().NotBeNullOrWhiteSpace(); 108 | last.Author.Should().NotBeNull(); 109 | last.Thumbnails.Should().NotBeEmpty(); 110 | 111 | var lastThumb = last.Thumbnails.Last(); 112 | lastThumb.Url.Should().NotBeNullOrWhiteSpace(); 113 | lastThumb.Resolution.Should().NotBeSameAs(default(Resolution)); 114 | } 115 | 116 | [Fact] 117 | public async Task I_can_get_channel_results_from_a_search_query() 118 | { 119 | // Arrange 120 | using var youtube = new YoutubeClient(); 121 | 122 | // Act 123 | var channels = await youtube.Search.GetChannelsAsync("undead corporation"); 124 | 125 | // Assert 126 | channels.Should().NotBeEmpty(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /YoutubeExplode/Channels/ChannelClient.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Globalization; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Text.RegularExpressions; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using YoutubeExplode.Bridge; 9 | using YoutubeExplode.Common; 10 | using YoutubeExplode.Exceptions; 11 | using YoutubeExplode.Playlists; 12 | using YoutubeExplode.Utils.Extensions; 13 | 14 | namespace YoutubeExplode.Channels; 15 | 16 | /// 17 | /// Operations related to YouTube channels. 18 | /// 19 | public class ChannelClient(HttpClient http) 20 | { 21 | private readonly ChannelController _controller = new(http); 22 | 23 | private Channel Get(ChannelPage channelPage) 24 | { 25 | var channelId = 26 | channelPage.Id 27 | ?? throw new YoutubeExplodeException("Failed to extract the channel ID."); 28 | 29 | var title = 30 | channelPage.Title 31 | ?? throw new YoutubeExplodeException("Failed to extract the channel title."); 32 | 33 | var logoUrl = 34 | channelPage.LogoUrl 35 | ?? throw new YoutubeExplodeException("Failed to extract the channel logo URL."); 36 | 37 | var logoSize = 38 | Regex 39 | .Matches(logoUrl, @"\bs(\d+)\b") 40 | .ToArray() 41 | .LastOrDefault() 42 | ?.Groups[1] 43 | .Value.NullIfWhiteSpace() 44 | ?.Pipe(s => 45 | int.TryParse(s, CultureInfo.InvariantCulture, out var result) 46 | ? result 47 | : (int?)null 48 | ) 49 | ?? 100; 50 | 51 | var thumbnails = new[] { new Thumbnail(logoUrl, new Resolution(logoSize, logoSize)) }; 52 | 53 | return new Channel(channelId, title, thumbnails); 54 | } 55 | 56 | /// 57 | /// Gets the metadata associated with the specified channel. 58 | /// 59 | public async ValueTask GetAsync( 60 | ChannelId channelId, 61 | CancellationToken cancellationToken = default 62 | ) 63 | { 64 | // Special case for the "Movies & TV" channel, which has a custom page 65 | if (channelId == "UCuVPpxrm2VAgpH3Ktln4HXg") 66 | { 67 | return new Channel( 68 | "UCuVPpxrm2VAgpH3Ktln4HXg", 69 | "Movies & TV", 70 | [ 71 | new Thumbnail( 72 | "https://www.gstatic.com/youtube/img/tvfilm/clapperboard_profile.png", 73 | new Resolution(1024, 1024) 74 | ), 75 | ] 76 | ); 77 | } 78 | 79 | return Get(await _controller.GetChannelPageAsync(channelId, cancellationToken)); 80 | } 81 | 82 | /// 83 | /// Gets the metadata associated with the channel of the specified user. 84 | /// 85 | public async ValueTask GetByUserAsync( 86 | UserName userName, 87 | CancellationToken cancellationToken = default 88 | ) => Get(await _controller.GetChannelPageAsync(userName, cancellationToken)); 89 | 90 | /// 91 | /// Gets the metadata associated with the channel identified by the specified slug or legacy custom URL. 92 | /// 93 | public async ValueTask GetBySlugAsync( 94 | ChannelSlug channelSlug, 95 | CancellationToken cancellationToken = default 96 | ) => Get(await _controller.GetChannelPageAsync(channelSlug, cancellationToken)); 97 | 98 | /// 99 | /// Gets the metadata associated with the channel identified by the specified handle or custom URL. 100 | /// 101 | public async ValueTask GetByHandleAsync( 102 | ChannelHandle channelHandle, 103 | CancellationToken cancellationToken = default 104 | ) => Get(await _controller.GetChannelPageAsync(channelHandle, cancellationToken)); 105 | 106 | /// 107 | /// Enumerates videos uploaded by the specified channel. 108 | /// 109 | // TODO: should return sequence instead (breaking change) 110 | public IAsyncEnumerable GetUploadsAsync( 111 | ChannelId channelId, 112 | CancellationToken cancellationToken = default 113 | ) 114 | { 115 | // Replace 'UC' in the channel ID with 'UU' 116 | var playlistId = "UU" + channelId.Value[2..]; 117 | return new PlaylistClient(http).GetVideosAsync(playlistId, cancellationToken); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /YoutubeExplode.Converter/Readme.md: -------------------------------------------------------------------------------- 1 | # YoutubeExplode.Converter 2 | 3 | [](https://nuget.org/packages/YoutubeExplode.Converter) 4 | [](https://nuget.org/packages/YoutubeExplode.Converter) 5 | 6 | **YoutubeExplode.Converter** is an extension package for **YoutubeExplode** that provides the capability to download YouTube videos into a single file by fetching individual streams and muxing them. 7 | This package relies on [FFmpeg](https://ffmpeg.org) under the hood. 8 | 9 | ## Install 10 | 11 | - 📦 [NuGet](https://nuget.org/packages/YoutubeExplode.Converter): `dotnet add package YoutubeExplode.Converter` 12 | 13 | > **Important**: 14 | > This package requires the [FFmpeg CLI](https://ffmpeg.org) to work, which can be downloaded [here](https://github.com/Tyrrrz/FFmpegBin/releases). 15 | > 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. 16 | 17 | ## Usage 18 | 19 | **YoutubeExplode.Converter** exposes its functionality by enhancing **YoutubeExplode**'s clients with additional extension methods. 20 | To use them, simply add the corresponding namespace and follow the examples below. 21 | 22 | ### Downloading videos 23 | 24 | You can download a video directly to a file through one of the extension methods provided on `VideoClient`. 25 | For example, to download a video in the specified format using the highest quality streams, simply call `DownloadAsync(...)` with the video ID and the destination path: 26 | 27 | ```csharp 28 | using YoutubeExplode; 29 | using YoutubeExplode.Converter; 30 | 31 | using var youtube = new YoutubeClient(); 32 | 33 | var videoUrl = "https://youtube.com/watch?v=u_yIGGhubZs"; 34 | await youtube.Videos.DownloadAsync(videoUrl, "video.mp4"); 35 | ``` 36 | 37 | Internally, this resolves the video's media streams, downloads the best candidates based on format, bitrate, framerate, and quality, and muxes them together into a single file. 38 | 39 | > [!NOTE] 40 | > If the specified output format is a known audio-only container (e.g. `mp3` or `ogg`) then only the audio stream is downloaded. 41 | 42 | > [!WARNING] 43 | > Stream muxing is a resource-intensive process, especially when transcoding is involved. 44 | > To avoid transcoding, consider specifying either `mp4` or `webm` for the output format, as these are the containers that YouTube uses for most of its streams. 45 | 46 | ### Customizing the conversion process 47 | 48 | To configure various aspects of the conversion process, use the following overload of `DownloadAsync(...)`: 49 | 50 | ```csharp 51 | using YoutubeExplode; 52 | using YoutubeExplode.Converter; 53 | 54 | using var youtube = new YoutubeClient(); 55 | var videoUrl = "https://youtube.com/watch?v=u_yIGGhubZs"; 56 | 57 | await youtube.Videos.DownloadAsync(videoUrl, "video.mp4", o => o 58 | .SetContainer("webm") // override format 59 | .SetPreset(ConversionPreset.UltraFast) // change preset 60 | .SetFFmpegPath("path/to/ffmpeg") // custom FFmpeg location 61 | .SetEnvironmentVariable("FFREPORT", "file=ffreport.log") // custom environment variable(s) passed to FFmpeg 62 | ); 63 | ``` 64 | 65 | ### Manually selecting streams 66 | 67 | If you need precise control over which streams are used for the muxing process, you can also provide them yourself instead of relying on the automatic resolution: 68 | 69 | ```csharp 70 | using YoutubeExplode; 71 | using YoutubeExplode.Videos.Streams; 72 | using YoutubeExplode.Converter; 73 | 74 | using var youtube = new YoutubeClient(); 75 | 76 | // Get stream manifest 77 | var videoUrl = "https://youtube.com/watch?v=u_yIGGhubZs"; 78 | var streamManifest = await youtube.Videos.Streams.GetManifestAsync(videoUrl); 79 | 80 | // Select best audio stream (highest bitrate) 81 | var audioStreamInfo = streamManifest 82 | .GetAudioStreams() 83 | .Where(s => s.Container == Container.Mp4) 84 | .GetWithHighestBitrate(); 85 | 86 | // Select best video stream (1080p60 in this example) 87 | var videoStreamInfo = streamManifest 88 | .GetVideoStreams() 89 | .Where(s => s.Container == Container.Mp4) 90 | .First(s => s.VideoQuality.Label == "1080p60"); 91 | 92 | // Download and mux streams into a single file 93 | await youtube.Videos.DownloadAsync( 94 | [audioStreamInfo, videoStreamInfo], 95 | new ConversionRequestBuilder("video.mp4").Build() 96 | ); 97 | ``` 98 | 99 | > [!WARNING] 100 | > Stream muxing is a resource-intensive process, especially when transcoding is involved. 101 | > To avoid transcoding, consider prioritizing streams that are already encoded in the desired format (e.g. `mp4` or `webm`). 102 | -------------------------------------------------------------------------------- /YoutubeExplode.Tests/VideoSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using FluentAssertions; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | using YoutubeExplode.Common; 7 | using YoutubeExplode.Exceptions; 8 | using YoutubeExplode.Tests.TestData; 9 | 10 | namespace YoutubeExplode.Tests; 11 | 12 | public class VideoSpecs(ITestOutputHelper testOutput) 13 | { 14 | [Fact] 15 | public async Task I_can_get_the_metadata_of_a_video() 16 | { 17 | // Arrange 18 | using var youtube = new YoutubeClient(); 19 | 20 | // Act 21 | var video = await youtube.Videos.GetAsync(VideoIds.Normal); 22 | 23 | // Assert 24 | video.Id.Value.Should().Be(VideoIds.Normal); 25 | video.Url.Should().NotBeNullOrWhiteSpace(); 26 | video.Title.Should().Be("PSY - GANGNAM STYLE(강남스타일) M/V"); 27 | video.Author.ChannelId.Value.Should().Be("UCrDkAvwZum-UTjHmzDI2iIw"); 28 | video.Author.ChannelUrl.Should().NotBeNullOrWhiteSpace(); 29 | video.Author.ChannelTitle.Should().Be("officialpsy"); 30 | video.UploadDate.Date.Should().Be(new DateTime(2012, 07, 15)); 31 | video.Description.Should().Contain("More about PSY@"); 32 | video.Duration.Should().BeCloseTo(TimeSpan.FromSeconds(252), TimeSpan.FromSeconds(1)); 33 | video.Thumbnails.Should().NotBeEmpty(); 34 | video 35 | .Keywords.Should() 36 | .BeEquivalentTo( 37 | "PSY", 38 | "싸이", 39 | "강남스타일", 40 | "뮤직비디오", 41 | "Music Video", 42 | "Gangnam Style", 43 | "KOREAN SINGER", 44 | "KPOP", 45 | "KOERAN WAVE", 46 | "PSY 6甲", 47 | "6th Studio Album", 48 | "싸이6집", 49 | "육갑", 50 | "Psy Gangnam Style" 51 | ); 52 | video.Engagement.ViewCount.Should().BeGreaterThanOrEqualTo(4_650_000_000); 53 | video.Engagement.LikeCount.Should().BeGreaterThanOrEqualTo(24_000_000); 54 | video.Engagement.DislikeCount.Should().BeGreaterThanOrEqualTo(0); 55 | video.Engagement.AverageRating.Should().BeGreaterThanOrEqualTo(0); 56 | } 57 | 58 | [Fact] 59 | public async Task I_can_try_to_get_the_metadata_of_a_video_and_get_an_error_if_it_is_private() 60 | { 61 | // Arrange 62 | using var youtube = new YoutubeClient(); 63 | 64 | // Act & assert 65 | var ex = await Assert.ThrowsAsync(async () => 66 | await youtube.Videos.GetAsync(VideoIds.Private) 67 | ); 68 | 69 | testOutput.WriteLine(ex.ToString()); 70 | } 71 | 72 | [Fact] 73 | public async Task I_can_try_to_get_the_metadata_of_a_video_and_get_an_error_if_it_does_not_exist() 74 | { 75 | // Arrange 76 | using var youtube = new YoutubeClient(); 77 | 78 | // Act & assert 79 | var ex = await Assert.ThrowsAsync(async () => 80 | await youtube.Videos.GetAsync(VideoIds.Deleted) 81 | ); 82 | 83 | testOutput.WriteLine(ex.ToString()); 84 | } 85 | 86 | [Theory] 87 | [InlineData(VideoIds.Normal)] 88 | [InlineData(VideoIds.Unlisted)] 89 | [InlineData(VideoIds.RequiresPurchaseDistributed)] 90 | [InlineData(VideoIds.EmbedRestrictedByYouTube)] 91 | [InlineData(VideoIds.EmbedRestrictedByAuthor)] 92 | [InlineData(VideoIds.ContentCheckViolent)] 93 | [InlineData(VideoIds.WithBrokenTitle)] 94 | public async Task I_can_get_the_metadata_of_any_available_video(string videoId) 95 | { 96 | // Arrange 97 | using var youtube = new YoutubeClient(); 98 | 99 | // Act 100 | var video = await youtube.Videos.GetAsync(videoId); 101 | 102 | // Assert 103 | video.Id.Value.Should().Be(videoId); 104 | video.Url.Should().NotBeNullOrWhiteSpace(); 105 | video.Title.Should().NotBeNull(); // empty titles are allowed 106 | video.Author.ChannelId.Value.Should().NotBeNullOrWhiteSpace(); 107 | video.Author.ChannelUrl.Should().NotBeNullOrWhiteSpace(); 108 | video.Author.ChannelTitle.Should().NotBeNullOrWhiteSpace(); 109 | video.UploadDate.Date.Should().NotBe(default); 110 | video.Description.Should().NotBeNull(); 111 | video.Duration.Should().NotBe(default); 112 | video.Thumbnails.Should().NotBeEmpty(); 113 | } 114 | 115 | [Fact] 116 | public async Task I_can_get_the_highest_resolution_thumbnail_from_a_video() 117 | { 118 | // Arrange 119 | using var youtube = new YoutubeClient(); 120 | 121 | // Act 122 | var video = await youtube.Videos.GetAsync(VideoIds.Normal); 123 | var thumbnail = video.Thumbnails.GetWithHighestResolution(); 124 | 125 | // Assert 126 | thumbnail.Url.Should().NotBeNullOrWhiteSpace(); 127 | } 128 | } 129 | --------------------------------------------------------------------------------
7 | /// Abstract result returned by a search query. 8 | /// Use pattern matching to handle specific instances of this type. 9 | ///
11 | /// Can be either one of the following: 12 | /// 13 | /// 14 | /// 15 | /// 16 | /// 17 | ///