diff --git a/Manager.App/Components/Application/Dev/CipherDev.razor b/Manager.App/Components/Application/Dev/CipherDev.razor new file mode 100644 index 0000000..42d9609 --- /dev/null +++ b/Manager.App/Components/Application/Dev/CipherDev.razor @@ -0,0 +1,13 @@ +@using Manager.App.Models.System +@using Manager.App.Services.System + +@inject ISnackbar Snackbar +@inject ClientService ClientService + +Cipher manager + + + + Exec + \ No newline at end of file diff --git a/Manager.App/Components/Application/Dev/CipherDev.razor.cs b/Manager.App/Components/Application/Dev/CipherDev.razor.cs new file mode 100644 index 0000000..dd7d7a9 --- /dev/null +++ b/Manager.App/Components/Application/Dev/CipherDev.razor.cs @@ -0,0 +1,43 @@ +using Manager.App.Models.System; +using Manager.YouTube.Util.Cipher; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using MudBlazor; + +namespace Manager.App.Components.Application.Dev; + +public partial class CipherDev : ComponentBase +{ + private YouTubeClientItem? _selectedClient; + + private async Task ExecCipher(MouseEventArgs obj) + { + if (_selectedClient == null) + { + Snackbar.Add("No client selected", Severity.Warning); + return; + } + + var ytClientResult = await ClientService.LoadClientByIdAsync(_selectedClient.Id); + if (!ytClientResult.IsSuccess) + { + Snackbar.Add(ytClientResult.Error?.Description ?? "Failed to get the client!", Severity.Error); + return; + } + + var ytClient = ytClientResult.Value; + if (ytClient.State == null) + { + Snackbar.Add("Client state is null!", Severity.Warning); + return; + } + + var decoder = await CipherManager.GetDecoderAsync(ytClient.State, ytClient); + } + + private async Task> SearchClientsAsync(string? search, CancellationToken cancellationToken) + { + var searchResults = await ClientService.GetClientsAsync(search, cancellationToken: cancellationToken); + return !searchResults.IsSuccess ? [] : searchResults.Value; + } +} \ No newline at end of file diff --git a/Manager.App/Components/Application/Dev/DevelopmentVideo.razor b/Manager.App/Components/Application/Dev/DevelopmentVideo.razor new file mode 100644 index 0000000..97925bc --- /dev/null +++ b/Manager.App/Components/Application/Dev/DevelopmentVideo.razor @@ -0,0 +1,17 @@ +@using Manager.App.Models.System +@using Manager.App.Services.System + +@inject ISnackbar Snackbar +@inject ClientService ClientService +@inject NavigationManager NavigationManager + +Video data + + + + + + + Get data + \ No newline at end of file diff --git a/Manager.App/Components/Application/Dev/DevelopmentVideo.razor.cs b/Manager.App/Components/Application/Dev/DevelopmentVideo.razor.cs new file mode 100644 index 0000000..93bcb9b --- /dev/null +++ b/Manager.App/Components/Application/Dev/DevelopmentVideo.razor.cs @@ -0,0 +1,40 @@ +using Manager.App.Models.System; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using MudBlazor; + +namespace Manager.App.Components.Application.Dev; + +public partial class DevelopmentVideo : ComponentBase +{ + private YouTubeClientItem? _selectedClient; + private string _videoId = ""; + + private async Task> SearchClientsAsync(string? search, CancellationToken cancellationToken) + { + var searchResults = await ClientService.GetClientsAsync(search, cancellationToken: cancellationToken); + return !searchResults.IsSuccess ? [] : searchResults.Value; + } + + private void NavigateToVideo(MouseEventArgs obj) + { + if (_selectedClient == null) + { + Snackbar.Add("No client selected!", Severity.Warning); + return; + } + + if (string.IsNullOrWhiteSpace(_videoId)) + { + Snackbar.Add("No video ID set!", Severity.Warning); + return; + } + + if (_videoId.Length != 11) + { + Snackbar.Add("Video ID needs to have an length of 11 chars!", Severity.Warning); + } + + NavigationManager.NavigateTo($"/video/{_videoId}?clientId={_selectedClient.Id}"); + } +} \ No newline at end of file diff --git a/Manager.App/Components/Layout/NavMenu.razor b/Manager.App/Components/Layout/NavMenu.razor index dd3ae86..b8a7d3d 100644 --- a/Manager.App/Components/Layout/NavMenu.razor +++ b/Manager.App/Components/Layout/NavMenu.razor @@ -2,9 +2,10 @@ Home + Search Accounts Channels - Playlists + Playlists Info diff --git a/Manager.App/Components/Pages/Development.razor b/Manager.App/Components/Pages/Development.razor index 338665e..bc2da8f 100644 --- a/Manager.App/Components/Pages/Development.razor +++ b/Manager.App/Components/Pages/Development.razor @@ -6,4 +6,10 @@ + + + + + + \ No newline at end of file diff --git a/Manager.App/Components/Pages/Video.razor b/Manager.App/Components/Pages/Video.razor new file mode 100644 index 0000000..6463efd --- /dev/null +++ b/Manager.App/Components/Pages/Video.razor @@ -0,0 +1,244 @@ +@page "/Video/{VideoId}" +@using Manager.App.Services.System + +@inject ISnackbar Snackbar +@inject ClientService ClientService +@inject CacheService Cache + + +@if (!_loading && _video != null) +{ + + + @{ + var thumbnailUrl = _video.Thumbnails.OrderByDescending(t => t.Width).FirstOrDefault()?.Url; + } + @if (!string.IsNullOrWhiteSpace(thumbnailUrl)) + { + + } + + @_video.Title + @_video.Description + + + + + + @* Info *@ + + + + Video ID: + @_video.VideoId + + + Title: + @_video.Title + + + Description: + @_video.Description + + + HashTags: + @foreach (var hashtag in _video.HashTags) + { + @hashtag + } + + + + View count: + @_video.ViewCount + + + Like count: + @_video.LikeCount + + + Channel ID: + @_video.ChannelId + + + Author: + @_video.Author + + + Playability status: + @_video.PlayabilityStatus + + + Length seconds: + @_video.LengthSeconds + + + Keywords: + @foreach (var keyword in _video.Keywords) + { + @keyword + } + + + + Publish date: + @_video.PublishDate + + + Upload date: + @_video.UploadDate + + + Category: + @_video.Category + + + + @* Boolean values *@ + + + + Is owner viewing: + @_video.IsOwnerViewing + + + Allow rating: + @_video.AllowRating + + + Is crawlable: + @_video.IsCrawlable + + + Is private: + @_video.IsPrivate + + + Is unplugged corpus: + @_video.IsUnpluggedCorpus + + + Is live: + @_video.IsLive + + + Is family save: + @_video.IsFamilySave + + + Is unlisted: + @_video.IsUnlisted + + + Has Ypc metadata: + @_video.HasYpcMetadata + + + Is shorts eligible: + @_video.IsShortsEligible + + + + + + + @if (_video.StreamingData == null) + { + No streaming data available! + } + else + { + + + Adaptive Formats + + + Id + Mime type + Bitrate + Resolution + Last modified (UNIX epoch) + Quality + FPS + + + @context.Itag + @context.MimeType + @context.Bitrate + @($"{context.Width}x{context.Height}") + @context.LastModified + @context.Quality + @context.Fps + + + + + Formats + + + Id + Mime type + Bitrate + Resolution + Last modified (UNIX epoch) + Quality + FPS + + + @context.Itag + @context.MimeType + @context.Bitrate + @($"{context.Width}x{context.Height}") + @context.LastModified + @context.Quality + @context.Fps + + + + + } + + + @if (_video.PlayerConfig == null) + { + No player config available! + } + else + { + + + + Audio loudness DB: + @_video.PlayerConfig.AudioLoudnessDb + + + Audio perceptual loudness DB: + @_video.PlayerConfig.AudioPerceptualLoudnessDb + + + Audio enable per format loudness: + @_video.PlayerConfig.AudioLoudnessDb + + + Max bitrate: + @_video.PlayerConfig.MaxBitrate + + + Max read ahead time MS: + @_video.PlayerConfig.MaxReadAheadMediaTimeMs + + + Min read ahead time MS: + @_video.PlayerConfig.MinReadAheadMediaTimeMs + + + Read ahead growth rate MS: + @_video.PlayerConfig.ReadAheadGrowthRateMs + + + + } + + + +} \ No newline at end of file diff --git a/Manager.App/Components/Pages/Video.razor.cs b/Manager.App/Components/Pages/Video.razor.cs new file mode 100644 index 0000000..92efa72 --- /dev/null +++ b/Manager.App/Components/Pages/Video.razor.cs @@ -0,0 +1,48 @@ +using Manager.YouTube.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace Manager.App.Components.Pages; + +public partial class Video : ComponentBase +{ + [Parameter] + public required string VideoId { get; set; } + + [SupplyParameterFromQuery(Name = "clientId")] + public string ClientId { get; set; } = ""; + + private bool _loading = true; + private YouTubeVideo? _video; + + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrEmpty(VideoId)) + { + Snackbar.Add("Video id is null or empty!", Severity.Error); + _loading = false; + return; + } + + var clientResult = await ClientService.LoadClientByIdAsync(ClientId); + if (!clientResult.IsSuccess) + { + Snackbar.Add(clientResult.Error?.Description ?? "Failed to load client!", Severity.Error); + _loading = false; + return; + } + + var client = clientResult.Value; + + var videoResult = await client.GetVideoByIdAsync(VideoId); + if (!videoResult.IsSuccess) + { + Snackbar.Add(videoResult.Error?.Description ?? "Failed to get video.", Severity.Error); + _loading = false; + return; + } + + _video = videoResult.Value; + _loading = false; + } +} \ No newline at end of file diff --git a/Manager.App/Services/LibraryService.cs b/Manager.App/Services/LibraryService.cs index 30212b0..3f7fa02 100644 --- a/Manager.App/Services/LibraryService.cs +++ b/Manager.App/Services/LibraryService.cs @@ -16,7 +16,6 @@ namespace Manager.App.Services; public class LibraryService : ILibraryService { private readonly ILogger _logger; - private readonly LibrarySettings _librarySettings; private readonly IDbContextFactory _dbContextFactory; private readonly DirectoryInfo _libraryDirectory; private readonly CacheService _cacheService; @@ -24,13 +23,13 @@ public class LibraryService : ILibraryService public LibraryService(ILogger logger, IOptions librarySettings, IDbContextFactory contextFactory, CacheService cacheService) { _logger = logger; - _librarySettings = librarySettings.Value; + var librarySettings1 = librarySettings.Value; _dbContextFactory = contextFactory; _cacheService = cacheService; - _libraryDirectory = Directory.CreateDirectory(_librarySettings.Path); + _libraryDirectory = Directory.CreateDirectory(librarySettings1.Path); logger.LogDebug("Library directory: {LibraryWorkingDir}", _libraryDirectory.FullName); - Directory.CreateDirectory(Path.Combine(_librarySettings.Path, LibraryConstants.Directories.SubDirMedia)); - Directory.CreateDirectory(Path.Combine(_librarySettings.Path, LibraryConstants.Directories.SubDirChannels)); + Directory.CreateDirectory(Path.Combine(librarySettings1.Path, LibraryConstants.Directories.SubDirMedia)); + Directory.CreateDirectory(Path.Combine(librarySettings1.Path, LibraryConstants.Directories.SubDirChannels)); } private async Task AddWebImagesAsync(LibraryDbContext context, List images, string foreignKey, string libSubDir, string fileType, string subDir) @@ -102,6 +101,7 @@ public class LibraryService : ILibraryService } else { + context.HttpCookies.RemoveRange(context.HttpCookies.Where(x => x.ClientId == client.Id)); context.ClientAccounts.Add(dbClient); } @@ -144,9 +144,9 @@ public class LibraryService : ILibraryService try { await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var channel = await context.Channels + var channel = await context.Channels.AsSplitQuery() .Include(c => c.ClientAccount) - .ThenInclude(p => p!.HttpCookies) + .ThenInclude(p => p!.HttpCookies) .Include(f => f.Files) .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); diff --git a/Manager.App/Services/System/ClientService.cs b/Manager.App/Services/System/ClientService.cs index 4b3525d..2690b85 100644 --- a/Manager.App/Services/System/ClientService.cs +++ b/Manager.App/Services/System/ClientService.cs @@ -31,16 +31,15 @@ public class ClientService(IServiceScopeFactory scopeFactory, ILogger> GetClientsAsync(string search, int offset = 0, int limit = 10, CancellationToken cancellationToken = default) + public async Task> GetClientsAsync(string? search, int offset = 0, int limit = 10, CancellationToken cancellationToken = default) { if (_libraryService == null) { diff --git a/Manager.YouTube/Interpreter/JavaScriptEngineManager.cs b/Manager.YouTube/Interpreter/JavaScriptEngineManager.cs new file mode 100644 index 0000000..3d4197b --- /dev/null +++ b/Manager.YouTube/Interpreter/JavaScriptEngineManager.cs @@ -0,0 +1,58 @@ +using System.Text; +using DotBased.Monads; + +namespace Manager.YouTube.Interpreter; + +public static class JavaScriptEngineManager +{ + private static readonly PlayerEngineCollection Engines = []; + + public static async Task> GetPlayerEngine(string playerUrl) + { + if (string.IsNullOrEmpty(playerUrl)) + { + return ResultError.Fail("player url is empty or null!"); + } + + var version = GetScriptVersion(playerUrl); + + if (Engines.TryGetValue(version, out var engine)) + { + return engine; + } + + var playerJsSourceResult = await DownloadPlayerScriptAsync(playerUrl); + if (!playerJsSourceResult.IsSuccess) + { + return playerJsSourceResult.Error ?? ResultError.Fail("Download player script failed!"); + } + + return new PlayerEngine(version, playerJsSourceResult.Value); + } + + private static string GetScriptVersion(string relativePlayerUrl) + { + var split = relativePlayerUrl.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var v = split[2]; + var lang = split[4]; + return $"{v}-{lang}"; + } + + private static async Task> DownloadPlayerScriptAsync(string relativeUrl, YouTubeClient? client = null) + { + var downloadRequest = new HttpRequestMessage(HttpMethod.Get, new Uri($"{NetworkService.Origin}/{relativeUrl}")); + var downloadResponse = await NetworkService.DownloadBytesAsync(downloadRequest, client); + if (!downloadResponse.IsSuccess) + { + return downloadResponse.Error ?? ResultError.Fail($"Failed to download script from url: {relativeUrl}"); + } + + var playerJs = Encoding.UTF8.GetString(downloadResponse.Value.Data); + if (string.IsNullOrWhiteSpace(playerJs)) + { + return ResultError.Fail("Script value is empty!"); + } + + return playerJs; + } +} \ No newline at end of file diff --git a/Manager.YouTube/Interpreter/PlayerEngine.cs b/Manager.YouTube/Interpreter/PlayerEngine.cs new file mode 100644 index 0000000..2d35a33 --- /dev/null +++ b/Manager.YouTube/Interpreter/PlayerEngine.cs @@ -0,0 +1,42 @@ +using DotBased.Logging; +using Jint; + +namespace Manager.YouTube.Interpreter; + +public class PlayerEngine +{ + public string Version { get; set; } + public Engine JsEngine { get; set; } + private ILogger Logger { get; set; } + + public PlayerEngine(string version, string script) + { + if (string.IsNullOrEmpty(version)) + { + throw new ArgumentNullException(nameof(version)); + } + + if (string.IsNullOrEmpty(script)) + { + throw new ArgumentNullException(nameof(script)); + } + + Logger = LogService.RegisterLogger(typeof(PlayerEngine), version); + Version = version; + JsEngine = new Engine().Execute(script).SetValue("log", new Action(obj => + { + var logStr = obj.ToString(); + if (string.IsNullOrEmpty(logStr)) + { + return; + } + Logger.Information(logStr); + })); + + } + + public void InitializePlayer() + { + JsEngine.Execute("createPlayer"); + } +} \ No newline at end of file diff --git a/Manager.YouTube/Interpreter/PlayerEngineCollection.cs b/Manager.YouTube/Interpreter/PlayerEngineCollection.cs new file mode 100644 index 0000000..f15ab19 --- /dev/null +++ b/Manager.YouTube/Interpreter/PlayerEngineCollection.cs @@ -0,0 +1,11 @@ +using System.Collections.ObjectModel; + +namespace Manager.YouTube.Interpreter; + +public class PlayerEngineCollection : KeyedCollection +{ + protected override string GetKeyForItem(PlayerEngine item) + { + return item.Version; + } +} \ No newline at end of file diff --git a/Manager.YouTube/Manager.YouTube.csproj b/Manager.YouTube/Manager.YouTube.csproj index 4166dec..4bd844d 100644 --- a/Manager.YouTube/Manager.YouTube.csproj +++ b/Manager.YouTube/Manager.YouTube.csproj @@ -9,6 +9,7 @@ + diff --git a/Manager.YouTube/Models/Innertube/ColorInfo.cs b/Manager.YouTube/Models/Innertube/ColorInfo.cs index afbd20a..642fa7b 100644 --- a/Manager.YouTube/Models/Innertube/ColorInfo.cs +++ b/Manager.YouTube/Models/Innertube/ColorInfo.cs @@ -1,8 +1,13 @@ +using System.Text.Json.Serialization; + namespace Manager.YouTube.Models.Innertube; public class ColorInfo { + [JsonPropertyName("primaries")] public string Primaries { get; set; } = ""; + [JsonPropertyName("transferCharacteristics")] public string TransferCharacteristics { get; set; } = ""; + [JsonPropertyName("matrixCoefficients")] 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 index 38d6fdc..2f6c384 100644 --- a/Manager.YouTube/Models/Innertube/Range.cs +++ b/Manager.YouTube/Models/Innertube/Range.cs @@ -1,7 +1,11 @@ +using System.Text.Json.Serialization; + namespace Manager.YouTube.Models.Innertube; public class Range { + [JsonPropertyName("start")] public uint Start { get; set; } + [JsonPropertyName("end")] public uint End { get; set; } } \ No newline at end of file diff --git a/Manager.YouTube/Models/Innertube/StreamingData.cs b/Manager.YouTube/Models/Innertube/StreamingData.cs index a1135f6..4f39c6b 100644 --- a/Manager.YouTube/Models/Innertube/StreamingData.cs +++ b/Manager.YouTube/Models/Innertube/StreamingData.cs @@ -1,10 +1,18 @@ +using System.Text.Json.Serialization; +using Manager.YouTube.Util.Converters; + namespace Manager.YouTube.Models.Innertube; public class StreamingData { public DateTime FetchedUtc { get; set; } = DateTime.UtcNow; + [JsonPropertyName("expiresInSeconds")] public int ExpiresInSeconds { get; set; } + [JsonPropertyName("serverAbrStreamingUrl")] + [JsonConverter(typeof(JsonUrlEscapeConverter))] public string ServerAbrStreamingUrl { get; set; } = ""; + [JsonPropertyName("formats")] public List Formats { get; set; } = []; + [JsonPropertyName("adaptiveFormats")] 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 index 8d84adc..592738d 100644 --- a/Manager.YouTube/Models/Innertube/StreamingFormat.cs +++ b/Manager.YouTube/Models/Innertube/StreamingFormat.cs @@ -1,31 +1,59 @@ +using System.Text.Json.Serialization; + namespace Manager.YouTube.Models.Innertube; public class StreamingFormat { + [JsonPropertyName("itag")] public int Itag { get; set; } + [JsonPropertyName("url")] public string? Url { get; set; } + [JsonPropertyName("mimeType")] public string MimeType { get; set; } = ""; + [JsonPropertyName("bitrate")] public uint Bitrate { get; set; } + [JsonPropertyName("width")] public uint? Width { get; set; } + [JsonPropertyName("height")] public uint? Height { get; set; } + [JsonPropertyName("initRange")] public Range? InitRange { get; set; } + [JsonPropertyName("indexRange")] public Range? IndexRange { get; set; } + [JsonPropertyName("lastModified")] public long LastModified { get; set; } + [JsonPropertyName("contentLength")] public long ContentLength { get; set; } + [JsonPropertyName("quality")] public string Quality { get; set; } = ""; + [JsonPropertyName("xtags")] public string? Xtags { get; set; } + [JsonPropertyName("fps")] public uint Fps { get; set; } + [JsonPropertyName("qualityLabel")] public string QualityLabel { get; set; } = ""; + [JsonPropertyName("projectionType")] public string ProjectionType { get; set; } = ""; + [JsonPropertyName("averagebitrate")] public uint? AverageBitrate { get; set; } + [JsonPropertyName("highReplication")] public bool? HighReplication { get; set; } + [JsonPropertyName("colorInfo")] public ColorInfo? ColorInfo { get; set; } + [JsonPropertyName("audioQuality")] public string? AudioQuality { get; set; } = ""; + [JsonPropertyName("approxDurationMs")] public long ApproxDurationMs { get; set; } + [JsonPropertyName("audioSampleRate")] public int? AudioSampleRate { get; set; } + [JsonPropertyName("audioChannels")] public int? AudioChannels { get; set; } + [JsonPropertyName("loudnessDb")] public double? LoudnessDb { get; set; } + [JsonPropertyName("isDrc")] public bool? IsDrc { get; set; } + [JsonPropertyName("signatureCipher")] public string? SignatureCipher { get; set; } + [JsonPropertyName("qualityOrdinal")] public string QualityOrdinal { get; set; } = ""; } \ No newline at end of file diff --git a/Manager.YouTube/Models/Innertube/WebImage.cs b/Manager.YouTube/Models/Innertube/WebImage.cs index 37e282b..8cd8101 100644 --- a/Manager.YouTube/Models/Innertube/WebImage.cs +++ b/Manager.YouTube/Models/Innertube/WebImage.cs @@ -1,8 +1,13 @@ +using System.Text.Json.Serialization; + namespace Manager.YouTube.Models.Innertube; public class WebImage { + [JsonPropertyName("width")] public int Width { get; set; } + [JsonPropertyName("height")] public int Height { get; set; } + [JsonPropertyName("url")] public string Url { get; set; } = ""; } \ No newline at end of file diff --git a/Manager.YouTube/Models/Parser/YouTubeVideoData.cs b/Manager.YouTube/Models/Parser/YouTubeVideoData.cs index 190cea2..0aae6ce 100644 --- a/Manager.YouTube/Models/Parser/YouTubeVideoData.cs +++ b/Manager.YouTube/Models/Parser/YouTubeVideoData.cs @@ -4,6 +4,6 @@ namespace Manager.YouTube.Models.Parser; public class YouTubeVideoData { - public JsonObject? YouTubePlayerData { get; set; } - public JsonObject? YouTubeInitialData { get; set; } + public JsonNode? YouTubePlayerData { get; set; } + public JsonNode? YouTubeInitialData { get; set; } } \ No newline at end of file diff --git a/Manager.YouTube/Models/Playlist/PlaylistVideo.cs b/Manager.YouTube/Models/Playlist/PlaylistVideo.cs new file mode 100644 index 0000000..279c67b --- /dev/null +++ b/Manager.YouTube/Models/Playlist/PlaylistVideo.cs @@ -0,0 +1,13 @@ +using Manager.YouTube.Models.Innertube; + +namespace Manager.YouTube.Models.Playlist; + +public class PlaylistVideo +{ + public required string VideoId { get; set; } + public List Thumbnails { get; set; } = []; + public required string Title { get; set; } + public required string Author { get; set; } + public long LengthSeconds { get; set; } + public bool IsPlayable { get; set; } +} \ No newline at end of file diff --git a/Manager.YouTube/Models/YouTubePlaylist.cs b/Manager.YouTube/Models/YouTubePlaylist.cs new file mode 100644 index 0000000..2d22272 --- /dev/null +++ b/Manager.YouTube/Models/YouTubePlaylist.cs @@ -0,0 +1,19 @@ +using Manager.YouTube.Models.Playlist; + +namespace Manager.YouTube.Models; + +public class YouTubePlaylist +{ + public required string Id { get; set; } + public required string Title { get; set; } + public required string Description { get; set; } + public required string Owner { get; set; } + public required string OwnerId { get; set; } + public bool NoIndex { get; set; } + public bool Unlisted { get; set; } + public bool CanReorder { get; set; } + public bool IsEditable { get; set; } + public List Videos { get; set; } = []; + + public string? ContinuationToken { get; set; } +} \ No newline at end of file diff --git a/Manager.YouTube/NetworkService.cs b/Manager.YouTube/NetworkService.cs index 2353946..c045c7c 100644 --- a/Manager.YouTube/NetworkService.cs +++ b/Manager.YouTube/NetworkService.cs @@ -8,7 +8,7 @@ public static class NetworkService public const string Origin = "https://www.youtube.com"; private static readonly HttpClient HttpClient = new(); - public static async Task> MakeRequestAsync(HttpRequestMessage request, YouTubeClient client, bool skipAuthenticationHeader = false) + public static async Task> MakeRequestAsync(HttpRequestMessage request, YouTubeClient client, bool skipAuthenticationHeader = false, CancellationToken cancellationToken = default) { request.Headers.Add("Origin", Origin); request.Headers.UserAgent.ParseAdd(client.UserAgent); @@ -19,8 +19,8 @@ public static class NetworkService try { - var response = await client.HttpClient.SendAsync(request); - var contentString = await response.Content.ReadAsStringAsync(); + var response = await client.HttpClient.SendAsync(request, cancellationToken); + var contentString = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { return ResultError.Fail(contentString); diff --git a/Manager.YouTube/Parsers/HtmlParser.cs b/Manager.YouTube/Parsers/HtmlParser.cs index cf22929..7200aba 100644 --- a/Manager.YouTube/Parsers/HtmlParser.cs +++ b/Manager.YouTube/Parsers/HtmlParser.cs @@ -74,8 +74,8 @@ public static class HtmlParser { return new YouTubeVideoData { - YouTubePlayerData = parsedPlayerInitialData?.AsObject(), - YouTubeInitialData = parsedInitialData?.AsObject() + YouTubePlayerData = parsedPlayerInitialData, + YouTubeInitialData = parsedInitialData }; } catch (Exception e) diff --git a/Manager.YouTube/Parsers/Json/JsonParser.cs b/Manager.YouTube/Parsers/Json/JsonParser.cs index def4fa8..8b4130e 100644 --- a/Manager.YouTube/Parsers/Json/JsonParser.cs +++ b/Manager.YouTube/Parsers/Json/JsonParser.cs @@ -12,45 +12,58 @@ public static class JsonParser .Select(image => new WebImage { Width = image.GetProperty("width").GetInt32(), Height = image.GetProperty("height").GetInt32(), Url = image.GetProperty("url").GetString() ?? "" }) .ToList(); - public static string ExtractTextOrHtml(JsonElement element) + public static string ExtractTextOrHtml(JsonNode? node) { + if (node is not JsonObject nodeObj) + { + return ""; + } + // Case 1: Simple text (no formatting) - if (element.TryGetProperty("simpleText", out var simpleText)) - return simpleText.GetString() ?? string.Empty; + if (nodeObj.TryGetPropertyValue("simpleText", out var simpleText)) + return simpleText?.GetValue() ?? string.Empty; // Case 2: Runs (formatted text segments) - if (element.TryGetProperty("runs", out var runs) && runs.ValueKind == JsonValueKind.Array) + if (nodeObj.TryGetPropertyValue("runs", out var runs) && runs != null && runs.GetValueKind() == JsonValueKind.Array) { var sb = new StringBuilder(); - foreach (var run in runs.EnumerateArray()) + foreach (var runNode in runs.AsArray()) { - var text = run.GetProperty("text").GetString() ?? string.Empty; + if (runNode is not JsonObject run) + { + continue; + } + + var text = runNode["text"]?.GetValue() ?? string.Empty; var formatted = System.Net.WebUtility.HtmlEncode(text); - var bold = run.TryGetProperty("bold", out var boldProp) && boldProp.GetBoolean(); - var italic = run.TryGetProperty("italic", out var italicProp) && italicProp.GetBoolean(); - var underline = run.TryGetProperty("underline", out var underlineProp) && underlineProp.GetBoolean(); - var strikethrough = run.TryGetProperty("strikethrough", out var strikeProp) && strikeProp.GetBoolean(); + var bold = run.TryGetPropertyValue("bold", out var boldNode) && boldNode is JsonValue bv && bv.GetValue(); + + var italic = run.TryGetPropertyValue("italic", out var italicNode) && italicNode is JsonValue iv && iv.GetValue(); + + var underline = run.TryGetPropertyValue("underline", out var underlineNode) && underlineNode is JsonValue uv && uv.GetValue(); + + var strikethrough = run.TryGetPropertyValue("strikethrough", out var strikeNode) && strikeNode is JsonValue sv && sv.GetValue(); if (bold) formatted = $"{formatted}"; if (italic) formatted = $"{formatted}"; if (underline) formatted = $"{formatted}"; if (strikethrough) formatted = $"{formatted}"; - if (run.TryGetProperty("navigationEndpoint", out var nav) && - nav.TryGetProperty("url", out var urlProp)) + if (run.TryGetPropertyValue("navigationEndpoint", out var nav) && nav is JsonObject navObj && + navObj.TryGetPropertyValue("url", out var urlProp)) { - var url = urlProp.GetString(); + var url = urlProp?.GetValue(); if (!string.IsNullOrEmpty(url)) formatted = $"{formatted}"; } - if (run.TryGetProperty("emoji", out var emoji) && emoji.ValueKind == JsonValueKind.Object) + if (run.TryGetPropertyValue("emoji", out var emoji) && emoji is JsonObject emojiObj) { - if (emoji.TryGetProperty("url", out var emojiUrl)) + if (emojiObj.TryGetPropertyValue("url", out var emojiUrl)) { - var src = emojiUrl.GetString(); + var src = emojiUrl?.GetValue(); if (!string.IsNullOrEmpty(src)) formatted = $"\"{text}\""; } @@ -64,10 +77,15 @@ public static class JsonParser return string.Empty; } - - public static List ExtractWebImages(JsonElement element) + + public static List ExtractWebImages(JsonNode? node) { - var thumbnailsArray = element.GetProperty("thumbnail").GetProperty("thumbnails"); - return thumbnailsArray.Deserialize>() ?? []; + if (node == null) + { + return []; + } + + var thumbnailsArray = node["thumbnails"]; + return thumbnailsArray?.Deserialize>() ?? []; } } \ No newline at end of file diff --git a/Manager.YouTube/Parsers/Json/VideoJsonParser.cs b/Manager.YouTube/Parsers/Json/VideoJsonParser.cs index eadcecb..2f17fd9 100644 --- a/Manager.YouTube/Parsers/Json/VideoJsonParser.cs +++ b/Manager.YouTube/Parsers/Json/VideoJsonParser.cs @@ -14,7 +14,7 @@ public static class VideoJsonParser public static Result ParseVideoData(YouTubeVideoData videoData) { - if (videoData.YouTubeInitialData == null || videoData.YouTubeInitialData.Count == 0) + if (videoData.YouTubePlayerData == null) { return ResultError.Fail("No initial video data found!"); } @@ -22,7 +22,7 @@ public static class VideoJsonParser YouTubeVideo? video; try { - video = videoData.YouTubeInitialData.Deserialize(VideoParserOptions); + video = videoData.YouTubePlayerData.Deserialize(VideoParserOptions); } catch (Exception e) { diff --git a/Manager.YouTube/Util/Cipher/CipherDecoder.cs b/Manager.YouTube/Util/Cipher/CipherDecoder.cs index 1dd39f3..9f43ae2 100644 --- a/Manager.YouTube/Util/Cipher/CipherDecoder.cs +++ b/Manager.YouTube/Util/Cipher/CipherDecoder.cs @@ -8,7 +8,6 @@ namespace Manager.YouTube.Util.Cipher; public partial class CipherDecoder { public required string Version { get; init; } - public required string OriginalRelativeUrl { get; init; } public readonly IReadOnlySet Operations; private CipherDecoder(IEnumerable operations) @@ -25,7 +24,6 @@ public partial class CipherDecoder var operations = await GetCipherOperations(relativeUrl, client); var decoder = new CipherDecoder(operations) { - OriginalRelativeUrl = relativeUrl, Version = version }; return decoder; @@ -114,7 +112,7 @@ public partial class CipherDecoder } - [GeneratedRegex(@"(\w+)=function\(\w+\){(\w+)=\2\.split\(\x22{2}\);.*?return\s+\2\.join\(\x22{2}\)}")] + [GeneratedRegex(@"([A-Za-z_$][A-Za-z0-9_$]*)=function\([A-Za-z_$][A-Za-z0-9_$]*\)\{\s*([A-Za-z_$][A-Za-z0-9_$]*)=\2\.split\(\x22\x22\);[\s\S]*?return\s+\2\.join\(\x22\x22\)\s*\}")] private static partial Regex FunctionBodyRegex(); [GeneratedRegex("([\\$_\\w]+).\\w+\\(\\w+,\\d+\\);")] private static partial Regex DefinitionBodyRegex(); diff --git a/Manager.YouTube/Util/Cipher/CipherManager.cs b/Manager.YouTube/Util/Cipher/CipherManager.cs index 9900788..3a76d07 100644 --- a/Manager.YouTube/Util/Cipher/CipherManager.cs +++ b/Manager.YouTube/Util/Cipher/CipherManager.cs @@ -1,5 +1,6 @@ using DotBased.Logging; using DotBased.Monads; +using Manager.YouTube.Models.Innertube; namespace Manager.YouTube.Util.Cipher; @@ -8,9 +9,9 @@ public static class CipherManager private static readonly CipherDecoderCollection LoadedCiphers = []; private static readonly ILogger Logger = LogService.RegisterLogger(typeof(CipherManager)); - public static async Task> GetDecoderAsync(YouTubeClient client) + public static async Task> GetDecoderAsync(ClientState clientState, YouTubeClient? client = null) { - var relativePlayerJsUrl = client.State?.PlayerJsUrl; + var relativePlayerJsUrl = clientState.PlayerJsUrl; if (string.IsNullOrEmpty(relativePlayerJsUrl)) { return ResultError.Fail("Could not get player js url."); @@ -25,7 +26,7 @@ public static class CipherManager try { - var decoder = await CipherDecoder.CreateAsync(relativePlayerJsUrl, version); + var decoder = await CipherDecoder.CreateAsync(relativePlayerJsUrl, version, client); LoadedCiphers.Add(decoder); } catch (Exception e) @@ -38,7 +39,7 @@ public static class CipherManager private static string GetCipherVersion(string relativePlayerUrl) { - var split = relativePlayerUrl.Split('/'); + var split = relativePlayerUrl.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var v = split[2]; var lang = split[4]; return $"{v}_{lang}"; diff --git a/Manager.YouTube/Util/Converters/JsonUrlEscapeConverter.cs b/Manager.YouTube/Util/Converters/JsonUrlEscapeConverter.cs new file mode 100644 index 0000000..f5efd32 --- /dev/null +++ b/Manager.YouTube/Util/Converters/JsonUrlEscapeConverter.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Manager.YouTube.Util.Converters; + +public partial class JsonUrlEscapeConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var url = reader.GetString(); + if (string.IsNullOrWhiteSpace(url)) + { + return url; + } + + return UrlPatternRegex().IsMatch(url) ? Uri.UnescapeDataString(url) : url; + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } + + [GeneratedRegex("^(https?|ftp)://", RegexOptions.IgnoreCase | RegexOptions.Compiled, "nl-NL")] + private static partial Regex UrlPatternRegex(); +} \ No newline at end of file diff --git a/Manager.YouTube/Util/Converters/NumericJsonConverter.cs b/Manager.YouTube/Util/Converters/NumericJsonConverter.cs new file mode 100644 index 0000000..18e672e --- /dev/null +++ b/Manager.YouTube/Util/Converters/NumericJsonConverter.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Manager.YouTube.Util.Converters; + +public class NumericJsonConverter : JsonConverter where T : struct, IConvertible +{ + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + try + { + if (reader.TokenType == JsonTokenType.Number) + { + // Direct numeric value + return (T)Convert.ChangeType(reader.GetDouble(), typeof(T)); + } + + if (reader.TokenType == JsonTokenType.String) + { + var str = reader.GetString(); + if (string.IsNullOrWhiteSpace(str)) + throw new JsonException("Empty string cannot be converted to a number."); + + return (T)Convert.ChangeType(str, typeof(T)); + } + + throw new JsonException($"Unexpected token {reader.TokenType} for type {typeof(T)}."); + } + catch (Exception ex) + { + throw new JsonException($"Error converting value to {typeof(T)}.", ex); + } + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteNumberValue(Convert.ToDouble(value)); + } +} \ No newline at end of file diff --git a/Manager.YouTube/Util/Converters/YouTubeVideoJsonConverter.cs b/Manager.YouTube/Util/Converters/YouTubeVideoJsonConverter.cs index d7f73eb..f6eea63 100644 --- a/Manager.YouTube/Util/Converters/YouTubeVideoJsonConverter.cs +++ b/Manager.YouTube/Util/Converters/YouTubeVideoJsonConverter.cs @@ -1,5 +1,6 @@ using System.Runtime.Serialization; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using DotBased.Logging; using Manager.YouTube.Models; @@ -11,56 +12,74 @@ namespace Manager.YouTube.Util.Converters; public class YouTubeVideoJsonConverter : JsonConverter { private readonly ILogger _logger = LogService.RegisterLogger(); + private readonly JsonSerializerOptions _serializerOptions = new() + { + Converters = { + new NumericJsonConverter(), + new NumericJsonConverter(), + new NumericJsonConverter(), + new NumericJsonConverter(), + new NumericJsonConverter() }, + PropertyNameCaseInsensitive = true + }; public override YouTubeVideo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - using var document = JsonDocument.ParseValue(ref reader); - var root = document.RootElement; + var node = JsonNode.Parse(ref reader); + if (node == null) + { + throw new SerializationException("Failed to parse JSON reader."); + } - var playabilityStatus = root.GetProperty("playabilityStatus"); - var streamingData = root.GetProperty("streamingData"); - var videoDetails = root.GetProperty("videoDetails"); - var playerConfigJson = root.GetProperty("playerConfig"); - var microformat = root.GetProperty("microformat").GetProperty("playerMicroformatRenderer"); + var rootObject = node.AsObject(); - var videoId = videoDetails.GetProperty("videoId").GetString() ?? microformat.GetProperty("externalVideoId").GetString(); + var playabilityStatus = rootObject["playabilityStatus"]; + var streamingDataJson = rootObject["streamingData"]; + var videoDetails = rootObject["videoDetails"]; + var playerConfigJson = rootObject["playerConfig"]; + var microformat = rootObject["microformat"]?["playerMicroformatRenderer"]; + + var videoId = videoDetails?["videoId"]?.GetValue() ?? microformat?["externalVideoId"]?.GetValue(); if (string.IsNullOrEmpty(videoId)) { throw new SerializationException("Failed to get videoId"); } + + var thumbnails = JsonParser.ExtractWebImages(videoDetails?["thumbnail"]); + thumbnails.AddRange(JsonParser.ExtractWebImages(microformat?["thumbnail"])); - var thumbnails = JsonParser.ExtractWebImages(videoDetails.GetProperty("thumbnail")); - thumbnails.AddRange(JsonParser.ExtractWebImages(microformat.GetProperty("thumbnail"))); + var streamingData = streamingDataJson.Deserialize(_serializerOptions); + var playerConfig = ExtractPlayerConfig(playerConfigJson); var video = new YouTubeVideo { VideoId = videoId, - Title = JsonParser.ExtractTextOrHtml(microformat.GetProperty("title")), - Description = JsonParser.ExtractTextOrHtml(microformat.GetProperty("description")), - ViewCount = videoDetails.GetProperty("viewCount").GetInt32(), - LikeCount = videoDetails.GetProperty("likeCount").GetInt32(), - ChannelId = videoDetails.GetProperty("channelId").GetString() ?? "", - Author = JsonParser.ExtractTextOrHtml(videoDetails.GetProperty("author")), - PlayabilityStatus = playabilityStatus.GetProperty("status").GetString() ?? "", - LengthSeconds = videoDetails.GetProperty("lengthSeconds").GetInt32(), - Keywords = videoDetails.GetProperty("keywords").EnumerateArray().Select(v => v.GetString()).Cast().ToArray(), - IsOwnerViewing = videoDetails.GetProperty("isOwnerViewing").GetBoolean(), - AllowRating = videoDetails.GetProperty("allowRating").GetBoolean(), - IsCrawlable = videoDetails.GetProperty("isCrawlable").GetBoolean(), - IsPrivate = videoDetails.GetProperty("isPrivate").GetBoolean(), - IsUnpluggedCorpus = videoDetails.GetProperty("isUnpluggedCorpus").GetBoolean(), - IsLive = videoDetails.GetProperty("isLiveContent").GetBoolean(), - IsFamilySave = microformat.GetProperty("isFamilySave").GetBoolean(), - AvailableCountries = microformat.GetProperty("availableCountries").EnumerateArray().Select(v => v.GetString()).Cast().ToArray(), - IsUnlisted = microformat.GetProperty("isUnlisted").GetBoolean(), - HasYpcMetadata = microformat.GetProperty("hasYpcMetadata").GetBoolean(), - PublishDate = microformat.GetProperty("publishDate").GetDateTime(), - UploadDate = microformat.GetProperty("uploadDate").GetDateTime(), - IsShortsEligible = microformat.GetProperty("isShortsEligible").GetBoolean(), - Category = microformat.GetProperty("category").GetString() ?? "", - StreamingData = streamingData.Deserialize(), + Title = JsonParser.ExtractTextOrHtml(microformat?["title"]), + Description = JsonParser.ExtractTextOrHtml(microformat?["description"]), + ViewCount = long.TryParse(microformat?["viewCount"]?.GetValue(), out var viewCountParsed) ? viewCountParsed : -1, + LikeCount = long.TryParse(microformat?["likeCount"]?.GetValue(), out var likeCountParsed) ? likeCountParsed : -1, + ChannelId = videoDetails?["channelId"]?.GetValue() ?? "", + Author = videoDetails?["author"]?.GetValue() ?? "", + PlayabilityStatus = playabilityStatus?["status"]?.GetValue() ?? "", + LengthSeconds = long.TryParse(videoDetails?["lengthSeconds"]?.GetValue(), out var lengthSecondsParsed) ? lengthSecondsParsed : -1, + Keywords = videoDetails?["keywords"]?.AsArray().Select(v => v?.GetValue() ?? "").ToArray() ?? [], + IsOwnerViewing = videoDetails?["isOwnerViewing"]?.GetValue() ?? false, + AllowRating = videoDetails?["allowRating"]?.GetValue() ?? false, + IsCrawlable = videoDetails?["isCrawlable"]?.GetValue() ?? false, + IsPrivate = videoDetails?["isPrivate"]?.GetValue() ?? false, + IsUnpluggedCorpus = videoDetails?["isUnpluggedCorpus"]?.GetValue() ?? false, + IsLive = videoDetails?["isLiveContent"]?.GetValue() ?? false, + IsFamilySave = microformat?["isFamilySave"]?.GetValue() ?? false, + AvailableCountries = microformat?["availableCountries"]?.AsArray().Select(v => v?.GetValue() ?? "").ToArray() ?? [], + IsUnlisted = microformat?["isUnlisted"]?.GetValue() ?? false, + HasYpcMetadata = microformat?["hasYpcMetadata"]?.GetValue() ?? false, + PublishDate = DateTime.TryParse(microformat?["publishDate"]?.GetValue(), out var parsedPublishDate) ? parsedPublishDate : DateTime.MinValue, + UploadDate = DateTime.TryParse(microformat?["uploadDate"]?.GetValue(), out var parsedUploadDate) ? parsedUploadDate : DateTime.MinValue, + IsShortsEligible = microformat?["isShortsEligible"]?.GetValue() ?? false, + Category = microformat?["category"]?.GetValue() ?? "", + StreamingData = streamingData, Thumbnails = thumbnails, - PlayerConfig = ExtractPlayerConfig(playerConfigJson) + PlayerConfig = playerConfig }; return video; @@ -71,23 +90,25 @@ public class YouTubeVideoJsonConverter : JsonConverter throw new NotImplementedException("Converter only supports reading."); } - private PlayerConfig? ExtractPlayerConfig(JsonElement element) + private PlayerConfig? ExtractPlayerConfig(JsonNode? playerConfigNode) { + if (playerConfigNode == null) + { + return null; + } + try { + var playerConfigObj = playerConfigNode.AsObject(); var playerConfig = new PlayerConfig { - AudioLoudnessDb = element.GetProperty("audioConfig").GetProperty("loudnessDb").GetDouble(), - AudioPerceptualLoudnessDb = element.GetProperty("audioConfig").GetProperty("perceptualLoudnessDb").GetDouble(), - AudioEnablePerFormatLoudness = element.GetProperty("audioConfig").GetProperty("enablePerFormatLoudness") - .GetBoolean(), - MaxBitrate = element.GetProperty("streamSelectionConfig").GetProperty("maxBitrate").GetUInt32(), - MaxReadAheadMediaTimeMs = element.GetProperty("mediaCommonConfig").GetProperty("dynamicReadaheadConfig") - .GetProperty("maxReadAheadMediaTimeMs").GetUInt32(), - MinReadAheadMediaTimeMs = element.GetProperty("mediaCommonConfig").GetProperty("dynamicReadaheadConfig") - .GetProperty("minReadAheadMediaTimeMs").GetUInt32(), - ReadAheadGrowthRateMs = element.GetProperty("mediaCommonConfig").GetProperty("dynamicReadaheadConfig") - .GetProperty("readAheadGrowthRateMs").GetUInt32(), + AudioLoudnessDb = playerConfigObj["audioConfig"]?["loudnessDb"]?.GetValue() ?? 0, + AudioPerceptualLoudnessDb = playerConfigObj["audioConfig"]?["perceptualLoudnessDb"]?.GetValue() ?? 0, + AudioEnablePerFormatLoudness = playerConfigObj["audioConfig"]?["enablePerFormatLoudness"]?.GetValue() ?? false, + MaxBitrate = uint.TryParse(playerConfigObj["streamSelectionConfig"]?["maxBitrate"]?.GetValue(), out var parsedMaxBitrate) ? parsedMaxBitrate : 0, + MaxReadAheadMediaTimeMs = playerConfigObj["mediaCommonConfig"]?["dynamicReadaheadConfig"]?["maxReadAheadMediaTimeMs"]?.GetValue() ?? 0, + MinReadAheadMediaTimeMs = playerConfigObj["mediaCommonConfig"]?["dynamicReadaheadConfig"]?["minReadAheadMediaTimeMs"]?.GetValue() ?? 0, + ReadAheadGrowthRateMs = playerConfigObj["mediaCommonConfig"]?["dynamicReadaheadConfig"]?["readAheadGrowthRateMs"]?.GetValue() ?? 0, }; return playerConfig; } diff --git a/Manager.YouTube/YouTubeClient.cs b/Manager.YouTube/YouTubeClient.cs index b8369fc..dbbd1fb 100644 --- a/Manager.YouTube/YouTubeClient.cs +++ b/Manager.YouTube/YouTubeClient.cs @@ -9,15 +9,14 @@ using Manager.YouTube.Models; using Manager.YouTube.Models.Innertube; using Manager.YouTube.Parsers; using Manager.YouTube.Parsers.Json; -using Manager.YouTube.Util.Cipher; namespace Manager.YouTube; public sealed class YouTubeClient : IDisposable { public string Id { get; private set; } = ""; - public string? UserAgent { get; set; } - public bool IsAnonymous { get; } + public string UserAgent { get; private set; } + public bool IsAnonymous { get; private set; } public CookieContainer CookieContainer { get; } = new() { PerDomainCapacity = 50 }; public ClientState? State { get; private set; } public List DatasyncIds { get; } = []; @@ -52,7 +51,7 @@ public sealed class YouTubeClient : IDisposable /// /// The cookies to use for making requests. Empty collection or null for anonymous requests. /// The user agent to use for the requests. Only WEB client is supported. - /// The logger that the client is going to use, if null will create a new logger. + /// The logger that the client is going to use, null will create a new logger. /// public static async Task> CreateAsync(CookieCollection? cookies, string userAgent, ILogger? logger = null) { @@ -68,114 +67,57 @@ public sealed class YouTubeClient : IDisposable return client; } - private HttpClientHandler GetHttpClientHandler() + public void SetUserAgent(string userAgent) { - var clientHandler = new HttpClientHandler + if (string.IsNullOrWhiteSpace(userAgent)) { - AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, - UseCookies = true, - CookieContainer = CookieContainer - }; - return clientHandler; + _logger.Warning("UserAgent cannot be null or empty!"); + return; + } + UserAgent = userAgent; } - internal async Task FetchClientDataAsync() + public async Task> GetVideoByIdAsync(string videoId, CancellationToken cancellationToken = default) { - if (State is not { LoggedIn: true }) + if (string.IsNullOrWhiteSpace(videoId)) { - var state = await GetClientStateAsync(); - if (!state.IsSuccess) - { - return state; - } + return ResultError.Fail("Video id is empty!"); } - if (string.IsNullOrWhiteSpace(State?.WebPlayerContextConfig?.WebPlayerContext?.DatasyncId)) + var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"{NetworkService.Origin}/watch?v={videoId}")); + + var videoResponse = await NetworkService.MakeRequestAsync(request, this, true, cancellationToken); + if (!videoResponse.IsSuccess && !string.IsNullOrWhiteSpace(videoResponse.Value)) { - var datasyncResult = await GetDatasyncIdsAsync(); - if (!datasyncResult.IsSuccess) - { - return datasyncResult; - } - - foreach (var id in datasyncResult.Value) - { - if (DatasyncIds.Contains(id)) - continue; - DatasyncIds.Add(id); - } + return videoResponse.Error ?? ResultError.Fail("Request failed!"); } - if (string.IsNullOrWhiteSpace(Id)) + var html = videoResponse.Value; + + var stateResult = GetClientStateFromHtml(html); + var state = stateResult.Value; + if (!stateResult.IsSuccess && State != null) { - var accountInfoResult = await GetCurrentAccountIdAsync(); - if (!accountInfoResult.IsSuccess) - { - return accountInfoResult; - } - - Id = accountInfoResult.Value; + state = State; } + + var htmlParseResult = HtmlParser.GetVideoDataFromHtml(html); + if (!htmlParseResult.IsSuccess) + { + return htmlParseResult.Error ?? ResultError.Fail("Failed to parse HTML video data!"); + } + + var videoParseResult = VideoJsonParser.ParseVideoData(htmlParseResult.Value); + if (!videoParseResult.IsSuccess) + { + return videoParseResult; + } + + //await DecipherSignaturesAsync(videoParseResult.Value, state); - return Result.Success(); + return videoParseResult.Value; } - private async Task GetClientStateAsync() - { - var httpRequest = new HttpRequestMessage - { - Method = HttpMethod.Get, - RequestUri = new Uri(NetworkService.Origin) - }; - - var result = await NetworkService.MakeRequestAsync(httpRequest, this, true); - if (!result.IsSuccess) - { - return result.Error ?? ResultError.Fail("Request failed!"); - } - - 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; - } - - try - { - State = JsonSerializer.Deserialize(clientStateResult.Value.Item1); - } - catch (Exception e) - { - return ResultError.Error(e, "Error while parsing JSON!"); - } - - if (State == null) - { - return ResultError.Fail("Unable to parse client state!"); - } - - State.IsPremiumUser = clientStateResult.Value.Item2; - - return Result.Success(); - } - public async Task> GetChannelByIdAsync(string channelId) { if (State == null) @@ -256,11 +198,137 @@ public sealed class YouTubeClient : IDisposable var rotateRequest = new HttpRequestMessage(HttpMethod.Post, new Uri("https://accounts.youtube.com/RotateCookies")); return await NetworkService.MakeRequestAsync(rotateRequest, this, true); } - + public void Dispose() { HttpClient.Dispose(); } + + private async Task FetchClientDataAsync() + { + if (State is not { LoggedIn: true }) + { + var stateResult = await GetClientStateAsync(); + if (!stateResult.IsSuccess) + { + return stateResult; + } + } + + if (string.IsNullOrWhiteSpace(State?.WebPlayerContextConfig?.WebPlayerContext?.DatasyncId)) + { + var datasyncResult = await GetDatasyncIdsAsync(); + if (!datasyncResult.IsSuccess) + { + return datasyncResult; + } + + foreach (var id in datasyncResult.Value) + { + if (DatasyncIds.Contains(id)) + continue; + DatasyncIds.Add(id); + } + } + + if (string.IsNullOrWhiteSpace(Id)) + { + var accountInfoResult = await GetCurrentAccountIdAsync(); + if (!accountInfoResult.IsSuccess) + { + return accountInfoResult; + } + + Id = accountInfoResult.Value; + } + + return Result.Success(); + } + + private HttpClientHandler GetHttpClientHandler() + { + var clientHandler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, + UseCookies = true, + CookieContainer = CookieContainer + }; + return clientHandler; + } + + private async Task GetClientStateAsync() + { + var httpRequest = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri(NetworkService.Origin) + }; + + var result = await NetworkService.MakeRequestAsync(httpRequest, this, true); + if (!result.IsSuccess) + { + return result.Error ?? ResultError.Fail("Request failed!"); + } + + var stateResult = SetClientStateFromHtml(result.Value); + if (!stateResult.IsSuccess) + { + return stateResult; + } + + if (State is { LoggedIn: false }) + { + _logger.Warning("Client is not logged in!"); + return ResultError.Fail("Client login failed!"); + } + + 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 = GetClientStateFromHtml(html); + if (clientStateResult is { IsSuccess: false, Error: not null }) + { + return clientStateResult.Error; + } + + State = clientStateResult.Value; + IsAnonymous = !State.LoggedIn; + + return Result.Success(); + } + + private Result GetClientStateFromHtml(string html) + { + var clientStateResult = HtmlParser.GetStateJson(html); + if (clientStateResult is { IsSuccess: false, Error: not null }) + { + return clientStateResult.Error; + } + + ClientState? state; + try + { + state = JsonSerializer.Deserialize(clientStateResult.Value.Item1); + if (state != null) + { + state.IsPremiumUser = clientStateResult.Value.Item2; + } + } + catch (Exception e) + { + return ResultError.Error(e, "Error while parsing JSON!"); + } + + return state == null ? ResultError.Fail("Unable to parse client state!") : state; + } private async Task> GetCurrentAccountIdAsync() { @@ -317,47 +385,7 @@ 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) - { - _logger.Warning("Failed to update client state."); - } - - var htmlParseResult = HtmlParser.GetVideoDataFromHtml(html); - if (!htmlParseResult.IsSuccess) - { - return htmlParseResult.Error ?? ResultError.Fail("Failed to parse HTML video data!"); - } - - var videoParseResult = VideoJsonParser.ParseVideoData(htmlParseResult.Value); - if (!videoParseResult.IsSuccess) - { - return videoParseResult; - } - - await DecipherSignatures(videoParseResult.Value); - - return videoParseResult.Value; - } - - private async Task DecipherSignatures(YouTubeVideo video) + /*private async Task DecipherSignaturesAsync(YouTubeVideo video, ClientState state) { var streamingData = video.StreamingData; if (streamingData == null) @@ -365,25 +393,40 @@ public sealed class YouTubeClient : IDisposable _logger.Debug("No streaming data available, skipping decipher."); return; } + + if (string.IsNullOrWhiteSpace(state.PlayerJsUrl)) + { + _logger.Warning("No player js url found."); + } var formatsWithCipher = streamingData.Formats.Concat(streamingData.AdaptiveFormats).Where(x => !string.IsNullOrWhiteSpace(x.SignatureCipher)).ToList(); if (formatsWithCipher.Count == 0) { - _logger.Debug("Skipping decipher, no signatures found to decipher."); + _logger.Debug("Skipping signature decipher, no signatures found to decipher."); + } + + if (string.IsNullOrWhiteSpace(streamingData.ServerAbrStreamingUrl)) + { + _logger.Warning("No ABR streaming url available."); + } + + var abrStreamUri = new Uri(streamingData.ServerAbrStreamingUrl); + var queries = HttpUtility.ParseQueryString(abrStreamUri.Query); + var nSig = queries.Get("n"); + + if (string.IsNullOrWhiteSpace(nSig)) + { + _logger.Warning("No N signature found."); + } + + /*var jsEngineResult = await JavaScriptEngineManager.GetPlayerEngine(state.PlayerJsUrl ?? ""); + if (!jsEngineResult.IsSuccess) + { + _logger.Warning(jsEngineResult.Error?.Description ?? "Failed to get player script engine."); return; } - var decipherDecoderResult = await CipherManager.GetDecoderAsync(this); - if (!decipherDecoderResult.IsSuccess) - { - _logger.Warning(decipherDecoderResult.Error?.Description ?? "Failed to get the cipher decoder!"); - return; - } - var decoder = decipherDecoderResult.Value; - - foreach (var format in formatsWithCipher) - { - format.Url = decoder.Decipher(format.SignatureCipher); - } - } + var engine = jsEngineResult.Value; + engine.InitializePlayer();#1# + }*/ } \ No newline at end of file