diff --git a/.gitignore b/.gitignore index 48bf3e1..ed43fa5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ /YouTubeScraper/.vs/YouTubeScraper/v16 /YouTubeScraper/YouTubeScraper/bin/Debug/netstandard2.0 /YouTubeScraper/YouTubeScraper/obj +/YouTubeScraper/.vs/YouTubeScraper/DesignTimeBuild +/YouTubeScraper/.vs/YouTube.API/v16 +/YouTubeScraper/YouTube.API.Test/bin/Debug/netcoreapp3.0 +/YouTubeScraper/YouTube.API.Test/obj +/YouTubeScraper/YouTubeScraper/bin/Debug/netstandard2.1 diff --git a/YouTubeScraper/YouTube.API.Test/DashManifestTest.cs b/YouTubeScraper/YouTube.API.Test/DashManifestTest.cs new file mode 100644 index 0000000..3ace573 --- /dev/null +++ b/YouTubeScraper/YouTube.API.Test/DashManifestTest.cs @@ -0,0 +1,27 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using YouTube.Models; + +namespace YouTube.API.Test +{ + public class DashManifestTest + { + [SetUp] + public void Setup() + { + + } + + [Test] + public void ValidManifestTest() + { + YouTubeService service = new YouTubeService(); + IReadOnlyList manifests = service.DashManifests.List("VC5-YkjMHuw").Execute(); + foreach (var i in manifests) + Console.WriteLine(i.Label); + Assert.IsNotNull(manifests); + Assert.IsNotEmpty(manifests); + } + } +} \ No newline at end of file diff --git a/YouTubeScraper/YouTube.API.Test/VideoPlaybackTest.cs b/YouTubeScraper/YouTube.API.Test/VideoPlaybackTest.cs new file mode 100644 index 0000000..3a3af23 --- /dev/null +++ b/YouTubeScraper/YouTube.API.Test/VideoPlaybackTest.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Text; +using NUnit.Framework; +using YouTube.Models; + +namespace YouTube.API.Test +{ + public class VideoPlaybackTest + { + [Test] + public void ValidVideoPlaybackTest() + { + YouTubeService service = new YouTubeService(); + VideoPlayback info = service.VideoPlayback.List("VC5-YkjMHuw").Execute(); + Assert.NotNull(info); + } + } +} diff --git a/YouTubeScraper/YouTube.API.Test/YouTube.API.Test.csproj b/YouTubeScraper/YouTube.API.Test/YouTube.API.Test.csproj new file mode 100644 index 0000000..6983232 --- /dev/null +++ b/YouTubeScraper/YouTube.API.Test/YouTube.API.Test.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp3.0 + + false + + + + + + + + + + + + + diff --git a/YouTubeScraper/YouTubeScraper.sln b/YouTubeScraper/YouTube.API.sln similarity index 60% rename from YouTubeScraper/YouTubeScraper.sln rename to YouTubeScraper/YouTube.API.sln index 9fa488e..0df3625 100644 --- a/YouTubeScraper/YouTubeScraper.sln +++ b/YouTubeScraper/YouTube.API.sln @@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29424.173 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YouTubeScraper", "YouTubeScraper\YouTubeScraper.csproj", "{F7E1AD03-B67C-4C79-BE84-682490ED05C5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YouTube.API", "YouTubeScraper\YouTube.API.csproj", "{F7E1AD03-B67C-4C79-BE84-682490ED05C5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YouTube.API.Test", "YouTube.API.Test\YouTube.API.Test.csproj", "{D5E35A2A-03CA-4A5B-9F12-80E078356340}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +17,10 @@ Global {F7E1AD03-B67C-4C79-BE84-682490ED05C5}.Debug|Any CPU.Build.0 = Debug|Any CPU {F7E1AD03-B67C-4C79-BE84-682490ED05C5}.Release|Any CPU.ActiveCfg = Release|Any CPU {F7E1AD03-B67C-4C79-BE84-682490ED05C5}.Release|Any CPU.Build.0 = Release|Any CPU + {D5E35A2A-03CA-4A5B-9F12-80E078356340}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5E35A2A-03CA-4A5B-9F12-80E078356340}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5E35A2A-03CA-4A5B-9F12-80E078356340}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5E35A2A-03CA-4A5B-9F12-80E078356340}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/YouTubeScraper/YouTubeScraper/Assets/DashManifestTemplate.xml b/YouTubeScraper/YouTubeScraper/Assets/DashManifestTemplate.xml new file mode 100644 index 0000000..3900b6f --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Assets/DashManifestTemplate.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/YouTubeScraper/YouTubeScraper/Authorization/AuthorizationHelpers.cs b/YouTubeScraper/YouTubeScraper/Authorization/AuthorizationHelpers.cs new file mode 100644 index 0000000..f7a82f1 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Authorization/AuthorizationHelpers.cs @@ -0,0 +1,71 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Auth.OAuth2.Flows; +using Google.Apis.Auth.OAuth2.Responses; +using Google.Apis.Oauth2.v2; +using Google.Apis.YouTube.v3; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace YouTube.Authorization +{ + public static class AuthorizationHelpers + { + public static Uri Endpoint => "https://accounts.google.com/o/oauth2/approval".ToUri(); + public static string RedirectUrl => Uri.EscapeDataString(redirectUrl); + + const string tokenEndpoint = "https://www.googleapis.com/oauth2/v4/token"; + const string redirectUrl = "urn:ietf:wg:oauth:2.0:oob"; + + public static Uri FormQueryString(ClientSecrets clientSecrets, params string[] scopes) + { + string clientId = Uri.EscapeDataString(clientSecrets.ClientId); + string scopeStr = string.Join(' ', scopes); + + return $"https://accounts.google.com/o/oauth2/auth?client_id={clientId}&redirect_uri={RedirectUrl}&response_type=code&scope={scopeStr}".ToUri(); + } + + public static async Task ExchangeToken(ClientSecrets clientSecrets, string responseToken) + { + using HttpClient client = new HttpClient(); + + Dictionary requestBody = new Dictionary + { + { "code", responseToken }, + { "redirect_uri", redirectUrl }, + { "grant_type", "authorization_code" }, + { "client_id", clientSecrets.ClientId }, + { "client_secret", clientSecrets.ClientSecret } + }; + + HttpResponseMessage response = await client.PostAsync(tokenEndpoint, new FormUrlEncodedContent(requestBody)); + + if (!response.IsSuccessStatusCode) + return null; + + string responseString = await response.Content.ReadAsStringAsync(); + dynamic responseData = JsonConvert.DeserializeObject(responseString); + + TokenResponse tokenResponse = new TokenResponse + { + AccessToken = responseData.access_token, + ExpiresInSeconds = responseData.expires_in, + RefreshToken = responseData.refresh_token, + Scope = responseData.scope, + TokenType = responseData.token_type + }; + + AuthorizationCodeFlow authorizationCodeFlow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer() + { + ClientSecrets = clientSecrets, + Scopes = responseData.scope.Split(' ') + }); + + return new UserCredential(authorizationCodeFlow, "user", tokenResponse); + } + } +} diff --git a/YouTubeScraper/YouTubeScraper/Extensions.cs b/YouTubeScraper/YouTubeScraper/Extensions.cs new file mode 100644 index 0000000..991fc65 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Extensions.cs @@ -0,0 +1,23 @@ +using System; + +namespace YouTube +{ + internal static class Extensions + { + internal static Uri ToUri(this string str) + { + try { return new Uri(str); } + catch { return null; } + } + + internal static int RangeOffset(int value, int min, int max) + { + if (value < min) + return -1; + else if (value > max) + return 1; + else + return 0; + } + } +} diff --git a/YouTubeScraper/YouTubeScraper/Generators/ManifestGenerator.cs b/YouTubeScraper/YouTubeScraper/Generators/ManifestGenerator.cs new file mode 100644 index 0000000..27cd179 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Generators/ManifestGenerator.cs @@ -0,0 +1,226 @@ +using AngleSharp.Html.Dom; +using AngleSharp.Html.Parser; +using Google.Apis.Services; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Xml; +using YouTube.Models; +using YoutubeExplode; +using YoutubeExplode.Models; +using YoutubeExplode.Models.MediaStreams; + +namespace YouTube.Generators +{ + internal class ManifestGenerator + { + IClientService ClientService { get; } + YoutubeClient Client { get; } + string Id { get; } + Video Meta { get; set; } + MediaStreamInfoSet UrlsSet { get; set; } + + public ManifestGenerator(IClientService service, string id) + { + ClientService = service; + Id = id; + Client = new YoutubeClient(service.HttpClient); + } + + public async Task> GenerateManifestsAsync() + { + Meta = await Client.GetVideoAsync(Id); + + if (Meta == null) + throw new FileNotFoundException("Video not found. Check video ID and visibility preferences"); + + UrlsSet = await Client.GetVideoMediaStreamInfosAsync(Id); + + if (!string.IsNullOrWhiteSpace(UrlsSet.HlsLiveStreamUrl)) + throw new NotSupportedException("This is livestream. Use 'YouTubeClient.VideoPlayback.List()' to get playback URLs"); + + List list = new List + { + await GenerateManifest("Auto") + }; + foreach (string i in UrlsSet.GetAllVideoQualityLabels()) + list.Add(await GenerateManifest(i)); + + return list.AsReadOnly(); + } + + async Task GenerateManifest(string quality) + { + XmlDocument manifest = new XmlDocument(); + manifest.LoadXml(Properties.Resources.DashManifestTemplate); + + manifest["MPD"].SetAttribute("mediaPresentationDuration", XmlConvert.ToString(Meta.Duration)); + + StreamInfo streamInfo = await GetInfoAsync(quality); + + foreach (var i in streamInfo.Video) + { + string rep = GetVideoRepresentation(i); + manifest.GetElementsByTagName("ContentComponent")[0].InnerXml += rep; + } + + foreach (var i in streamInfo.Audio) + manifest.GetElementsByTagName("ContentComponent")[1].InnerXml += GetAudioRepresentation(i); + + return new DashManifest(quality, manifest); + } + + string GetVideoRepresentation(StreamInfo.VideoInfo info) => + $@" + {WebUtility.UrlEncode(info.Url)} + + + + "; + + string GetAudioRepresentation(StreamInfo.AudioInfo info) => + $@" + {WebUtility.UrlEncode(info.Url)} + + + + "; + + async Task GetInfoAsync(string quality) + { + StreamInfo info = new StreamInfo(); + + string response = await ClientService.HttpClient.GetStringAsync($"https://youtube.com/watch?v={Id}&disable_polymer=true&bpctr=9999999999&hl=en"); + IHtmlDocument videoEmbedPageHtml = new HtmlParser().ParseDocument(response); + + #region I don't know what the fuck is this + string playerConfigRaw = Regex.Match(videoEmbedPageHtml.Source.Text, + @"ytplayer\.config = (?\{[^\{\}]*(((?\{)[^\{\}]*)+((?\})[^\{\}]*)+)*(?(Open)(?!))\})") + .Groups["Json"].Value; + JToken playerConfigJson = JToken.Parse(playerConfigRaw); + + var playerResponseRaw = playerConfigJson.SelectToken("args.player_response").Value(); + JToken playerResponseJson = JToken.Parse(playerResponseRaw); + string errorReason = playerResponseJson.SelectToken("playabilityStatus.reason")?.Value(); + if (!string.IsNullOrWhiteSpace(errorReason)) + throw new InvalidDataException($"Video [{Id}] is unplayable. Reason: {errorReason}"); + + List> adaptiveStreamInfosUrl = playerConfigJson.SelectToken("args.adaptive_fmts")?.Value().Split(',').Select(SplitQuery).ToList(); + List> video = + quality == "Auto" ? + adaptiveStreamInfosUrl.FindAll(i => i.ContainsKey("quality_label")) : + adaptiveStreamInfosUrl.FindAll(i => i.ContainsValue(quality.Substring(0, quality.IndexOf('p')))); + List> audio = adaptiveStreamInfosUrl.FindAll(i => i.ContainsKey("audio_sample_rate")); + #endregion + + foreach (var i in video) + info.Video.Add(new StreamInfo.VideoInfo + { + IndexRange = i["index"], + Url = i["url"], + Itag = i["itag"], + Fps = i["fps"], + Height = i["size"].Split('x')[1], + Width = i["size"].Split('x')[0], + Codecs = i["type"].Split('"')[1], + MimeType = i["type"].Split(';')[0], + Label = i["quality_label"] + }); + + foreach (var i in audio) + info.Audio.Add(new StreamInfo.AudioInfo + { + ChannelsCount = i["audio_channels"], + IndexRange = i["index"], + SampleRate = i["audio_sample_rate"], + Codecs = i["type"].Split('"')[1], + MimeType = i["type"].Split(';')[0], + Url = i["url"], + Itag = i["itag"] + }); + + return info; + } + + /// + /// I don't know what the fuck is this either + /// + public Dictionary SplitQuery(string query) + { + Dictionary dic = new Dictionary(StringComparer.OrdinalIgnoreCase); + string[] paramsEncoded = query.TrimStart('?').Split("&"); + foreach (string paramEncoded in paramsEncoded) + { + string param = WebUtility.UrlDecode(paramEncoded); + + // Look for the equals sign + int equalsPos = param.IndexOf('='); + if (equalsPos <= 0) + continue; + + // Get the key and value + string key = param.Substring(0, equalsPos); + string value = equalsPos < param.Length + ? param.Substring(equalsPos + 1) + : string.Empty; + + // Add to dictionary + dic[key] = value; + } + + return dic; + } + + string GetBandwidth(string quality) => + quality.Split('p')[0] switch + { + "4320" => "16763040‬", + "3072" => "11920384", + "2880" => "11175360", + "2160" => "8381520", + "1440" => "5587680‬", + "1080" => "4190760", + "720" => "2073921", + "480" => "869460", + "360" => "686521", + "240" => "264835", + _ => "100000", + }; + + class StreamInfo + { + public class VideoInfo + { + public string IndexRange { get; set; } + public string InitRange => $"0-{int.Parse(IndexRange.Split('-')[0]) - 1}"; + public string Itag { get; set; } + public string Fps { get; set; } + public string Url { get; set; } + public string Codecs { get; set; } + public string MimeType { get; set; } + public string Height { get; set; } + public string Width { get; set; } + public string Label { get; set; } + } + public class AudioInfo + { + public string IndexRange { get; set; } + public string InitRange => $"0-{int.Parse(IndexRange.Split('-')[0]) - 1}"; + public string SampleRate { get; set; } + public string ChannelsCount { get; set; } + public string Codecs { get; set; } + public string MimeType { get; set; } + public string Url { get; set; } + public string Itag { get; set; } + } + + public List Video { get; } = new List(); + public List Audio { get; } = new List(); + } + } +} diff --git a/YouTubeScraper/YouTubeScraper/Models/ClosedCaptionInfo.cs b/YouTubeScraper/YouTubeScraper/Models/ClosedCaptionInfo.cs new file mode 100644 index 0000000..01aa425 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Models/ClosedCaptionInfo.cs @@ -0,0 +1,11 @@ +using System.Globalization; + +namespace YouTube.Models +{ + public class ClosedCaptionInfo + { + public CultureInfo Language { get; set; } + public string Url { get; set; } + public bool AutoGenerated { get; set; } + } +} diff --git a/YouTubeScraper/YouTubeScraper/Models/DashManifest.cs b/YouTubeScraper/YouTubeScraper/Models/DashManifest.cs new file mode 100644 index 0000000..f09654d --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Models/DashManifest.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; +using System.Xml; + +namespace YouTube.Models +{ + public class DashManifest + { + public string Label { get; } + public DateTime ValidUntil { get; } + public DashManifest(string label, XmlDocument doc) + { + Label = label; + Xml = doc; + } + + public string ManifestContent => Xml.OuterXml; + public XmlDocument Xml { get; } + public Uri WriteManifest(FileStream outStream) + { + Xml.Save(outStream); + return new Uri(outStream.Name); + } + } +} diff --git a/YouTubeScraper/YouTubeScraper/Models/PlaybackUrl.cs b/YouTubeScraper/YouTubeScraper/Models/PlaybackUrl.cs new file mode 100644 index 0000000..e740d6b --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Models/PlaybackUrl.cs @@ -0,0 +1,48 @@ +using System.Drawing; + +namespace YouTube.Models +{ + public enum VideoFormat + { + /// + /// MPEG-4 Part 2. + /// + Mp4V = 0, + H263 = 1, + /// + /// MPEG-4 Part 10, H264, Advanced Video Coding (AVC). + /// + H264 = 2, + Vp8 = 3, + Vp9 = 4, + Av1 = 5 + } + public enum AudioFormat + { + /// + /// MPEG-4 Part 3, Advanced Audio Coding (AAC). + /// + Aac = 0, + Vorbis = 1, + Opus = 2 + } + public enum AudioQuality { Low, Medium, High } + + public class VideoPlaybackUrl + { + public string Quality { get; set; } + public VideoFormat Format { get; set; } + public string Url { get; set; } + public Size Resolution { get; set; } + public bool HasAudio { get; set; } + public int Bitrate { get; set; } + } + + public class AudioPlaybackUrl + { + public AudioQuality Quality { get; set; } + public AudioFormat Format { get; set; } + public string Url { get; set; } + public int Bitrate { get; set; } + } +} diff --git a/YouTubeScraper/YouTubeScraper/Models/VideoPlayback.cs b/YouTubeScraper/YouTubeScraper/Models/VideoPlayback.cs new file mode 100644 index 0000000..5aec033 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Models/VideoPlayback.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace YouTube.Models +{ + public class VideoPlayback + { + public string Id { get; set; } + public PlaybackUrlsData PlaybackUrls { get; set; } = new PlaybackUrlsData(); + public IReadOnlyList ClosedCaptions { get; set; } + + public class PlaybackUrlsData + { + public IReadOnlyList Video { get; set; } + public IReadOnlyList Audio { get; set; } + public string LiveStreamUrl { get; set; } + public DateTime ValidUntil { get; set; } + } + } +} diff --git a/YouTubeScraper/YouTubeScraper/Properties/Resources.Designer.cs b/YouTubeScraper/YouTubeScraper/Properties/Resources.Designer.cs new file mode 100644 index 0000000..9bf4505 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Properties/Resources.Designer.cs @@ -0,0 +1,86 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace YouTube.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("YouTube.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to <?xml version="1.0" encoding="utf-8" ?> + ///<MPD minBufferTime="PT2S" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" type="static"> + /// <Period> + /// <AdaptationSet> + /// <ContentComponent contentType="video" id="1"> + /// + /// </ContentComponent> + /// </AdaptationSet> + /// <AdaptationSet> + /// <ContentComponent contentType="audio" id="2"> + /// + /// </ContentComponent> + /// </AdaptationSet> + /// </Period> + ///</MPD>. + /// + internal static string DashManifestTemplate { + get { + return ResourceManager.GetString("DashManifestTemplate", resourceCulture); + } + } + } +} diff --git a/YouTubeScraper/YouTubeScraper/Properties/Resources.resx b/YouTubeScraper/YouTubeScraper/Properties/Resources.resx new file mode 100644 index 0000000..30fab28 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Properties/Resources.resx @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ..\assets\dashmanifesttemplate.xml;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8 + + \ No newline at end of file diff --git a/YouTubeScraper/YouTubeScraper/Resources/CaptionsResource.cs b/YouTubeScraper/YouTubeScraper/Resources/CaptionsResource.cs new file mode 100644 index 0000000..51fa553 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Resources/CaptionsResource.cs @@ -0,0 +1,11 @@ +using Google.Apis.Services; + +namespace YouTube.Resources +{ + public class CaptionsResource + { + IClientService Service { get; } + public CaptionsResource(IClientService service) => + Service = service; + } +} diff --git a/YouTubeScraper/YouTubeScraper/Resources/DashManifestsResource.cs b/YouTubeScraper/YouTubeScraper/Resources/DashManifestsResource.cs index b1b8c99..b54710b 100644 --- a/YouTubeScraper/YouTubeScraper/Resources/DashManifestsResource.cs +++ b/YouTubeScraper/YouTubeScraper/Resources/DashManifestsResource.cs @@ -1,16 +1,44 @@ -using System; +using Google.Apis.Services; using System.Collections.Generic; -using System.Text; +using System.Threading.Tasks; +using YouTube.Generators; +using YouTube.Models; -namespace YouTubeScraper.Resources +namespace YouTube.Resources { public class DashManifestsResource { - public class ListRequest { } + IClientService Service { get; } + public DashManifestsResource(IClientService service) => + Service = service; - public ListRequest List(string id) + public ListRequest List(string videoId) => + new ListRequest(Service, videoId); + + public class ListRequest { - return new ListRequest(); + public string Id { get; set; } + IClientService Service { get; set; } + + public ListRequest(IClientService service, string id) + { + Id = id; + Service = service; + } + + public async Task> ExecuteAsync() + { + ManifestGenerator generator = new ManifestGenerator(Service, Id); + + return await generator.GenerateManifestsAsync(); + } + + public IReadOnlyList Execute() + { + Task> task = ExecuteAsync(); + task.Wait(); + return task.Result; + } } } } diff --git a/YouTubeScraper/YouTubeScraper/Resources/HistoryResource.cs b/YouTubeScraper/YouTubeScraper/Resources/HistoryResource.cs index 4d37e92..5950d81 100644 --- a/YouTubeScraper/YouTubeScraper/Resources/HistoryResource.cs +++ b/YouTubeScraper/YouTubeScraper/Resources/HistoryResource.cs @@ -4,7 +4,7 @@ using System.Text; using Google.Apis.YouTube.v3; using Google.Apis.YouTube.v3.Data; -namespace YouTubeScraper.Resources +namespace YouTube.Resources { public class HistoryResource { diff --git a/YouTubeScraper/YouTubeScraper/Resources/VideoPlaybackResource.cs b/YouTubeScraper/YouTubeScraper/Resources/VideoPlaybackResource.cs new file mode 100644 index 0000000..8c88a00 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/Resources/VideoPlaybackResource.cs @@ -0,0 +1,110 @@ +using Google.Apis.Services; +using System.Collections.Generic; +using System.Drawing; +using System.Globalization; +using System.Threading.Tasks; +using YouTube.Models; +using YoutubeExplode; +using YoutubeExplode.Models.ClosedCaptions; +using YoutubeExplode.Models.MediaStreams; + +namespace YouTube.Resources +{ + public class VideoPlaybackResource + { + IClientService ClientService { get; } + public VideoPlaybackResource(IClientService clientService) => + ClientService = clientService; + + public ListRequest List(string videoId) => + new ListRequest(ClientService, videoId); + + public class ListRequest + { + IClientService Service { get; } + + public string Id { get; set; } + + public async Task ExecuteAsync() + { + VideoPlayback item = new VideoPlayback(); + YoutubeClient client = new YoutubeClient(Service.HttpClient); + MediaStreamInfoSet streamSet = await client.GetVideoMediaStreamInfosAsync(Id); + item.Id = Id; + item.PlaybackUrls.ValidUntil = streamSet.ValidUntil.DateTime; + + if(!string.IsNullOrWhiteSpace(streamSet.HlsLiveStreamUrl)) + { + item.PlaybackUrls.LiveStreamUrl = streamSet.HlsLiveStreamUrl; + return item; + } + + List audio = new List(); + foreach (AudioStreamInfo i in streamSet.Audio) + audio.Add(new AudioPlaybackUrl + { + Url = i.Url, + Bitrate = (int)i.Bitrate, + Format = (AudioFormat)i.AudioEncoding, + Quality = Extensions.RangeOffset((int)i.Bitrate / 1024, 128, 255) switch + { + -1 => AudioQuality.Low, + 1 => AudioQuality.High, + _ => AudioQuality.Medium + } + }); + item.PlaybackUrls.Audio = audio.AsReadOnly(); + + List video = new List(); + foreach (VideoStreamInfo i in streamSet.Video) + video.Add(new VideoPlaybackUrl + { + Format = (VideoFormat)i.VideoEncoding, + HasAudio = false, + Quality = i.VideoQualityLabel, + Url = i.Url, + Resolution = new Size(i.Resolution.Width, i.Resolution.Height), + Bitrate = (int)i.Bitrate + }); + + foreach (MuxedStreamInfo i in streamSet.Muxed) + video.Add(new VideoPlaybackUrl + { + Format = (VideoFormat)i.VideoEncoding, + HasAudio = true, + Quality = i.VideoQualityLabel, + Url = i.Url, + Resolution = new Size(i.Resolution.Width, i.Resolution.Height), + Bitrate = 0 + }); + item.PlaybackUrls.Video = video.AsReadOnly(); + + var ccSet = await client.GetVideoClosedCaptionTrackInfosAsync(Id); + + List captions = new List(); + foreach (ClosedCaptionTrackInfo i in ccSet) + captions.Add(new ClosedCaptionInfo + { + AutoGenerated = i.IsAutoGenerated, + Url = i.Url, + Language = new CultureInfo(i.Language.Code) + }); + item.ClosedCaptions = captions.AsReadOnly(); + + return item; + } + + public VideoPlayback Execute() + { + Task task = ExecuteAsync(); + task.Wait(); + return task.Result; + } + public ListRequest(IClientService service, string id) + { + Id = id; + Service = service; + } + } + } +} diff --git a/YouTubeScraper/YouTubeScraper/Resources/WatchLaterResource.cs b/YouTubeScraper/YouTubeScraper/Resources/WatchLaterResource.cs index 64c3786..6d3aa17 100644 --- a/YouTubeScraper/YouTubeScraper/Resources/WatchLaterResource.cs +++ b/YouTubeScraper/YouTubeScraper/Resources/WatchLaterResource.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Text; -namespace YouTubeScraper.Resources +namespace YouTube.Resources { public class WatchLaterResource { diff --git a/YouTubeScraper/YouTubeScraper/VideoQuality.cs b/YouTubeScraper/YouTubeScraper/VideoQuality.cs new file mode 100644 index 0000000..2938ed5 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/VideoQuality.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace YouTube +{ + public static class VideoQuality + { + public static string Auto => QualityConstants.Auto; + public static string Low144 => QualityConstants.Low144; + public static string Low240 => QualityConstants.Low240; + public static string Medium360 => QualityConstants.Medium360; + public static string Meduim480 => QualityConstants.Meduim480; + public static string High720 => QualityConstants.High720; + public static string High720p60 => QualityConstants.High720p60; + public static string High1080 => QualityConstants.High1080; + public static string High1080p60 => QualityConstants.High1080p60; + public static string High1440 => QualityConstants.High1440; + public static string High2160 => QualityConstants.High2160; + public static string High2880 => QualityConstants.High2880; + public static string High3072 => QualityConstants.High3072; + public static string High4320 => QualityConstants.High4320; + + public static class QualityConstants + { + public const string Auto = "auto"; + public const string Low144 = "144p"; + public const string Low240 = "240p"; + public const string Medium360 = "360p"; + public const string Meduim480 = "480p"; + public const string High720 = "720p"; + public const string High720p60 = "720p60"; + public const string High1080 = "1080p"; + public const string High1080p60 = "1080p60"; + public const string High1440 = "1440p"; + public const string High2160 = "2160p"; + public const string High2880 = "2880p"; + public const string High3072 = "3072p"; + public const string High4320 = "4320p"; + } + } +} diff --git a/YouTubeScraper/YouTubeScraper/YouTube.API.csproj b/YouTubeScraper/YouTubeScraper/YouTube.API.csproj new file mode 100644 index 0000000..af27b01 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/YouTube.API.csproj @@ -0,0 +1,43 @@ + + + + netstandard2.1 + YouTube.API + YouTube + + + + + + + + + + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + diff --git a/YouTubeScraper/YouTubeScraper/YouTubeScraper.cs b/YouTubeScraper/YouTubeScraper/YouTubeScraper.cs deleted file mode 100644 index 293ea26..0000000 --- a/YouTubeScraper/YouTubeScraper/YouTubeScraper.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Google.Apis.YouTube.v3; -using YouTubeScraper.Resources; - -namespace YouTubeScraper -{ - public class YouTubeScraper : YouTubeService - { - public HistoryResource History { get; } - public WatchLaterResource WatchLater { get; } - public DashManifestsResource DashManifests { get; set; } - // TODO: Add Activities override for recomendations and subscriptions - - public YouTubeScraper() - { - - } - } -} diff --git a/YouTubeScraper/YouTubeScraper/YouTubeScraper.csproj b/YouTubeScraper/YouTubeScraper/YouTubeScraper.csproj deleted file mode 100644 index d16b0df..0000000 --- a/YouTubeScraper/YouTubeScraper/YouTubeScraper.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - netstandard2.0 - - - - - - - - - - - - - diff --git a/YouTubeScraper/YouTubeScraper/YouTubeService.cs b/YouTubeScraper/YouTubeScraper/YouTubeService.cs new file mode 100644 index 0000000..c7f5aa8 --- /dev/null +++ b/YouTubeScraper/YouTubeScraper/YouTubeService.cs @@ -0,0 +1,20 @@ +using YouTube.Resources; + +namespace YouTube +{ + public partial class YouTubeService : Google.Apis.YouTube.v3.YouTubeService + { + public DashManifestsResource DashManifests => new DashManifestsResource(this); + public VideoPlaybackResource VideoPlayback => new VideoPlaybackResource(this); + public HistoryResource History { get; } + public WatchLaterResource WatchLater { get; } + // TODO: Add Activities override for recomendations and subscriptions and implementation of cc retrieval + + public YouTubeService() : base() + { + + } + + public YouTubeService(Initializer initializer) : base(initializer) { } + } +}