[CHANGE] Implementing video fetching and deciphering

This commit is contained in:
max
2025-10-20 13:57:55 +02:00
parent 1555ae9f3d
commit 2b5e93ff8a
10 changed files with 230 additions and 8 deletions

View 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; } = "";
}

View File

@@ -0,0 +1,7 @@
namespace Manager.YouTube.Models.Innertube;
public class Range
{
public uint Start { get; set; }
public uint End { get; set; }
}

View 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; }
}

View 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; } = [];
}

View 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; } = "";
}

View 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; }
}

View 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; }
}

View 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; }
}

View File

@@ -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<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)
{
var start = input.IndexOf(marker, StringComparison.Ordinal);

View File

@@ -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<Result<InnertubeChannel>> GetChannelByIdAsync(string channelId)
{
if (State == null)
@@ -262,7 +278,7 @@ public sealed class YouTubeClient : IDisposable
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)
{
@@ -291,4 +307,38 @@ public sealed class YouTubeClient : IDisposable
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();
}
}