[CHANGE] Implementing video fetching and deciphering
This commit is contained in:
8
Manager.YouTube/Models/Innertube/ColorInfo.cs
Normal file
8
Manager.YouTube/Models/Innertube/ColorInfo.cs
Normal file
@@ -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; } = "";
|
||||||
|
}
|
||||||
7
Manager.YouTube/Models/Innertube/Range.cs
Normal file
7
Manager.YouTube/Models/Innertube/Range.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Manager.YouTube.Models.Innertube;
|
||||||
|
|
||||||
|
public class Range
|
||||||
|
{
|
||||||
|
public uint Start { get; set; }
|
||||||
|
public uint End { get; set; }
|
||||||
|
}
|
||||||
8
Manager.YouTube/Models/Innertube/StoryBoard.cs
Normal file
8
Manager.YouTube/Models/Innertube/StoryBoard.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
10
Manager.YouTube/Models/Innertube/StreamingData.cs
Normal file
10
Manager.YouTube/Models/Innertube/StreamingData.cs
Normal file
@@ -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<StreamingFormat> Formats { get; set; } = [];
|
||||||
|
public List<StreamingFormat> AdaptiveFormats { get; set; } = [];
|
||||||
|
}
|
||||||
30
Manager.YouTube/Models/Innertube/StreamingFormat.cs
Normal file
30
Manager.YouTube/Models/Innertube/StreamingFormat.cs
Normal file
@@ -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; } = "";
|
||||||
|
}
|
||||||
9
Manager.YouTube/Models/Parser/YouTubeVideoData.cs
Normal file
9
Manager.YouTube/Models/Parser/YouTubeVideoData.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
12
Manager.YouTube/Models/PlayerConfig.cs
Normal file
12
Manager.YouTube/Models/PlayerConfig.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
36
Manager.YouTube/Models/YouTubeVideo.cs
Normal file
36
Manager.YouTube/Models/YouTubeVideo.cs
Normal file
@@ -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<WebImage> Thumbnails { get; set; } = [];
|
||||||
|
|
||||||
|
public PlayerConfig? PlayerConfig { get; set; }
|
||||||
|
public StoryBoard? StoryBoard { get; set; }
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
using DotBased.Monads;
|
using DotBased.Monads;
|
||||||
using HtmlAgilityPack;
|
using HtmlAgilityPack;
|
||||||
|
using Manager.YouTube.Models.Parser;
|
||||||
|
|
||||||
namespace Manager.YouTube.Parsers;
|
namespace Manager.YouTube.Parsers;
|
||||||
|
|
||||||
@@ -31,7 +33,57 @@ public static class HtmlParser
|
|||||||
|
|
||||||
return (json, isPremiumUser);
|
return (json, isPremiumUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Result<YouTubeVideoData> 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)
|
static string? ExtractJson(string input, string marker)
|
||||||
{
|
{
|
||||||
var start = input.IndexOf(marker, StringComparison.Ordinal);
|
var start = input.IndexOf(marker, StringComparison.Ordinal);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ public sealed class YouTubeClient : IDisposable
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(State?.WebPlayerContextConfig?.WebPlayerContext?.DatasyncId))
|
if (string.IsNullOrWhiteSpace(State?.WebPlayerContextConfig?.WebPlayerContext?.DatasyncId))
|
||||||
{
|
{
|
||||||
var datasyncResult = await GetDatasyncIds();
|
var datasyncResult = await GetDatasyncIdsAsync();
|
||||||
if (!datasyncResult.IsSuccess)
|
if (!datasyncResult.IsSuccess)
|
||||||
{
|
{
|
||||||
return datasyncResult;
|
return datasyncResult;
|
||||||
@@ -125,7 +125,24 @@ public sealed class YouTubeClient : IDisposable
|
|||||||
return result.Error ?? ResultError.Fail("Request failed!");
|
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 })
|
if (clientStateResult is { IsSuccess: false, Error: not null })
|
||||||
{
|
{
|
||||||
return clientStateResult.Error;
|
return clientStateResult.Error;
|
||||||
@@ -146,11 +163,10 @@ public sealed class YouTubeClient : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
State.IsPremiumUser = clientStateResult.Value.Item2;
|
State.IsPremiumUser = clientStateResult.Value.Item2;
|
||||||
|
|
||||||
var cookieRotationResult = await RotateCookiesPageAsync();
|
return Result.Success();
|
||||||
return !cookieRotationResult.IsSuccess ? cookieRotationResult : Result.Success();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<InnertubeChannel>> GetChannelByIdAsync(string channelId)
|
public async Task<Result<InnertubeChannel>> GetChannelByIdAsync(string channelId)
|
||||||
{
|
{
|
||||||
if (State == null)
|
if (State == null)
|
||||||
@@ -262,7 +278,7 @@ public sealed class YouTubeClient : IDisposable
|
|||||||
return JsonAccountParser.ParseAccountId(responseResult.Value);
|
return JsonAccountParser.ParseAccountId(responseResult.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Result<string[]>> GetDatasyncIds()
|
private async Task<Result<string[]>> GetDatasyncIdsAsync()
|
||||||
{
|
{
|
||||||
if (State is not { LoggedIn: true } || CookieContainer.Count == 0)
|
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.");
|
return ResultError.Fail("Failed to get datasyncIds! Client not logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user