diff --git a/Manager.YouTube/Models/Innertube/ColorInfo.cs b/Manager.YouTube/Models/Innertube/ColorInfo.cs new file mode 100644 index 0000000..afbd20a --- /dev/null +++ b/Manager.YouTube/Models/Innertube/ColorInfo.cs @@ -0,0 +1,8 @@ +namespace Manager.YouTube.Models.Innertube; + +public class ColorInfo +{ + public string Primaries { get; set; } = ""; + public string TransferCharacteristics { get; set; } = ""; + public string MatrixCoefficients { get; set; } = ""; +} \ No newline at end of file diff --git a/Manager.YouTube/Models/Innertube/Range.cs b/Manager.YouTube/Models/Innertube/Range.cs new file mode 100644 index 0000000..38d6fdc --- /dev/null +++ b/Manager.YouTube/Models/Innertube/Range.cs @@ -0,0 +1,7 @@ +namespace Manager.YouTube.Models.Innertube; + +public class Range +{ + public uint Start { get; set; } + public uint End { get; set; } +} \ No newline at end of file diff --git a/Manager.YouTube/Models/Innertube/StoryBoard.cs b/Manager.YouTube/Models/Innertube/StoryBoard.cs new file mode 100644 index 0000000..d2b00af --- /dev/null +++ b/Manager.YouTube/Models/Innertube/StoryBoard.cs @@ -0,0 +1,8 @@ +namespace Manager.YouTube.Models.Innertube; + +public class StoryBoard +{ + public string Spec { get; set; } = ""; + public int RecommendedLevel { get; set; } + public int HighResolutionRecommendedLevel { get; set; } +} \ No newline at end of file diff --git a/Manager.YouTube/Models/Innertube/StreamingData.cs b/Manager.YouTube/Models/Innertube/StreamingData.cs new file mode 100644 index 0000000..a1135f6 --- /dev/null +++ b/Manager.YouTube/Models/Innertube/StreamingData.cs @@ -0,0 +1,10 @@ +namespace Manager.YouTube.Models.Innertube; + +public class StreamingData +{ + public DateTime FetchedUtc { get; set; } = DateTime.UtcNow; + public int ExpiresInSeconds { get; set; } + public string ServerAbrStreamingUrl { get; set; } = ""; + public List Formats { get; set; } = []; + public List AdaptiveFormats { get; set; } = []; +} \ No newline at end of file diff --git a/Manager.YouTube/Models/Innertube/StreamingFormat.cs b/Manager.YouTube/Models/Innertube/StreamingFormat.cs new file mode 100644 index 0000000..fab8557 --- /dev/null +++ b/Manager.YouTube/Models/Innertube/StreamingFormat.cs @@ -0,0 +1,30 @@ +namespace Manager.YouTube.Models.Innertube; + +public class StreamingFormat +{ + public int Itag { get; set; } + public string Url { get; set; } = ""; + public string MimeType { get; set; } = ""; + public uint Bitrate { get; set; } + public uint? Width { get; set; } + public uint? Height { get; set; } + public Range? InitRange { get; set; } + public Range? IndexRange { get; set; } + public long LastModified { get; set; } + public long ContentLength { get; set; } + public string Quality { get; set; } = ""; + public string? Xtags { get; set; } + public uint Fps { get; set; } + public string QualityLabel { get; set; } = ""; + public string ProjectionType { get; set; } = ""; + public uint? AverageBitrate { get; set; } + public bool? HighReplication { get; set; } + public ColorInfo? ColorInfo { get; set; } + public string? AudioQuality { get; set; } = ""; + public long ApproxDurationMs { get; set; } + public int? AudioSampleRate { get; set; } + public int? AudioChannels { get; set; } + public double? LoudnessDb { get; set; } + public bool? IsDrc { get; set; } + public string QualityOrdinal { get; set; } = ""; +} \ No newline at end of file diff --git a/Manager.YouTube/Models/Parser/YouTubeVideoData.cs b/Manager.YouTube/Models/Parser/YouTubeVideoData.cs new file mode 100644 index 0000000..190cea2 --- /dev/null +++ b/Manager.YouTube/Models/Parser/YouTubeVideoData.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Nodes; + +namespace Manager.YouTube.Models.Parser; + +public class YouTubeVideoData +{ + public JsonObject? YouTubePlayerData { get; set; } + public JsonObject? YouTubeInitialData { get; set; } +} \ No newline at end of file diff --git a/Manager.YouTube/Models/PlayerConfig.cs b/Manager.YouTube/Models/PlayerConfig.cs new file mode 100644 index 0000000..03f33ff --- /dev/null +++ b/Manager.YouTube/Models/PlayerConfig.cs @@ -0,0 +1,12 @@ +namespace Manager.YouTube.Models; + +public class PlayerConfig +{ + public double AudioLoudnessDb { get; set; } + public double AudioPerceptualLoudnessDb { get; set; } + public bool AudioEnablePerFormatLoudness { get; set; } + public uint MaxBitrate { get; set; } + public uint MaxReadAheadMediaTimeMs { get; set; } + public uint MinReadAheadMediaTimeMs { get; set; } + public uint ReadAheadGrowthRateMs { get; set; } +} \ No newline at end of file diff --git a/Manager.YouTube/Models/YouTubeVideo.cs b/Manager.YouTube/Models/YouTubeVideo.cs new file mode 100644 index 0000000..17e263a --- /dev/null +++ b/Manager.YouTube/Models/YouTubeVideo.cs @@ -0,0 +1,36 @@ +using Manager.YouTube.Models.Innertube; + +namespace Manager.YouTube.Models; + +public class YouTubeVideo +{ + public required string VideoId { get; set; } + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public string[] HashTags { get; set; } = []; + public long ViewCount { get; set; } + public long LikeCount { get; set; } + public string ChannelId { get; set; } = ""; + public string Author { get; set; } = ""; + public string PlayabilityStatus { get; set; } = ""; + public long LengthSeconds { get; set; } + public bool AllowRating { get; set; } + public bool IsCrawlable { get; set; } + public bool IsPrivate { get; set; } + public bool IsUnpluggedCorpus { get; set; } + public bool IsLive { get; set; } + public bool IsFamiliySave { get; set; } + public string[] AvailableCountries { get; set; } = []; + public bool IsUnlisted { get; set; } + public bool HasYpcMetadata { get; set; } + public DateTime PublishDate { get; set; } + public DateTime UploadDate { get; set; } + public bool IsShortsEligible { get; set; } + public string Category { get; set; } = ""; + + public StreamingData StreamingData { get; set; } = new(); + public List Thumbnails { get; set; } = []; + + public PlayerConfig? PlayerConfig { get; set; } + public StoryBoard? StoryBoard { get; set; } +} \ No newline at end of file diff --git a/Manager.YouTube/Parsers/HtmlParser.cs b/Manager.YouTube/Parsers/HtmlParser.cs index b00429c..cf22929 100644 --- a/Manager.YouTube/Parsers/HtmlParser.cs +++ b/Manager.YouTube/Parsers/HtmlParser.cs @@ -1,5 +1,7 @@ +using System.Text.Json.Nodes; using DotBased.Monads; using HtmlAgilityPack; +using Manager.YouTube.Models.Parser; namespace Manager.YouTube.Parsers; @@ -31,7 +33,57 @@ public static class HtmlParser return (json, isPremiumUser); } - + + public static Result GetVideoDataFromHtml(string html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return ResultError.Fail("html cannot be empty!"); + } + var htmlDocument = new HtmlDocument(); + htmlDocument.LoadHtml(html); + + const string initialYoutubeData = "var ytInitialPlayerResponse = {"; + var initialPlayerDataNode = htmlDocument.DocumentNode.SelectSingleNode($"//script[contains(., '{initialYoutubeData}')]"); + if (string.IsNullOrWhiteSpace(initialPlayerDataNode.InnerText)) + { + return ResultError.Fail("Could not find {initialPlayerData} in html script nodes!"); + } + var initialPlayerDataString = ExtractJson(initialPlayerDataNode.InnerText, "var ytInitialPlayerResponse = "); + if (string.IsNullOrWhiteSpace(initialPlayerDataString)) + { + return ResultError.Fail("Failed to extract initial player date from JSON."); + } + var parsedPlayerInitialData = JsonNode.Parse(initialPlayerDataString); + + const string initialData = "var ytInitialData = {"; + var initialDataNode = htmlDocument.DocumentNode.SelectSingleNode($"//script[contains(., '{initialData}')]"); + if (string.IsNullOrWhiteSpace(initialDataNode.InnerText)) + { + return ResultError.Fail("Could not find {initialData} in html script nodes!"); + } + + var initialDataJsonString = ExtractJson(initialDataNode.InnerText, "var ytInitialData = "); + if (string.IsNullOrWhiteSpace(initialDataJsonString)) + { + return ResultError.Fail("Failed to extract initial player date from JSON."); + } + var parsedInitialData = JsonNode.Parse(initialDataJsonString); + + try + { + return new YouTubeVideoData + { + YouTubePlayerData = parsedPlayerInitialData?.AsObject(), + YouTubeInitialData = parsedInitialData?.AsObject() + }; + } + catch (Exception e) + { + return ResultError.Error(e, "Could not parse youtube player data."); + } + } + static string? ExtractJson(string input, string marker) { var start = input.IndexOf(marker, StringComparison.Ordinal); diff --git a/Manager.YouTube/YouTubeClient.cs b/Manager.YouTube/YouTubeClient.cs index 3d65511..64115f2 100644 --- a/Manager.YouTube/YouTubeClient.cs +++ b/Manager.YouTube/YouTubeClient.cs @@ -83,7 +83,7 @@ public sealed class YouTubeClient : IDisposable if (string.IsNullOrWhiteSpace(State?.WebPlayerContextConfig?.WebPlayerContext?.DatasyncId)) { - var datasyncResult = await GetDatasyncIds(); + var datasyncResult = await GetDatasyncIdsAsync(); if (!datasyncResult.IsSuccess) { return datasyncResult; @@ -125,7 +125,24 @@ public sealed class YouTubeClient : IDisposable return result.Error ?? ResultError.Fail("Request failed!"); } - var clientStateResult = HtmlParser.GetStateJson(result.Value); + var stateResult = SetClientStateFromHtml(result.Value); + if (!stateResult.IsSuccess) + { + return stateResult; + } + + var cookieRotationResult = await RotateCookiesPageAsync(); + return !cookieRotationResult.IsSuccess ? cookieRotationResult : Result.Success(); + } + + private Result SetClientStateFromHtml(string html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return ResultError.Fail("HTML is empty!!"); + } + + var clientStateResult = HtmlParser.GetStateJson(html); if (clientStateResult is { IsSuccess: false, Error: not null }) { return clientStateResult.Error; @@ -146,11 +163,10 @@ public sealed class YouTubeClient : IDisposable } State.IsPremiumUser = clientStateResult.Value.Item2; - - var cookieRotationResult = await RotateCookiesPageAsync(); - return !cookieRotationResult.IsSuccess ? cookieRotationResult : Result.Success(); + + return Result.Success(); } - + public async Task> GetChannelByIdAsync(string channelId) { if (State == null) @@ -262,7 +278,7 @@ public sealed class YouTubeClient : IDisposable return JsonAccountParser.ParseAccountId(responseResult.Value); } - private async Task> GetDatasyncIds() + private async Task> GetDatasyncIdsAsync() { if (State is not { LoggedIn: true } || CookieContainer.Count == 0) { @@ -291,4 +307,38 @@ public sealed class YouTubeClient : IDisposable return ResultError.Fail("Failed to get datasyncIds! Client not logged in."); } + + public async Task GetVideoByIdAsync(string videoId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(videoId)) + { + return ResultError.Fail("Video id is empty!"); + } + + var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"{NetworkService.Origin}/watch?v={videoId}")); + + var response = await NetworkService.MakeRequestAsync(request, this); + if (!response.IsSuccess && !string.IsNullOrWhiteSpace(response.Value)) + { + return response.Error ?? ResultError.Fail("Request failed!"); + } + + var html = response.Value; + + var stateResult = SetClientStateFromHtml(html); + if (!stateResult.IsSuccess) + { + //TODO: Log warning: failed to update client state! + } + + var htmlParseReult = HtmlParser.GetVideoDataFromHtml(html); + if (!htmlParseReult.IsSuccess) + { + return htmlParseReult; + } + + //TODO: Process to YouTubeVideo model && decipher stream urls + + return Result.Success(); + } } \ No newline at end of file