diff --git a/Dockerfile b/Dockerfile index edff1f4f..33a3fe78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,10 @@ COPY ["LightTube/LightTube.csproj", "LightTube/"] RUN dotnet restore -a $TARGETARCH "LightTube/LightTube.csproj" COPY . . WORKDIR "/src/LightTube" -RUN dotnet build "LightTube.csproj" -a $TARGETARCH -c Release -o /app/build /p:Version=`date +0.%Y.%m.%d` +RUN dotnet build "LightTube.csproj" -a $TARGETARCH -c Release -o /app/build /p:Version=`date +3.%Y.%m.%d` FROM build AS publish -RUN dotnet publish "LightTube.csproj" -a $TARGETARCH -c Release -o /app/publish /p:Version=`date +0.%Y.%m.%d` +RUN dotnet publish "LightTube.csproj" -a $TARGETARCH -c Release -o /app/publish /p:Version=`date +3.%Y.%m.%d` FROM base AS final WORKDIR /app diff --git a/LightTube/ApiModels/ApiChannel.cs b/LightTube/ApiModels/ApiChannel.cs index c33fdcd1..aaf79898 100644 --- a/LightTube/ApiModels/ApiChannel.cs +++ b/LightTube/ApiModels/ApiChannel.cs @@ -1,84 +1,147 @@ -using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf; using InnerTube.Renderers; using LightTube.Database.Models; +using LightTube.Localization; +using Endpoint = InnerTube.Protobuf.Endpoint; namespace LightTube.ApiModels; public class ApiChannel { - public string Id { get; } - public string Title { get; } - public IEnumerable Avatars { get; } - public IEnumerable Banner { get; } - public IEnumerable Badges { get; } - public IEnumerable PrimaryLinks { get; } - public IEnumerable SecondaryLinks { get; } - public string SubscriberCountText { get; } - public IEnumerable EnabledTabs { get; } - public IEnumerable Contents { get; } - public string? Continuation { get; } + public ChannelHeader? Header { get; } + public ChannelTab[] Tabs { get; } + public ChannelMetadata? Metadata { get; } + public RendererContainer[] Contents { get; } - public ApiChannel(InnerTubeChannelResponse channel) - { - if (channel.Header != null) - { - Id = channel.Header.Id; - Title = channel.Header.Title; - Avatars = channel.Header.Avatars; - Banner = channel.Header.Banner; - Badges = channel.Header.Badges; - PrimaryLinks = channel.Header.PrimaryLinks; - SecondaryLinks = channel.Header.SecondaryLinks; - SubscriberCountText = channel.Header.SubscriberCountText; - } - else - { - Id = channel.Metadata.Id; - Title = channel.Metadata.Title; - Avatars = channel.Metadata.Avatar; - Banner = []; - Badges = []; - PrimaryLinks = []; - SecondaryLinks = []; - SubscriberCountText = "Unavailable"; - } + public ApiChannel(InnerTubeChannel channel) + { + Header = channel.Header; + Tabs = channel.Tabs.ToArray(); + Metadata = channel.Metadata; + Contents = channel.Contents; + } - EnabledTabs = channel.EnabledTabs.Select(x => x.ToString()); - Contents = channel.Contents; - Continuation = - (channel.Contents.FirstOrDefault(x => x is ContinuationItemRenderer) as ContinuationItemRenderer)?.Token; - } + public ApiChannel(ContinuationResponse continuation) + { + Header = null; + Tabs = []; + Metadata = null; + List renderers = new(); + renderers.AddRange(continuation.Results); + if (continuation.ContinuationToken != null) + renderers.Add(new RendererContainer + { + Type = "continuation", + OriginalType = "continuationItemRenderer", + Data = new ContinuationRendererData + { + ContinuationToken = continuation.ContinuationToken + } + }); + Contents = renderers.ToArray(); + } - public ApiChannel(InnerTubeContinuationResponse channel) - { - Id = ""; - Title = ""; - Avatars = []; - Banner = []; - Badges = []; - PrimaryLinks = []; - SecondaryLinks = []; - SubscriberCountText = "Unavailable"; - EnabledTabs = []; - Contents = channel.Contents; - Continuation = channel.Continuation; - } - - public ApiChannel(DatabaseUser channel) - { - Id = channel.LTChannelID; - Title = channel.UserID; - Avatars = []; - Banner = []; - Badges = []; - PrimaryLinks = []; - SecondaryLinks = []; - SubscriberCountText = "LightTube account"; - EnabledTabs = - [ - ChannelTabs.Playlists.ToString() - ]; - Contents = [channel.PlaylistRenderers()]; - Continuation = null; - } + public ApiChannel(DatabaseUser channel, LocalizationManager localization) + { + Header = new ChannelHeader(new PageHeaderRenderer + { + PageTitle = channel.UserID, + Content = new RendererWrapper + { + PageHeaderViewModel = new PageHeaderViewModel + { + Image = new RendererWrapper + { + DecoratedAvatarViewModel = new DecoratedAvatarViewModel + { + Avatar = new RendererWrapper + { + AvatarViewModel = new AvatarViewModel + { + Image = new Image() + } + } + }, + ImageBannerViewModel = new ImageBannerViewModel + { + Image = new Image() + } + }, + Metadata = new RendererWrapper + { + ContentMetadataViewModel = new ContentMetadataViewModel + { + MetadataRows = + { + new ContentMetadataViewModel.Types.MetadataRow + { + MetadataParts = + { + new ContentMetadataViewModel.Types.MetadataRow.Types. + AttributedDescriptionWrapper + { + Text = new AttributedDescription + { + Content = $"@LT_{channel.UserID}" + } + } + } + }, + new ContentMetadataViewModel.Types.MetadataRow + { + MetadataParts = + { + new ContentMetadataViewModel.Types.MetadataRow.Types. + AttributedDescriptionWrapper + { + Text = new AttributedDescription + { + Content = "LightTube Channel" + } + }, + new ContentMetadataViewModel.Types.MetadataRow.Types. + AttributedDescriptionWrapper + { + Text = new AttributedDescription + { + Content = "" + } + } + } + } + } + } + }, + Description = new RendererWrapper + { + DescriptionPreviewViewModel = new DescriptionPreviewViewModel + { + Content = new AttributedDescription + { + Content = "" + } + } + } + } + } + }, channel.LTChannelID, "en"); + Tabs = + [ + new ChannelTab(new TabRenderer + { + Endpoint = new Endpoint + { + BrowseEndpoint = new BrowseEndpoint + { + Params = "EglwbGF5bGlzdHPyBgQKAkIA" + } + }, + Title = "Playlists", + Selected = true + }) + ]; + Metadata = null; + Contents = channel.PlaylistRenderers(localization).ToArray(); + } } \ No newline at end of file diff --git a/LightTube/ApiModels/ApiLocals.cs b/LightTube/ApiModels/ApiLocals.cs new file mode 100644 index 00000000..3924a938 --- /dev/null +++ b/LightTube/ApiModels/ApiLocals.cs @@ -0,0 +1,7 @@ +namespace LightTube.ApiModels; + +public class ApiLocals +{ + public Dictionary Languages { get; set; } + public Dictionary Regions { get; set; } +} \ No newline at end of file diff --git a/LightTube/ApiModels/ApiPlaylist.cs b/LightTube/ApiModels/ApiPlaylist.cs index 1e1ec215..341e11d8 100644 --- a/LightTube/ApiModels/ApiPlaylist.cs +++ b/LightTube/ApiModels/ApiPlaylist.cs @@ -1,91 +1,54 @@ using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf; using InnerTube.Renderers; using LightTube.Database; using LightTube.Database.Models; +using LightTube.Localization; +using Endpoint = InnerTube.Protobuf.Endpoint; namespace LightTube.ApiModels; public class ApiPlaylist { - public string Id { get; } - public IEnumerable Alerts { get; } - public string Title { get; } - public string Description { get; } - public IEnumerable Badges { get; } - public Channel Channel { get; } - public IEnumerable Thumbnails { get; } - public string LastUpdated { get; } - public string VideoCountText { get; } - public string ViewCountText { get; } - public PlaylistContinuationInfo? Continuation { get; } - public IEnumerable Videos { get; } + public string Id { get; } + public string[] Alerts { get; } + public RendererContainer[] Contents { get; } + public RendererContainer[] Chips { get; } + public string? Continuation { get; } + public PlaylistSidebar? Sidebar { get; } + public bool Editable { get; } - public ApiPlaylist(InnerTubePlaylist playlist) - { - Id = playlist.Id; - Alerts = playlist.Alerts; - Title = playlist.Sidebar.Title; - Description = playlist.Sidebar.Description; - Badges = playlist.Sidebar.Badges; - Channel = playlist.Sidebar.Channel; - Thumbnails = playlist.Sidebar.Thumbnails; - LastUpdated = playlist.Sidebar.LastUpdated; - VideoCountText = playlist.Sidebar.VideoCountText; - ViewCountText = playlist.Sidebar.ViewCountText; - Continuation = playlist.Continuation; - Videos = playlist.Videos; - } + public ApiPlaylist(InnerTubePlaylist playlist) + { + Id = playlist.Id; + Alerts = playlist.Alerts; + Contents = playlist.Contents; + Chips = playlist.Chips; + Continuation = playlist.Continuation; + Sidebar = playlist.Sidebar; + Editable = false; + } - public ApiPlaylist(InnerTubeContinuationResponse playlist) - { - Id = ""; - Alerts = []; - Title = ""; - Description = ""; - Badges = []; - Channel = new Channel(); - Thumbnails = []; - LastUpdated = ""; - VideoCountText = ""; - ViewCountText = ""; - Continuation = playlist.Continuation is not null ? InnerTube.Utils.UnpackPlaylistContinuation(playlist.Continuation) : null; - Videos = playlist.Contents.Cast(); - } + public ApiPlaylist(ContinuationResponse playlist) + { + Id = ""; + Alerts = []; + Contents = playlist.Results; + Chips = []; + Continuation = playlist.ContinuationToken; + Sidebar = null; + Editable = false; + } - public ApiPlaylist(DatabasePlaylist playlist) - { - Id = playlist.Id; - Alerts = []; - Title = playlist.Name; - Description = playlist.Description; - Badges = []; - DatabaseUser user = DatabaseManager.Users.GetUserFromId(playlist.Author).Result!; - Channel = new Channel - { - Id = user.LTChannelID, - Title = user.UserID, - Avatar = null, - Subscribers = null, - Badges = [] - }; - Thumbnails = - [ - new Thumbnail - { - Width = null, - Height = null, - Url = new Uri($"https://i.ytimg.com/vi/{playlist.VideoIds.FirstOrDefault()}/hqdefault.jpg") - } - ]; - LastUpdated = $"Last updated on {playlist.LastUpdated:MMM d, yyyy}"; - VideoCountText = playlist.VideoIds.Count switch - { - 0 => "No videos", - 1 => "1 video", - _ => $"{playlist.VideoIds.Count} videos" - }; - ViewCountText = "LightTube playlist"; - Continuation = null; - Videos = DatabaseManager.Playlists.GetPlaylistVideos(playlist.Id, false); - } + public ApiPlaylist(DatabasePlaylist playlist, DatabaseUser author, LocalizationManager localization, DatabaseUser? user) + { + Id = playlist.Id; + Alerts = []; + Contents = DatabaseManager.Playlists.GetPlaylistVideoRenderers(playlist.Id, playlist.Author == user?.UserID, localization).ToArray(); + Chips = []; + Continuation = null; + Sidebar = new PlaylistSidebar(playlist.GetHeaderRenderer(author, localization), "en"); + Editable = playlist.Author == user?.UserID; + } } \ No newline at end of file diff --git a/LightTube/ApiModels/ApiSearchResults.cs b/LightTube/ApiModels/ApiSearchResults.cs index ff01a772..cfed7509 100644 --- a/LightTube/ApiModels/ApiSearchResults.cs +++ b/LightTube/ApiModels/ApiSearchResults.cs @@ -1,27 +1,39 @@ using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf.Params; using InnerTube.Renderers; namespace LightTube.ApiModels; public class ApiSearchResults { - public IEnumerable SearchResults { get; } - public long? EstimatedResultCount { get; } - public SearchParams? SearchParams { get; } - public string? ContinuationKey { get; } + public RendererContainer[] Results { get; } + public ShowingResultsFor? QueryCorrecter { get; } + public RendererContainer[] Chips { get; } + public string? Continuation { get; } + public string[] Refinements { get; } + public long EstimatedResults { get; } + public SearchParams? SearchParams { get; } - public ApiSearchResults(InnerTubeSearchResults results, SearchParams searchParams) - { - SearchParams = searchParams; - SearchResults = results.Results; - ContinuationKey = results.Continuation; - EstimatedResultCount = results.EstimatedResults; - } + public ApiSearchResults(InnerTubeSearchResults results, SearchParams searchParams) + { + Results = results.Results; + QueryCorrecter = results.QueryCorrecter; + Chips = results.Chips; + Continuation = results.Continuation; + Refinements = results.Refinements; + EstimatedResults = results.EstimatedResults; + SearchParams = searchParams; + } - public ApiSearchResults(InnerTubeContinuationResponse continuationResults) - { - SearchResults = continuationResults.Contents; - ContinuationKey = continuationResults.Continuation; - EstimatedResultCount = null; - } + public ApiSearchResults(ContinuationResponse continuationResults) + { + Results = continuationResults.Results; + QueryCorrecter = null; + Chips = []; + Continuation = continuationResults.ContinuationToken; + Refinements = []; + EstimatedResults = 0; + SearchParams = null; + } } \ No newline at end of file diff --git a/LightTube/ApiModels/ApiUserData.cs b/LightTube/ApiModels/ApiUserData.cs index 6e039333..68ef46bb 100644 --- a/LightTube/ApiModels/ApiUserData.cs +++ b/LightTube/ApiModels/ApiUserData.cs @@ -18,55 +18,24 @@ public class ApiUserData }; } - public void CalculateWithRenderers(IEnumerable renderers) + public void CalculateWithRenderers(IEnumerable renderers) { - foreach (IRenderer renderer in renderers) + foreach (RendererContainer renderer in renderers) CalculateWithRenderer(renderer); } - private void CalculateWithRenderer(IRenderer renderer) + private void CalculateWithRenderer(RendererContainer renderer) { - switch (renderer) + switch (renderer.Type) { - case ChannelRenderer channel: - AddInfoForChannel(channel.Id); + case "channel": + AddInfoForChannel((renderer.Data as ChannelRendererData)?.ChannelId); break; - case VideoRenderer video: - AddInfoForChannel(video.Channel.Id); + case "video": + AddInfoForChannel((renderer.Data as VideoRendererData)?.Author?.Id); break; - case CompactVideoRenderer video: - AddInfoForChannel(video.Channel.Id); - break; - case GridVideoRenderer video: - AddInfoForChannel(video.Channel.Id); - break; - case PlaylistVideoRenderer video: - AddInfoForChannel(video.Channel.Id); - break; - case PlaylistPanelVideoRenderer video: - AddInfoForChannel(video.Channel.Id); - break; - - case ShelfRenderer shelf: - CalculateWithRenderers(shelf.Items); - break; - case ReelShelfRenderer shelf: - CalculateWithRenderers(shelf.Items); - break; - case RichShelfRenderer shelf: - CalculateWithRenderers(shelf.Contents); - break; - case SectionListRenderer list: - CalculateWithRenderers(list.Contents); - break; - case ItemSectionRenderer section: - CalculateWithRenderers(section.Contents); - break; - case RichSectionRenderer section: - CalculateWithRenderer(section.Content); - break; - case GridRenderer grid: - CalculateWithRenderers(grid.Items); + case "container": + CalculateWithRenderers((renderer.Data as ContainerRendererData)?.Items ?? []); break; } } @@ -74,7 +43,7 @@ private void CalculateWithRenderer(IRenderer renderer) public void AddInfoForChannel(string? channelId) { if (channelId == null) return; - if (User.Subscriptions.ContainsKey(channelId) && !Channels.ContainsKey(channelId)) - Channels.Add(channelId, new ApiSubscriptionInfo(User.Subscriptions[channelId])); + if (User.Subscriptions.TryGetValue(channelId, out SubscriptionType value) && !Channels.ContainsKey(channelId)) + Channels.Add(channelId, new ApiSubscriptionInfo(value)); } } \ No newline at end of file diff --git a/LightTube/ApiModels/ModifyPlaylistContentResponse.cs b/LightTube/ApiModels/ModifyPlaylistContentResponse.cs index 5f1e380d..62277c00 100644 --- a/LightTube/ApiModels/ModifyPlaylistContentResponse.cs +++ b/LightTube/ApiModels/ModifyPlaylistContentResponse.cs @@ -1,4 +1,5 @@ using InnerTube; +using InnerTube.Models; namespace LightTube.Controllers; diff --git a/LightTube/ApiModels/UpdateSubscriptionResponse.cs b/LightTube/ApiModels/UpdateSubscriptionResponse.cs index fe4193a4..f36ad9ab 100644 --- a/LightTube/ApiModels/UpdateSubscriptionResponse.cs +++ b/LightTube/ApiModels/UpdateSubscriptionResponse.cs @@ -1,4 +1,4 @@ -using InnerTube; +using InnerTube.Models; using LightTube.ApiModels; using LightTube.Database.Models; @@ -11,7 +11,7 @@ public class UpdateSubscriptionResponse public bool Subscribed { get; } public bool Notifications { get; } - public UpdateSubscriptionResponse(InnerTubeChannelResponse channel, SubscriptionType subscription) + public UpdateSubscriptionResponse(InnerTubeChannel channel, SubscriptionType subscription) { try { @@ -26,6 +26,6 @@ public UpdateSubscriptionResponse(InnerTubeChannelResponse channel, Subscription } ChannelName = channel.Metadata.Title; - ChannelAvatar = channel.Metadata.Avatar.Last().Url.ToString(); + ChannelAvatar = channel.Metadata.AvatarUrl; } } \ No newline at end of file diff --git a/LightTube/Chores/DatabaseCleanupChore.cs b/LightTube/Chores/DatabaseCleanupChore.cs index b5f8ab3c..9fbbeb07 100644 --- a/LightTube/Chores/DatabaseCleanupChore.cs +++ b/LightTube/Chores/DatabaseCleanupChore.cs @@ -27,7 +27,7 @@ public async Task RunChore(Action updateStatus, Guid id) updateStatus("Duplicate UserID: " + user.UserID); else users.Add(user.UserID); - foreach (string channel in user.Subscriptions?.Keys.ToArray() ?? user.SubscribedChannels) + foreach (string channel in user.Subscriptions?.Keys.ToArray() ?? []) if (!channels.Contains(channel)) channels.Add(channel); } diff --git a/LightTube/Configuration.cs b/LightTube/Configuration.cs index 374e2d77..ed44cfea 100644 --- a/LightTube/Configuration.cs +++ b/LightTube/Configuration.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using InnerTube; using Newtonsoft.Json; +using Serilog; namespace LightTube; @@ -13,6 +14,7 @@ public static class Configuration public static bool RegistrationEnabled { get; private set; } public static bool ProxyEnabled { get; private set; } public static bool ThirdPartyProxyEnabled { get; private set; } + public static bool IsNightly { get; private set; } public static int CacheSize { get; private set; } public static string ConnectionString { get; private set; } public static string Database { get; private set; } @@ -30,21 +32,23 @@ public static class Configuration public static void InitConfig() { - InnerTubeAuthorization = Environment.GetEnvironmentVariable("LIGHTTUBE_AUTH_TYPE")?.ToLower() switch + InnerTubeAuthorization = null; + string? authType = Environment.GetEnvironmentVariable("LIGHTTUBE_AUTH_TYPE"); + if (authType == "cookie") { - "cookie" => InnerTubeAuthorization.SapisidAuthorization( - Environment.GetEnvironmentVariable("LIGHTTUBE_AUTH_SAPISID") ?? - throw new ArgumentNullException("LIGHTTUBE_AUTH_SAPISID", - "Authentication type set to 'cookie' but the 'LIGHTTUBE_AUTH_SAPISID' environment variable is not set."), - Environment.GetEnvironmentVariable("LIGHTTUBE_AUTH_PSID") ?? - throw new ArgumentNullException("LIGHTTUBE_AUTH_PSID", - "Authentication type set to 'cookie' but the 'LIGHTTUBE_AUTH_PSID' environment variable is not set.")), - "oauth2" => InnerTubeAuthorization.RefreshTokenAuthorization( + Log.Error("Cookie authentication has been removed in LightTube v3 as it does not work with youtubei.googleapis.com"); + } + else if (authType == "oauth2") + { + InnerTubeAuthorization = InnerTubeAuthorization.RefreshTokenAuthorization( Environment.GetEnvironmentVariable("LIGHTTUBE_AUTH_REFRESH_TOKEN") ?? throw new ArgumentNullException("LIGHTTUBE_AUTH_REFRESH_TOKEN", - "Authentication type set to 'oauth2' but the 'LIGHTTUBE_AUTH_REFRESH_TOKEN' environment variable is not set.")), - _ => null - }; + "Authentication type set to 'oauth2' but the 'LIGHTTUBE_AUTH_REFRESH_TOKEN' environment variable is not set.")); + } + else + { + Log.Warning("Unknown auth type: '{AuthType}'", authType); + } CustomCssPath = Environment.GetEnvironmentVariable("LIGHTTUBE_CUSTOM_CSS_PATH"); if (CustomCssPath != null) @@ -71,6 +75,7 @@ public static void InitConfig() DefaultContentLanguage = GetVariable("LIGHTTUBE_DEFAULT_CONTENT_LANGUAGE", "en")!; DefaultContentRegion = GetVariable("LIGHTTUBE_DEFAULT_CONTENT_REGION", "US")!; DefaultTheme = GetVariable("LIGHTTUBE_DEFAULT_THEME", "auto")!; + IsNightly = GetVariable("LIGHTTUBE_IS_NIGHTLY", "false")?.ToLower() == "true"; try { diff --git a/LightTube/Contexts/AppearanceSettingsContext.cs b/LightTube/Contexts/AppearanceSettingsContext.cs index 9b15d144..616edfdc 100644 --- a/LightTube/Contexts/AppearanceSettingsContext.cs +++ b/LightTube/Contexts/AppearanceSettingsContext.cs @@ -1,16 +1,17 @@ using InnerTube; +using LightTube.ApiModels; using LightTube.Localization; namespace LightTube.Contexts; public class AppearanceSettingsContext( HttpContext context, - InnerTubeLocals locals, + ApiLocals locals, Dictionary customThemes, Language[] languages) : BaseContext(context) { public Language[] Languages = languages; - public InnerTubeLocals Locals = locals; + public ApiLocals Locals = locals; public Dictionary CustomThemes = customThemes; public Dictionary BuiltinThemes = new() { diff --git a/LightTube/Contexts/ChannelContext.cs b/LightTube/Contexts/ChannelContext.cs index dab5f3f3..97a436bd 100644 --- a/LightTube/Contexts/ChannelContext.cs +++ b/LightTube/Contexts/ChannelContext.cs @@ -1,110 +1,147 @@ -using InnerTube; +using System.Collections.ObjectModel; +using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf; using InnerTube.Renderers; using LightTube.Database.Models; +using Endpoint = InnerTube.Protobuf.Endpoint; namespace LightTube.Contexts; public class ChannelContext : BaseContext { - public string? BannerUrl; - public string AvatarUrl; - public string ChannelTitle; - public string SubscriberCountText; - public bool LightTubeAccount; - public bool Editable; - public ChannelTabs CurrentTab; + public string? BannerUrl; + public string AvatarUrl; + public string ChannelTitle; + public string? Handle; + public long SubscriberCount; + public long VideoCount; + public string? Tagline; + public string? PrimaryLink; + public string? SecondaryLink; + public bool LightTubeAccount; + public bool Editable; + public ChannelTabs CurrentTab; - [Obsolete] - public InnerTubeChannelResponse? Channel; - public IEnumerable Content; - public string Id; - public string? Continuation; - public ChannelTabs[] Tabs; + public IEnumerable Content; + public string Id; + public string? Continuation; + public ReadOnlyCollection Tabs; + public InnerTubeAboutChannel? About; - public ChannelContext(HttpContext context, ChannelTabs tab, InnerTubeChannelResponse channel, string id) : base(context) - { - Id = id; - CurrentTab = tab; - BannerUrl = channel.Header?.Banner.LastOrDefault()?.Url.ToString(); - AvatarUrl = channel.Header?.Avatars.LastOrDefault()?.Url.ToString() ?? ""; - ChannelTitle = channel.Header?.Title ?? ""; - SubscriberCountText = channel.Header?.SubscriberCountText ?? ""; - LightTubeAccount = false; - Editable = false; - Content = channel.Contents; - Continuation = - (channel.Contents.FirstOrDefault(x => x is ContinuationItemRenderer) as ContinuationItemRenderer)?.Token; - Tabs = channel.EnabledTabs; + public ChannelContext(HttpContext context, ChannelTabs tab, InnerTubeChannel channel, string id, InnerTubeAboutChannel? about = null) : base(context) + { + Id = id; + CurrentTab = channel.Tabs.FirstOrDefault(x => x.Selected)?.Tab ?? tab; + BannerUrl = channel.Header?.Banner.LastOrDefault()?.Url; + AvatarUrl = channel.Header?.Avatars.LastOrDefault()?.Url ?? ""; + ChannelTitle = channel.Header?.Title ?? ""; + Handle = channel.Header?.Handle; + SubscriberCount = channel.Header?.SubscriberCount ?? 0; + VideoCount = channel.Header?.VideoCount ?? 0; + Tagline = channel.Header?.Tagline; + PrimaryLink = channel.Header?.PrimaryLink; + SecondaryLink = channel.Header?.SecondaryLink; + LightTubeAccount = false; + Editable = false; + Content = channel.Contents; + Continuation = + (channel.Contents.FirstOrDefault(x => x.Type == "continuation")?.Data as ContinuationRendererData) + ?.ContinuationToken; + Tabs = channel.Tabs; + About = about; - AddMeta("description", channel.Metadata.Description); - AddMeta("author", channel.Metadata.Title); - AddMeta("og:title", channel.Metadata.Title); - AddMeta("og:description", channel.Metadata.Description); - AddMeta("og:url", $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); - AddMeta("og:image", channel.Header?.Avatars.Last().Url.ToString() ?? ""); - AddMeta("twitter:card", channel.Header?.Avatars.Last().Url.ToString() ?? ""); - AddRSSUrl(context.Request.Scheme + "://" + context.Request.Host + "/feed/" + Id + "/rss.xml"); + AddMeta("description", channel.Metadata.Description); + AddMeta("author", channel.Metadata.Title); + AddMeta("og:title", channel.Metadata.Title); + AddMeta("og:description", channel.Metadata.Description); + AddMeta("og:url", + $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); + AddMeta("og:image", channel.Header?.Avatars.Last().Url ?? ""); + AddMeta("twitter:card", channel.Header?.Avatars.Last().Url ?? ""); + AddRSSUrl($"{context.Request.Scheme}://{context.Request.Host}/channel/{Id}.xml"); - if (channel.Contents.Any(x => x is ChannelVideoPlayerRenderer || x is ItemSectionRenderer isr && isr.Contents.Any(y => y is ChannelVideoPlayerRenderer))) - { - AddStylesheet("/lib/videojs/video-js.min.css"); - AddStylesheet("/lib/videojs-endscreen/videojs-endscreen.css"); - AddStylesheet("/lib/videojs-vtt-thumbnails/videojs-vtt-thumbnails.min.css"); - AddStylesheet("/lib/videojs-hls-quality-selector/videojs-hls-quality-selector.css"); - AddStylesheet("/lib/silvermine-videojs-quality-selector/silvermine-videojs-quality-selector.css"); - AddStylesheet("/css/vjs-skin.css"); + if (channel.Contents + .Select(x => x.OriginalType == "itemSectionRenderer" ? (x.Data as ContainerRendererData)!.Items.First() : x) + .Any(x => x.OriginalType == "channelVideoPlayerRenderer")) + { + AddStylesheet("/lib/ltplayer.css"); + AddScript("/lib/ltplayer.js"); + AddScript("/js/player.js"); + } + } - AddScript("/lib/videojs/video.min.js"); - AddScript("/lib/videojs-hotkeys/videojs.hotkeys.min.js"); - AddScript("/lib/videojs-endscreen/videojs-endscreen.js"); - AddScript("/lib/videojs-vtt-thumbnails/videojs-vtt-thumbnails.min.js"); - AddScript("/lib/videojs-contrib-quality-levels/videojs-contrib-quality-levels.min.js"); - AddScript("/lib/videojs-hls-quality-selector/videojs-hls-quality-selector.min.js"); - AddScript("/lib/silvermine-videojs-quality-selector/silvermine-videojs-quality-selector.min.js"); - AddScript("/js/player.js"); - } - } + public ChannelContext(HttpContext context, ChannelTabs tab, InnerTubeChannel channel, + ContinuationResponse continuation, string id) : base(context) + { + Id = id; + CurrentTab = channel.Tabs.FirstOrDefault(x => x.Selected)?.Tab ?? tab; + BannerUrl = channel.Header?.Banner.LastOrDefault()?.Url; + AvatarUrl = channel.Header?.Avatars.Last().Url ?? ""; + ChannelTitle = channel.Header?.Title ?? ""; + Handle = channel.Header?.Handle; + SubscriberCount = channel.Header?.SubscriberCount ?? 0; + VideoCount = channel.Header?.VideoCount ?? 0; + Tagline = channel.Header?.Tagline; + PrimaryLink = channel.Header?.PrimaryLink; + SecondaryLink = channel.Header?.SecondaryLink; + LightTubeAccount = false; + Editable = false; + Content = continuation.Results; + Continuation = continuation.ContinuationToken; + Tabs = channel.Tabs; - public ChannelContext(HttpContext context, ChannelTabs tab, InnerTubeChannelResponse channel, InnerTubeContinuationResponse continuation, string id) : base(context) - { - Id = id; - CurrentTab = tab; - BannerUrl = channel.Header?.Banner.LastOrDefault()?.Url.ToString(); - AvatarUrl = channel.Header?.Avatars.Last().Url.ToString() ?? ""; - ChannelTitle = channel.Header?.Title ?? ""; - SubscriberCountText = channel.Header?.SubscriberCountText ?? ""; - LightTubeAccount = false; - Editable = false; - Content = continuation.Contents; - Continuation = continuation.Continuation; - Tabs = Enum.GetValues(); + AddMeta("description", channel.Metadata.Description); + AddMeta("author", channel.Metadata.Title); + AddMeta("og:title", channel.Metadata.Title); + AddMeta("og:description", channel.Metadata.Description); + AddMeta("og:url", + $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); + AddMeta("og:image", channel.Header?.Avatars.Last().Url ?? ""); + AddMeta("twitter:card", channel.Header?.Avatars.Last().Url ?? ""); + AddRSSUrl(context.Request.Scheme + "://" + context.Request.Host + "/feed/" + Id + "/rss.xml"); + + if (channel.Contents + .Select(x => x.OriginalType == "itemSectionRenderer" ? (x.Data as ContainerRendererData)!.Items.First() : x) + .Any(x => x.OriginalType == "channelVideoPlayerRenderer")) + { + AddStylesheet("/lib/ltplayer.css"); + AddScript("/lib/ltplayer.js"); + AddScript("/js/player.js"); + } + } - AddMeta("description", channel.Metadata.Description); - AddMeta("author", channel.Metadata.Title); - AddMeta("og:title", channel.Metadata.Title); - AddMeta("og:description", channel.Metadata.Description); - AddMeta("og:url", $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); - AddMeta("og:image", channel.Header?.Avatars.Last().Url.ToString() ?? ""); - AddMeta("twitter:card", channel.Header?.Avatars.Last().Url.ToString() ?? ""); - AddRSSUrl(context.Request.Scheme + "://" + context.Request.Host + "/feed/" + Id + "/rss.xml"); - } + public ChannelContext(HttpContext context, DatabaseUser channel, string id) : base(context) + { + Id = id; + CurrentTab = ChannelTabs.Playlists; + BannerUrl = null; + AvatarUrl = ""; + ChannelTitle = channel.UserID; + Handle = "@LT_" + id; + SubscriberCount = 0; + VideoCount = 0; + Tagline = Localization.GetRawString("channel.tagline.lighttube"); + PrimaryLink = null; + SecondaryLink = null; + LightTubeAccount = true; + Editable = channel.UserID == User?.UserID; + Tabs = new ReadOnlyCollection([ + new ChannelTab(new TabRenderer + { + Endpoint = new Endpoint + { + BrowseEndpoint = new() + { + Params = "EglwbGF5bGlzdHP" + } + }, + Title = "Playlists", + Selected = true, + + }) + ]); - public ChannelContext(HttpContext context, DatabaseUser? channel, string id) : base(context) - { - Id = id; - CurrentTab = ChannelTabs.Playlists; - BannerUrl = null; - AvatarUrl = ""; - ChannelTitle = channel?.UserID ?? ""; - SubscriberCountText = "LightTube account"; - LightTubeAccount = true; - Editable = channel?.UserID == User?.UserID; - Tabs = [ - ChannelTabs.Playlists - ]; - - Content = [ - channel?.PlaylistRenderers() - ]; - } + Content = channel.PlaylistRenderers(Localization); + } } \ No newline at end of file diff --git a/LightTube/Contexts/EmbedContext.cs b/LightTube/Contexts/EmbedContext.cs index 6ce77e7e..911bc4d1 100644 --- a/LightTube/Contexts/EmbedContext.cs +++ b/LightTube/Contexts/EmbedContext.cs @@ -1,51 +1,24 @@ using InnerTube; +using InnerTube.Models; namespace LightTube.Contexts; public class EmbedContext : BaseContext { public PlayerContext Player; - public InnerTubeNextResponse Video; + public InnerTubeVideo Video; - public EmbedContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeNextResponse innerTubeNextResponse, bool compatibility, SponsorBlockSegment[] sponsors) : base(context) + public EmbedContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeVideo innerTubeNextResponse, + bool compatibility, SponsorBlockSegment[] sponsors) : base(context) { - Player = new PlayerContext(context, innerTubePlayer, innerTubeNextResponse, "embed", compatibility, context.Request.Query["q"], sponsors); + Player = new PlayerContext(context, innerTubePlayer, innerTubeNextResponse, "embed", compatibility, + context.Request.Query["q"], sponsors); Video = innerTubeNextResponse; - - AddMeta("description", Video.Description); - AddMeta("author", Video.Channel.Title); - AddMeta("og:title", Video.Title); - AddMeta("og:description", Video.Description); - AddMeta("og:url", - $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); - AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - AddMeta("og:video:width", "640"); - AddMeta("og:video:height", "360"); - AddMeta("og:type", "video.other"); - AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); - AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); } - public EmbedContext(HttpContext context, Exception e, InnerTubeNextResponse innerTubeNextResponse) : base(context) + public EmbedContext(HttpContext context, Exception e, InnerTubeVideo innerTubeNextResponse) : base(context) { Player = new PlayerContext(context, e); Video = innerTubeNextResponse; - - AddMeta("description", Video.Description); - AddMeta("author", Video.Channel.Title); - AddMeta("og:title", Video.Title); - AddMeta("og:description", Video.Description); - AddMeta("og:url", - $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); - AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - AddMeta("og:video:width", "640"); - AddMeta("og:video:height", "360"); - AddMeta("og:type", "video.other"); - AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); - AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); } } \ No newline at end of file diff --git a/LightTube/Contexts/HomepageContext.cs b/LightTube/Contexts/HomepageContext.cs index 1313cb1f..d8fa16bb 100644 --- a/LightTube/Contexts/HomepageContext.cs +++ b/LightTube/Contexts/HomepageContext.cs @@ -12,7 +12,7 @@ public HomepageContext(HttpContext context) : base(context) { Videos = Task.Run(async () => { - return (await YoutubeRSS.GetMultipleFeeds(User.Subscriptions.Where(x => x.Value == SubscriptionType.NOTIFICATIONS_ON).Select(x => x.Key))).Take(context.Request.Cookies["maxvideos"] is null ? 5 : Convert.ToInt32(context.Request.Cookies["maxvideos"])).ToArray(); + return (await YoutubeRss.GetMultipleFeeds(User.Subscriptions.Where(x => x.Value == SubscriptionType.NOTIFICATIONS_ON).Select(x => x.Key))).Take(context.Request.Cookies["maxvideos"] is null ? 5 : Convert.ToInt32(context.Request.Cookies["maxvideos"])).ToArray(); }).Result; } } diff --git a/LightTube/Contexts/LibraryContext.cs b/LightTube/Contexts/LibraryContext.cs index 46228486..210a39a8 100644 --- a/LightTube/Contexts/LibraryContext.cs +++ b/LightTube/Contexts/LibraryContext.cs @@ -10,7 +10,7 @@ public class LibraryContext : BaseContext public LibraryContext(HttpContext context) : base(context) { Playlists = User != null - ? DatabaseManager.Playlists.GetUserPlaylists(User.UserID, PlaylistVisibility.PRIVATE) + ? DatabaseManager.Playlists.GetUserPlaylists(User.UserID, PlaylistVisibility.Private) : []; } } \ No newline at end of file diff --git a/LightTube/Contexts/PlayerContext.cs b/LightTube/Contexts/PlayerContext.cs index 75504360..f851878b 100644 --- a/LightTube/Contexts/PlayerContext.cs +++ b/LightTube/Contexts/PlayerContext.cs @@ -1,4 +1,7 @@ using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf; +using InnerTube.Protobuf.Responses; using InnerTube.Renderers; using Newtonsoft.Json; @@ -7,27 +10,26 @@ namespace LightTube.Contexts; public class PlayerContext : BaseContext { public InnerTubePlayer? Player; - public InnerTubeNextResponse Video; + public InnerTubeVideo? Video; public Exception? Exception; public bool UseHls; public bool UseDash; public Thumbnail[] Thumbnails = []; public string? ErrorMessage = null; - public string PreferredItag = "18"; + public int PreferredItag = 18; public bool UseEmbedUi = false; public string? ClassName; public SponsorBlockSegment[] Sponsors; - public PlayerContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeNextResponse video, - string className, bool compatibility, - string preferredItag, SponsorBlockSegment[] sponsors) : base(context) + public PlayerContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeVideo? video, string className, + bool compatibility, string? preferredItag, SponsorBlockSegment[] sponsors) : base(context) { Player = innerTubePlayer; Video = video; ClassName = className; - PreferredItag = preferredItag; + PreferredItag = int.TryParse(preferredItag ?? "18", out int itag) ? itag : 18; Sponsors = sponsors; - UseHls = !compatibility; // Prefer HLS + UseHls = !compatibility && !string.IsNullOrWhiteSpace(innerTubePlayer.HlsManifestUrl); // Prefer HLS UseDash = innerTubePlayer.AdaptiveFormats.Any() && !compatibility; // Formats if (!Configuration.ProxyEnabled) @@ -39,21 +41,21 @@ public class PlayerContext : BaseContext public string GetChaptersJson() { - if (Video.Chapters is null) return "[]"; - ChapterRenderer[] c = Video.Chapters.ToArray(); + if (Video?.Chapters is null) return "[]"; + VideoChapter[] c = Video.Chapters.ToArray(); List ltChapters = []; for (int i = 0; i < c.Length; i++) { - ChapterRenderer chapter = c[i]; + VideoChapter chapter = c[i]; float to = 100; if (i + 1 < c.Length) { - ChapterRenderer next = c[i + 1]; - to = next.TimeRangeStartMillis / (float)Player!.Details.Length.TotalMilliseconds * 100; + VideoChapter next = c[i + 1]; + to = (next.StartSeconds * 1000) / (float)Player!.Details.Length!.Value.TotalMilliseconds * 100; } ltChapters.Add(new LtVideoChapter { - From = chapter.TimeRangeStartMillis / (float)Player!.Details.Length.TotalMilliseconds * 100, + From = (chapter.StartSeconds * 1000) / (float)Player!.Details.Length!.Value.TotalMilliseconds * 100, To = to, Name = chapter.Title }); @@ -76,13 +78,14 @@ public PlayerContext(HttpContext context, Exception e) : base(context) Sponsors = []; } - public string? GetFirstItag() => GetPreferredFormat()?.Itag; + public int? GetFirstItag() => GetPreferredFormat()?.Itag; public Format? GetPreferredFormat() => - Player?.Formats.FirstOrDefault(x => x.Itag == PreferredItag && x.Itag != "17") ?? - Player?.Formats.FirstOrDefault(x => x.Itag != "17"); + Player?.Formats.FirstOrDefault(x => x.Itag == PreferredItag && x.Itag != 17) ?? + Player?.Formats.FirstOrDefault(x => x.Itag != 17); public string GetClass() => ClassName is not null ? $" {ClassName}" : ""; - public IEnumerable GetFormatsInPreferredOrder() => Player!.Formats.OrderBy(x => x.Itag != PreferredItag).Where(x => x.Itag != "17"); + public IEnumerable GetFormatsInPreferredOrder() => + Player!.Formats.OrderBy(x => x.Itag != PreferredItag).Where(x => x.Itag != 17); } \ No newline at end of file diff --git a/LightTube/Contexts/PlaylistContext.cs b/LightTube/Contexts/PlaylistContext.cs index 2296a10b..1ccd7e87 100644 --- a/LightTube/Contexts/PlaylistContext.cs +++ b/LightTube/Contexts/PlaylistContext.cs @@ -1,4 +1,4 @@ -using InnerTube; +using InnerTube.Models; using InnerTube.Renderers; using LightTube.Database; using LightTube.Database.Models; @@ -16,22 +16,24 @@ public class PlaylistContext : BaseContext public string ViewCountText; public string LastUpdatedText; public bool Editable; - public IEnumerable Items; - public int? Continuation; + public IEnumerable Items; + public string? Continuation; + public string[] Alerts; public PlaylistContext(HttpContext context, InnerTubePlaylist playlist) : base(context) { Id = playlist.Id; - PlaylistThumbnail = playlist.Sidebar.Thumbnails.Last().Url.ToString(); + PlaylistThumbnail = playlist.Sidebar.Thumbnails.Last().Url; PlaylistTitle = playlist.Sidebar.Title; PlaylistDescription = playlist.Sidebar.Description; - AuthorName = playlist.Sidebar.Channel.Title; - AuthorId = playlist.Sidebar.Channel.Id!; + AuthorName = playlist.Sidebar.Channel?.Title ?? "????"; + AuthorId = playlist.Sidebar.Channel?.Id ?? "UC"; ViewCountText = playlist.Sidebar.ViewCountText; - LastUpdatedText = playlist.Sidebar.LastUpdated; + LastUpdatedText = playlist.Sidebar.LastUpdatedText; Editable = false; - Items = playlist.Videos; - Continuation = playlist.Continuation?.ContinueFrom; + Items = playlist.Contents; + Continuation = playlist.Continuation; + Alerts = playlist.Alerts; AddMeta("description", playlist.Sidebar.Description); AddMeta("author", playlist.Sidebar.Title); @@ -40,27 +42,26 @@ public PlaylistContext(HttpContext context, InnerTubePlaylist playlist) : base(c AddMeta("og:url", $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); AddMeta("og:image", - $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{playlist.Videos.First().Id}/-1"); + $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{(playlist.Contents.First(x => x.Type == "video").Data as VideoRendererData)?.VideoId}/-1"); AddMeta("twitter:card", - $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{playlist.Videos.First().Id}/-1"); + $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{(playlist.Contents.First(x => x.Type == "video").Data as VideoRendererData)?.VideoId}/-1"); } - public PlaylistContext(HttpContext context, InnerTubePlaylist playlist, InnerTubeContinuationResponse continuation) + public PlaylistContext(HttpContext context, InnerTubePlaylist playlist, ContinuationResponse continuation) : base(context) { Id = playlist.Id; - PlaylistThumbnail = playlist.Sidebar.Thumbnails.Last().Url.ToString(); + PlaylistThumbnail = playlist.Sidebar.Thumbnails.Last().Url; PlaylistTitle = playlist.Sidebar.Title; PlaylistDescription = playlist.Sidebar.Description; - AuthorName = playlist.Sidebar.Channel.Title; - AuthorId = playlist.Sidebar.Channel.Id!; + AuthorName = playlist.Sidebar.Channel?.Title ?? "????"; + AuthorId = playlist.Sidebar.Channel?.Id ?? "UC"; ViewCountText = playlist.Sidebar.ViewCountText; - LastUpdatedText = playlist.Sidebar.LastUpdated; + LastUpdatedText = playlist.Sidebar.LastUpdatedText; Editable = false; - Items = continuation.Contents; - Continuation = continuation.Continuation is not null - ? InnerTube.Utils.UnpackPlaylistContinuation(continuation.Continuation).ContinueFrom - : null; + Items = continuation.Results; + Continuation = continuation.ContinuationToken; + Alerts = []; AddMeta("description", playlist.Sidebar.Description); AddMeta("author", playlist.Sidebar.Title); @@ -69,16 +70,14 @@ public PlaylistContext(HttpContext context, InnerTubePlaylist playlist, InnerTub AddMeta("og:url", $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); AddMeta("og:image", - $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{playlist.Videos.First().Id}/-1"); + $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{(playlist.Contents.First(x => x.Type == "video").Data as VideoRendererData)?.VideoId}/-1"); AddMeta("twitter:card", - $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{playlist.Videos.First().Id}/-1"); + $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{(playlist.Contents.First(x => x.Type == "video").Data as VideoRendererData)?.VideoId}/-1"); } public PlaylistContext(HttpContext context, DatabasePlaylist? playlist) : base(context) { - bool visible = (playlist?.Visibility == PlaylistVisibility.PRIVATE) - ? User != null && User.UserID == playlist.Author - : true; + bool visible = playlist?.Visibility != PlaylistVisibility.Private || User != null && User.UserID == playlist.Author; if (visible && playlist != null) { @@ -91,11 +90,12 @@ public PlaylistContext(HttpContext context, DatabasePlaylist? playlist) : base(c ViewCountText = Localization.GetRawString("playlist.lighttube.views"); LastUpdatedText = string.Format(Localization.GetRawString("playlist.lastupdated"), playlist.LastUpdated.ToString("MMM d, yyyy")); Editable = User != null && User.UserID == playlist.Author; - Items = DatabaseManager.Playlists.GetPlaylistVideos(playlist.Id, Editable); + Items = DatabaseManager.Playlists.GetPlaylistVideoRenderers(playlist.Id, Editable, Localization); } else { - PlaylistThumbnail = $"https://i.ytimg.com/vi//hqdefault.jpg"; + Id = ""; + PlaylistThumbnail = "https://i.ytimg.com/vi//hqdefault.jpg"; PlaylistTitle = Localization.GetRawString("playlist.unavailable"); PlaylistDescription = ""; AuthorName = ""; @@ -105,5 +105,7 @@ public PlaylistContext(HttpContext context, DatabasePlaylist? playlist) : base(c Items = []; Editable = false; } + + Alerts = []; } } \ No newline at end of file diff --git a/LightTube/Contexts/PlaylistVideoContext.cs b/LightTube/Contexts/PlaylistVideoContext.cs index e0b0f61d..05e0852f 100644 --- a/LightTube/Contexts/PlaylistVideoContext.cs +++ b/LightTube/Contexts/PlaylistVideoContext.cs @@ -1,4 +1,4 @@ -using InnerTube; +using InnerTube.Models; using LightTube.Database.Models; namespace LightTube.Contexts; @@ -15,7 +15,7 @@ public PlaylistVideoContext(HttpContext context) : base(context) { } - public PlaylistVideoContext(HttpContext context, InnerTubeNextResponse video) : base(context) + public PlaylistVideoContext(HttpContext context, InnerTubeVideo video) : base(context) { ItemId = video.Id; ItemTitle = video.Title; diff --git a/LightTube/Contexts/SearchContext.cs b/LightTube/Contexts/SearchContext.cs index 83206969..45a38f4f 100644 --- a/LightTube/Contexts/SearchContext.cs +++ b/LightTube/Contexts/SearchContext.cs @@ -1,4 +1,6 @@ using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf.Params; using InnerTube.Renderers; namespace LightTube.Contexts; @@ -8,24 +10,35 @@ public class SearchContext : BaseContext public string Query; public SearchParams? Filter; public InnerTubeSearchResults? Search; - public IEnumerable Results; + public IEnumerable Results; + public RendererContainer? Sidebar; + public IEnumerable Chips; public string? Continuation; + public int? CurrentPage; - public SearchContext(HttpContext context, string query, SearchParams? filter, InnerTubeSearchResults search) : base(context) + public SearchContext(HttpContext context, string query, SearchParams? filter, InnerTubeSearchResults search, + int currentPage, RendererContainer? sidebar) : base(context) { Query = query; Filter = filter; Search = search; Results = Search.Results; Continuation = Search.Continuation; + Chips = Search.Chips; + CurrentPage = currentPage; + Sidebar = sidebar; } - public SearchContext(HttpContext context, string query, SearchParams? filter, InnerTubeContinuationResponse search) : base(context) + public SearchContext(HttpContext context, string query, SearchParams? filter, SearchContinuationResponse search) : + base(context) { Query = query; Filter = filter; Search = null; - Results = search.Contents; - Continuation = search.Continuation; + Results = search.Results; + Continuation = search.ContinuationToken; + Chips = search.Chips ?? []; + CurrentPage = null; + Sidebar = null; } } \ No newline at end of file diff --git a/LightTube/Contexts/SubscriptionContext.cs b/LightTube/Contexts/SubscriptionContext.cs index aee9b755..f329afb1 100644 --- a/LightTube/Contexts/SubscriptionContext.cs +++ b/LightTube/Contexts/SubscriptionContext.cs @@ -1,14 +1,14 @@ -using InnerTube; +using InnerTube.Models; using LightTube.Database.Models; namespace LightTube.Contexts; public class SubscriptionContext : ModalContext { - public InnerTubeChannelResponse Channel; + public InnerTubeChannel Channel; public SubscriptionType CurrentType = SubscriptionType.NONE; - public SubscriptionContext(HttpContext context, InnerTubeChannelResponse channel, SubscriptionType? subscriptionType = null) : + public SubscriptionContext(HttpContext context, InnerTubeChannel channel, SubscriptionType? subscriptionType = null) : base(context) { Channel = channel; diff --git a/LightTube/Contexts/WatchContext.cs b/LightTube/Contexts/WatchContext.cs index 1863bce5..9bcfe72d 100644 --- a/LightTube/Contexts/WatchContext.cs +++ b/LightTube/Contexts/WatchContext.cs @@ -1,152 +1,156 @@ -using InnerTube; +using InnerTube.Models; +using LightTube.Database; using LightTube.Database.Models; namespace LightTube.Contexts; public class WatchContext : BaseContext { - public PlayerContext Player; - public InnerTubeNextResponse Video; - public InnerTubePlaylistInfo? Playlist; - public InnerTubeContinuationResponse? Comments; - public int Dislikes; - public int Likes; - public SponsorBlockSegment[] Sponsors; + public PlayerContext Player; + public InnerTubeVideo Video; + public VideoPlaylistInfo? Playlist; + public ContinuationResponse? Comments; + public long Dislikes; + public long Likes; + public SponsorBlockSegment[] Sponsors; - public WatchContext(HttpContext context, InnerTubePlayer innerTubePlayer, - InnerTubeNextResponse innerTubeNextResponse, - InnerTubeContinuationResponse? comments, - bool compatibility, int dislikes, int likes, SponsorBlockSegment[] sponsors) : base(context) - { - Player = new PlayerContext(context, innerTubePlayer, innerTubeNextResponse, "embed", compatibility, - context.Request.Query["q"], sponsors); - Video = innerTubeNextResponse; - Playlist = Video.Playlist; - Comments = comments; - Dislikes = dislikes; - Likes = likes; - Sponsors = sponsors; - GuideHidden = true; + public WatchContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeVideo innerTubeVideo, + ContinuationResponse? comments, bool compatibility, int dislikes, + SponsorBlockSegment[] sponsors) : base(context) + { + Player = new PlayerContext(context, innerTubePlayer, innerTubeVideo, "embed", compatibility, + context.Request.Query["q"], sponsors); + Video = innerTubeVideo; + Playlist = Video.Playlist; + Comments = comments; + Dislikes = dislikes; + Likes = innerTubeVideo.LikeCount; + Sponsors = sponsors; + GuideHidden = true; - AddMeta("description", Video.Description); - AddMeta("author", Video.Channel.Title); - AddMeta("og:title", Video.Title); - AddMeta("og:description", Video.Description); - AddMeta("og:url", - $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); - AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - AddMeta("og:video:width", "640"); - AddMeta("og:video:height", "360"); - AddMeta("og:type", "video.other"); - AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); - AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); + AddMeta("description", Video.Description); + AddMeta("author", Video.Channel.Title); + AddMeta("og:title", Video.Title); + AddMeta("og:description", Video.Description); + AddMeta("og:url", + $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); + AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); + AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); + AddMeta("og:video:width", "640"); + AddMeta("og:video:height", "360"); + AddMeta("og:type", "video.other"); + AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); + AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); + AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - AddStylesheet("/lib/ltplayer.css"); + AddStylesheet("/lib/ltplayer.css"); - AddScript("/lib/ltplayer.js"); - AddScript("/lib/hls.js"); - AddScript("/js/player.js"); - } + AddScript("/lib/ltplayer.js"); + AddScript("/lib/hls.js"); + AddScript("/js/player.js"); + } - public WatchContext(HttpContext context, Exception e, InnerTubeNextResponse innerTubeNextResponse, - InnerTubeContinuationResponse? comments, int dislikes, int likes) : base(context) - { - Player = new PlayerContext(context, e); - Video = innerTubeNextResponse; - Playlist = Video.Playlist; - Comments = comments; - Dislikes = dislikes; - Likes = likes; - Sponsors = []; - GuideHidden = true; + public WatchContext(HttpContext context, Exception e, InnerTubeVideo innerTubeVideo, ContinuationResponse? comments, + int dislikes) : base(context) + { + Player = new PlayerContext(context, e); + Video = innerTubeVideo; + Playlist = Video.Playlist; + Comments = comments; + Dislikes = dislikes; + Likes = innerTubeVideo.LikeCount; + Sponsors = []; + GuideHidden = true; - AddMeta("description", Video.Description); - AddMeta("author", Video.Channel.Title); - AddMeta("og:title", Video.Title); - AddMeta("og:description", Video.Description); - AddMeta("og:url", - $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); - AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - AddMeta("og:video:width", "640"); - AddMeta("og:video:height", "360"); - AddMeta("og:type", "video.other"); - AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); - AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - } + AddMeta("description", Video.Description); + AddMeta("author", Video.Channel.Title); + AddMeta("og:title", Video.Title); + AddMeta("og:description", Video.Description); + AddMeta("og:url", + $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); + AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); + AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); + AddMeta("og:video:width", "640"); + AddMeta("og:video:height", "360"); + AddMeta("og:type", "video.other"); + AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); + AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); + AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); + } - public WatchContext(HttpContext context, InnerTubePlayer innerTubePlayer, - InnerTubeNextResponse innerTubeNextResponse, DatabasePlaylist? playlist, - InnerTubeContinuationResponse? comments, - bool compatibility, int dislikes, int likes, SponsorBlockSegment[] sponsors) : base(context) - { - Player = new PlayerContext(context, innerTubePlayer, innerTubeNextResponse, "embed", compatibility, - context.Request.Query["q"], sponsors); - Video = innerTubeNextResponse; - Playlist = playlist?.GetInnerTubePlaylistInfo(innerTubePlayer.Details.Id); - if (playlist != null && playlist.Visibility == PlaylistVisibility.PRIVATE) - if (playlist.Author != User?.UserID) - Playlist = null; - Comments = comments; - Dislikes = dislikes; - Likes = likes; - Sponsors = sponsors; - GuideHidden = true; + public WatchContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeVideo innerTubeVideo, + DatabasePlaylist? playlist, ContinuationResponse? comments, bool compatibility, int dislikes, + SponsorBlockSegment[] sponsors) : base(context) + { + Player = new PlayerContext(context, innerTubePlayer, innerTubeVideo, "embed", compatibility, + context.Request.Query["q"], sponsors); + Video = innerTubeVideo; + Playlist = playlist?.GetVideoPlaylistInfo(innerTubeVideo.Id, + DatabaseManager.Users.GetUserFromId(playlist.Author).Result!, + DatabaseManager.Playlists.GetPlaylistVideos(playlist.Id, Localization), + Localization); + if (playlist != null && playlist.Visibility == PlaylistVisibility.Private) + if (playlist.Author != User?.UserID) + Playlist = null; + Comments = comments; + Dislikes = dislikes; + Likes = innerTubeVideo.LikeCount; + Sponsors = sponsors; + GuideHidden = true; - AddMeta("description", Video.Description); - AddMeta("author", Video.Channel.Title); - AddMeta("og:title", Video.Title); - AddMeta("og:description", Video.Description); - AddMeta("og:url", - $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); - AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - AddMeta("og:video:width", "640"); - AddMeta("og:video:height", "360"); - AddMeta("og:type", "video.other"); - AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); - AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); + AddMeta("description", Video.Description); + AddMeta("author", Video.Channel.Title); + AddMeta("og:title", Video.Title); + AddMeta("og:description", Video.Description); + AddMeta("og:url", + $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); + AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); + AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); + AddMeta("og:video:width", "640"); + AddMeta("og:video:height", "360"); + AddMeta("og:type", "video.other"); + AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); + AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); + AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - AddStylesheet("/lib/ltplayer.css"); + AddStylesheet("/lib/ltplayer.css"); - AddScript("/lib/ltplayer.js"); - AddScript("/lib/hls.js"); - AddScript("/js/player.js"); - } + AddScript("/lib/ltplayer.js"); + AddScript("/lib/hls.js"); + AddScript("/js/player.js"); + } - public WatchContext(HttpContext context, Exception e, InnerTubeNextResponse innerTubeNextResponse, - DatabasePlaylist? playlist, - InnerTubeContinuationResponse? comments, int dislikes, int likes) : base(context) - { - Player = new PlayerContext(context, e); - Video = innerTubeNextResponse; - Playlist = playlist?.GetInnerTubePlaylistInfo(innerTubeNextResponse.Id); - if (playlist != null && playlist.Visibility == PlaylistVisibility.PRIVATE) - if (playlist.Author != User?.UserID) - Playlist = null; - Comments = comments; - Dislikes = dislikes; - Likes = likes; - Sponsors = []; - GuideHidden = true; + public WatchContext(HttpContext context, Exception e, InnerTubeVideo innerTubeVideo, DatabasePlaylist? playlist, + ContinuationResponse? comments, int dislikes) : base(context) + { + Player = new PlayerContext(context, e); + Video = innerTubeVideo; + Playlist = playlist?.GetVideoPlaylistInfo(innerTubeVideo.Id, + DatabaseManager.Users.GetUserFromId(playlist.Author).Result!, + DatabaseManager.Playlists.GetPlaylistVideos(playlist.Id, Localization), + Localization); + if (playlist != null && playlist.Visibility == PlaylistVisibility.Private) + if (playlist.Author != User?.UserID) + Playlist = null; + Comments = comments; + Dislikes = dislikes; + Likes = innerTubeVideo.LikeCount; + Sponsors = []; + GuideHidden = true; - AddMeta("description", Video.Description); - AddMeta("author", Video.Channel.Title); - AddMeta("og:title", Video.Title); - AddMeta("og:description", Video.Description); - AddMeta("og:url", - $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); - AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - AddMeta("og:video:width", "640"); - AddMeta("og:video:height", "360"); - AddMeta("og:type", "video.other"); - AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); - AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); - AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); - } + AddMeta("description", Video.Description); + AddMeta("author", Video.Channel.Title); + AddMeta("og:title", Video.Title); + AddMeta("og:description", Video.Description); + AddMeta("og:url", + $"{context.Request.Scheme}://{context.Request.Host}/{context.Request.Path}{context.Request.QueryString}"); + AddMeta("og:image", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); + AddMeta("og:video:url", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); + AddMeta("og:video:width", "640"); + AddMeta("og:video:height", "360"); + AddMeta("og:type", "video.other"); + AddMeta("twitter:card", $"{context.Request.Scheme}://{context.Request.Host}/proxy/thumbnail/{Video.Id}/-1"); + AddMeta("twitter:player", $"https://{context.Request.Host}/embed/{Video.Id}"); + AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/{Video.Id}/18.mp4"); + } } \ No newline at end of file diff --git a/LightTube/Controllers/ApiController.cs b/LightTube/Controllers/ApiController.cs index 6898fda9..aa3e4324 100644 --- a/LightTube/Controllers/ApiController.cs +++ b/LightTube/Controllers/ApiController.cs @@ -1,319 +1,373 @@ using System.Net; using System.Text.RegularExpressions; using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf.Params; +using InnerTube.Protobuf.Responses; +using InnerTube.Renderers; using LightTube.ApiModels; using LightTube.Attributes; using LightTube.Database; using LightTube.Database.Models; +using LightTube.Localization; using Microsoft.AspNetCore.Mvc; +using Endpoint = InnerTube.Protobuf.Endpoint; namespace LightTube.Controllers; [Route("/api")] -public class ApiController(InnerTube.InnerTube youtube) : Controller +public partial class ApiController(SimpleInnerTubeClient innerTube) : Controller { - private const string VIDEO_ID_REGEX = @"[a-zA-Z0-9_-]{11}"; - private const string CHANNEL_ID_REGEX = @"[a-zA-Z0-9_-]{24}"; - private const string PLAYLIST_ID_REGEX = @"[a-zA-Z0-9_-]{34}"; - private readonly InnerTube.InnerTube _youtube = youtube; - - [Route("info")] - public LightTubeInstanceInfo GetInstanceInfo() => - new() - { - Type = "lighttube/2.0", - Version = Utils.GetVersion(), - Messages = Configuration.Messages, - Alert = Configuration.Alert, - Config = new Dictionary - { - ["allowsApi"] = Configuration.ApiEnabled, - ["allowsNewUsers"] = Configuration.RegistrationEnabled, - ["allowsOauthApi"] = Configuration.OauthEnabled, - ["allowsThirdPartyProxyUsage"] = Configuration.ThirdPartyProxyEnabled - } - }; - - private ApiResponse Error(string message, int code, HttpStatusCode statusCode) - { - Response.StatusCode = (int)statusCode; - return new ApiResponse(statusCode == HttpStatusCode.BadRequest ? "BAD_REQUEST" : "ERROR", - message, code); - } - - [Route("player")] - [ApiDisableable] - public async Task> GetPlayerInfo(string? id, bool contentCheckOk = true, - bool includeHls = false) - { - if (id is null) - return Error("Missing video ID (query parameter `id`)", 400, - HttpStatusCode.BadRequest); - - Regex regex = new(VIDEO_ID_REGEX); - if (!regex.IsMatch(id) || id.Length != 11) - return Error($"Invalid video ID: {id}", 400, HttpStatusCode.BadRequest); - - try - { - InnerTubePlayer player = - await _youtube.GetPlayerAsync(id, contentCheckOk, includeHls, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - - DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); - ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); - userData?.AddInfoForChannel(player.Details.Author.Id); - - return new ApiResponse(player, userData); - } - catch (Exception e) - { - return Error(e.Message, 500, HttpStatusCode.InternalServerError); - } - } - - [Route("video")] - [ApiDisableable] - public async Task> GetVideoInfo( - string? id, - string? playlistId = null, - int? playlistIndex = null, - string? playlistParams = null) - { - if (id is null) - return Error("Missing video ID (query parameter `id`)", 400, - HttpStatusCode.BadRequest); - - Regex regex = new(VIDEO_ID_REGEX); - if (!regex.IsMatch(id) || id.Length != 11) - return Error($"Invalid video ID: {id}", 400, HttpStatusCode.BadRequest); - - try - { - InnerTubeNextResponse video = await _youtube.GetVideoAsync(id, playlistId, playlistIndex, playlistParams, - HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); - - DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); - ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); - userData?.AddInfoForChannel(video.Channel.Id); - userData?.CalculateWithRenderers(video.Recommended); - - return new ApiResponse(video, userData); - } - catch (Exception e) - { - return Error(e.Message, 500, HttpStatusCode.InternalServerError); - } - } - - [Route("search")] - [ApiDisableable] - public async Task> Search(string query, string? continuation = null) - { - if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(continuation)) - { - return Error( - "Missing query (query parameter `query`) or continuation key (query parameter `continuation`)", - 400, HttpStatusCode.BadRequest); - } - - - ApiSearchResults result; - if (continuation is null) - { - SearchParams searchParams = Request.GetSearchParams(); - InnerTubeSearchResults results = await _youtube.SearchAsync(query, searchParams, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - result = new ApiSearchResults(results, searchParams); - } - else - { - InnerTubeContinuationResponse results = await _youtube.ContinueSearchAsync(continuation, - HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - result = new ApiSearchResults(results); - } - - DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); - ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); - userData?.CalculateWithRenderers(result.SearchResults); - - return new ApiResponse(result, userData); - } - - [Route("searchSuggestions")] - [ApiDisableable] - public async Task> SearchSuggestions(string query) - { - if (string.IsNullOrWhiteSpace(query)) - return Error("Missing query (query parameter `query`)", 400, - HttpStatusCode.BadRequest); - try - { - DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); - ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); - return new ApiResponse(await _youtube.GetSearchAutocompleteAsync(query, - HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()), userData); - } - catch (Exception e) - { - return Error(e.Message, 500, HttpStatusCode.InternalServerError); - } - } - - [Route("playlist")] - [ApiDisableable] - public async Task> Playlist(string id, int? skip) - { - if (id.StartsWith("LT-PL")) - { - if (id.Length != 24) - return Error($"Invalid playlist ID: {id}", 400, HttpStatusCode.BadRequest); - } - else - { - Regex regex = new(PLAYLIST_ID_REGEX); - if (!regex.IsMatch(id) || id.Length != 34) - return Error($"Invalid playlist ID: {id}", 400, HttpStatusCode.BadRequest); - } - - - if (string.IsNullOrWhiteSpace(id) && skip is null) - return Error($"Invalid ID: {id}", 400, HttpStatusCode.BadRequest); - - try - { - DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); - ApiPlaylist result; - if (id.StartsWith("LT-PL")) - { - DatabasePlaylist? playlist = DatabaseManager.Playlists.GetPlaylist(id); - - if (playlist is null) - return Error("The playlist does not exist.", 500, - HttpStatusCode.InternalServerError); - - if (playlist.Visibility == PlaylistVisibility.PRIVATE) - { - if (user == null) - return Error("The playlist does not exist.", 500, - HttpStatusCode.InternalServerError); - - if (playlist.Author != user.UserID) - return Error("The playlist does not exist.", 500, - HttpStatusCode.InternalServerError); - } - - result = new ApiPlaylist(playlist); - } - else if (skip is null) - { - InnerTubePlaylist playlist = - await _youtube.GetPlaylistAsync(id, true, HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); - result = new ApiPlaylist(playlist); - } - else - { - InnerTubeContinuationResponse playlist = - await _youtube.ContinuePlaylistAsync(id, skip.Value, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - result = new ApiPlaylist(playlist); - } - - ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); - userData?.AddInfoForChannel(result.Channel.Id); - userData?.CalculateWithRenderers(result.Videos); - return new ApiResponse(result, userData); - } - catch (Exception e) - { - return Error(e.Message, 500, HttpStatusCode.InternalServerError); - } - } - - [Route("channel")] - [ApiDisableable] - public async Task> Channel(string id, ChannelTabs tab = ChannelTabs.Home, - string? searchQuery = null, string? continuation = null) - { - if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(continuation)) - return Error($"Invalid request: missing both `id` and `continuation`", 400, - HttpStatusCode.BadRequest); - - try - { - ApiChannel response; - if (id?.StartsWith("LT") ?? false) - { - DatabaseUser? localUser = await DatabaseManager.Users.GetUserFromLTId(id); - if (localUser is null) - throw new Exception("This user does not exist."); - response = new ApiChannel(localUser); - } - else if (continuation is null) - { - if (!id.StartsWith("UC")) - id = await _youtube.GetChannelIdFromVanity(id) ?? id; - - InnerTubeChannelResponse channel = await _youtube.GetChannelAsync(id, tab, searchQuery, - HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - response = new ApiChannel(channel); - } - else - { - InnerTubeContinuationResponse channel = await _youtube.ContinueChannelAsync(continuation); - response = new ApiChannel(channel); - } - - DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); - ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); - userData?.AddInfoForChannel(response.Id); - userData?.CalculateWithRenderers(response.Contents); - - return new ApiResponse(response, userData); - } - catch (Exception e) - { - return Error(e.Message, 500, HttpStatusCode.InternalServerError); - } - } - - [Route("comments")] - [ApiDisableable] - public async Task> Comments(string? continuation, string? id, - CommentsContext.Types.SortOrder sort = CommentsContext.Types.SortOrder.TopComments) - { - try - { - if (id != null && continuation == null) - continuation = InnerTube.Utils.PackCommentsContinuation(id, sort); - else if (id == null && continuation == null) - return Error( - "Invalid request, either 'continuation' or 'id' must be present", 400, - HttpStatusCode.BadRequest); - - InnerTubeContinuationResponse? comments = await _youtube.GetVideoCommentsAsync(continuation!, - HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); - - DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); - ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); - userData?.CalculateWithRenderers(comments.Contents); - - return new ApiResponse(comments, userData); - } - catch (Exception e) - { - return Error(e.Message, 500, HttpStatusCode.InternalServerError); - } - } - - [Route("locals")] - [ApiDisableable] - public async Task Locals() - { - InnerTubeLocals locals = await _youtube.GetLocalsAsync(); - return Json(locals); - } + private readonly Regex videoIdRegex = VideoIdRegex(); + + [Route("info")] + public LightTubeInstanceInfo GetInstanceInfo() => + new() + { + Type = "lighttube/2.0", + Version = Utils.GetVersion(), + Messages = Configuration.Messages, + Alert = Configuration.Alert, + Config = new Dictionary + { + ["allowsApi"] = Configuration.ApiEnabled, + ["allowsNewUsers"] = Configuration.RegistrationEnabled, + ["allowsOauthApi"] = Configuration.OauthEnabled, + ["allowsThirdPartyProxyUsage"] = Configuration.ThirdPartyProxyEnabled + } + }; + + private ApiResponse Error(string message, int code, HttpStatusCode statusCode) + { + Response.StatusCode = (int)statusCode; + return new ApiResponse(statusCode == HttpStatusCode.BadRequest ? "BAD_REQUEST" : "ERROR", + message, code); + } + + [Route("player")] + [ApiDisableable] + public async Task> GetPlayerInfo(string? id, bool contentCheckOk = true) + { + if (id is null) + return Error("Missing video ID (query parameter `id`)", 400, + HttpStatusCode.BadRequest); + + if (!videoIdRegex.IsMatch(id) || id.Length != 11) + return Error($"Invalid video ID: {id}", 400, HttpStatusCode.BadRequest); + + try + { + InnerTubePlayer player = await innerTube.GetVideoPlayerAsync(id, contentCheckOk, + HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + + DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); + userData?.AddInfoForChannel(player.Details.Author.Id); + + return new ApiResponse(player, userData); + } + catch (Exception e) + { + return Error(e.Message, 500, HttpStatusCode.InternalServerError); + } + } + + [Route("video")] + [ApiDisableable] + public async Task> GetVideoInfo( + string? id, + bool contentCheckOk = true, + string? playlistId = null, + int? playlistIndex = null, + string? playlistParams = null) + { + if (id is null) + return Error("Missing video ID (query parameter `id`)", 400, + HttpStatusCode.BadRequest); + + if (!videoIdRegex.IsMatch(id) || id.Length != 11) + return Error($"Invalid video ID: {id}", 400, HttpStatusCode.BadRequest); + + try + { + InnerTubeVideo video = await innerTube.GetVideoDetailsAsync(id, contentCheckOk, playlistId, + playlistIndex, playlistParams, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + + DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); + userData?.AddInfoForChannel(video.Channel.Id); + userData?.CalculateWithRenderers(video.Recommended); + + return new ApiResponse(video, userData); + } + catch (Exception e) + { + return Error(e.Message, 500, HttpStatusCode.InternalServerError); + } + } + + [Route("recommendations")] + [ApiDisableable] + public async Task> GetVideoRecommendations(string? id, string? continuation) + { + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(continuation)) + { + return Error( + "Missing video id (query parameter `id`) or continuation token (query parameter `continuation`)", + 400, HttpStatusCode.BadRequest); + } + + try + { + if (id != null) + { + InnerTubeVideo cont = await innerTube.GetVideoDetailsAsync(id, true, null, null, null, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + + DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); + userData?.CalculateWithRenderers(cont.Recommended); + + return new ApiResponse(new ContinuationResponse + { + ContinuationToken = + (cont.Recommended.FirstOrDefault(x => x.Type == "continuation")?.Data as + ContinuationRendererData) + ?.ContinuationToken, + Results = cont.Recommended.Where(x => x.Type != "continuation").ToArray() + }, userData); + } + else + { + ContinuationResponse cont = await innerTube.ContinueVideoRecommendationsAsync(continuation!, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + + DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); + userData?.CalculateWithRenderers(cont.Results); + + return new ApiResponse(cont, userData); + } + } + catch (Exception e) + { + return Error(e.Message, 500, HttpStatusCode.InternalServerError); + } + } + + [Route("search")] + [ApiDisableable] + public async Task> Search(string query, string? continuation = null, int? index = null) + { + if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(continuation)) + { + return Error( + "Missing query (query parameter `query`) or continuation token (query parameter `continuation`)", + 400, HttpStatusCode.BadRequest); + } + + + ApiSearchResults result; + if (continuation is null) + { + SearchParams searchParams = Request.GetSearchParams(); + if (index != null) + searchParams.Index = index.Value; + InnerTubeSearchResults results = await innerTube.SearchAsync(query, searchParams, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + result = new ApiSearchResults(results, searchParams); + } + else + { + ContinuationResponse results = await innerTube.ContinueSearchAsync(continuation, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + result = new ApiSearchResults(results); + } + + DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); + userData?.CalculateWithRenderers(result.Results); + + return new ApiResponse(result, userData); + } + + [Route("searchSuggestions")] + [ApiDisableable] + public async Task> SearchSuggestions(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return Error("Missing query (query parameter `query`)", 400, + HttpStatusCode.BadRequest); + try + { + DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); + return new ApiResponse(await SearchAutocomplete.GetAsync(query, + HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()), userData); + } + catch (Exception e) + { + return Error(e.Message, 500, HttpStatusCode.InternalServerError); + } + } + + [Route("playlist")] + [ApiDisableable] + public async Task> Playlist(string? id, PlaylistFilter filter = PlaylistFilter.All, + string? continuation = null) + { + if (string.IsNullOrWhiteSpace(id) && continuation is null) + return Error($"Invalid ID: {id}", 400, HttpStatusCode.BadRequest); + + try + { + DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); + ApiPlaylist result; + if (id?.StartsWith("LT-PL") == true) + { + if (id.Length != 24) + return Error($"Invalid playlist ID: {id}", 400, HttpStatusCode.BadRequest); + + DatabasePlaylist? playlist = DatabaseManager.Playlists.GetPlaylist(id); + + if (playlist is null) + return Error("The playlist does not exist.", 404, + HttpStatusCode.InternalServerError); + + if (playlist.Visibility == PlaylistVisibility.Private) + { + if (playlist.Author != user?.UserID) + return Error("The playlist does not exist.", 404, + HttpStatusCode.InternalServerError); + } + + result = new ApiPlaylist(playlist, (await DatabaseManager.Users.GetUserFromId(playlist.Author))!, + LocalizationManager.GetFromHttpContext(HttpContext), user); + } + else if (continuation is null) + { + InnerTubePlaylist playlist = + await innerTube.GetPlaylistAsync(id, true, filter, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + result = new ApiPlaylist(playlist); + } + else + { + ContinuationResponse playlist = await innerTube.ContinuePlaylistAsync(continuation, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + result = new ApiPlaylist(playlist); + } + + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); + userData?.AddInfoForChannel(result.Sidebar?.Channel?.Id); + userData?.CalculateWithRenderers(result.Contents); + return new ApiResponse(result, userData); + } + catch (Exception e) + { + return Error(e.Message, 500, HttpStatusCode.InternalServerError); + } + } + + [Route("channel")] + [ApiDisableable] + public async Task> Channel(string id, ChannelTabs tab = ChannelTabs.Featured, + string? continuation = null) + { + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(continuation)) + return Error($"Invalid request: missing both `id` and `continuation`", 400, + HttpStatusCode.BadRequest); + + if (id.StartsWith("@")) + { + ResolveUrlResponse endpoint = await innerTube.ResolveUrl("https://youtube.com/@" + id); + if (endpoint.Endpoint.EndpointTypeCase == Endpoint.EndpointTypeOneofCase.BrowseEndpoint) + id = endpoint.Endpoint.BrowseEndpoint.BrowseId; + } + else if (!id.StartsWith("UC")) + { + ResolveUrlResponse endpoint = await innerTube.ResolveUrl("https://youtube.com/c/" + id); + if (endpoint.Endpoint.EndpointTypeCase == Endpoint.EndpointTypeOneofCase.BrowseEndpoint) + id = endpoint.Endpoint.BrowseEndpoint.BrowseId; + } + + try + { + ApiChannel response; + if (id?.StartsWith("LT") ?? false) + { + DatabaseUser? localUser = await DatabaseManager.Users.GetUserFromLTId(id); + if (localUser is null) + return Error("This user does not exist", 404, HttpStatusCode.BadRequest); + response = new ApiChannel(localUser, LocalizationManager.GetFromHttpContext(HttpContext)); + } + else if (continuation is null && id != null) + { + if (!id.StartsWith("UC")) + return Error("resolveUrl not implemented yet", 501, HttpStatusCode.NotImplemented); + //id = await innerTube.ResolveUrl(id) ?? id; + + InnerTubeChannel channel = await innerTube.GetChannelAsync(id, tab, + HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + response = new ApiChannel(channel); + } + else + { + ContinuationResponse channel = await innerTube.ContinueChannelAsync(continuation, + HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + response = new ApiChannel(channel); + } + + DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); + userData?.AddInfoForChannel(response.Metadata?.Id); + userData?.CalculateWithRenderers(response.Contents); + + return new ApiResponse(response, userData); + } + catch (Exception e) + { + return Error(e.Message, 500, HttpStatusCode.InternalServerError); + } + } + + [Route("comments")] + [ApiDisableable] + public async Task> Comments(string? continuation, string? id, + CommentsContext.Types.SortOrder sort = CommentsContext.Types.SortOrder.TopComments) + { + try + { + if (id != null && continuation == null) + continuation = InnerTube.Utils.PackCommentsContinuation(id, sort); + else if (id == null && continuation == null) + return Error( + "Invalid request, either 'continuation' or 'id' must be present", 400, + HttpStatusCode.BadRequest); + + ContinuationResponse? comments = await innerTube.ContinueVideoCommentsAsync(continuation!); + + DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); + userData?.CalculateWithRenderers(comments.Results); + + return new ApiResponse(comments, userData); + } + catch (Exception e) + { + return Error(e.Message, 500, HttpStatusCode.InternalServerError); + } + } + + [Route("locals")] + [ApiDisableable] + public async Task> Locals() => new(Utils.GetLocals(), + ApiUserData.GetFromDatabaseUser(await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request))); + [GeneratedRegex("[a-zA-Z0-9_-]{11}")] + private static partial Regex VideoIdRegex(); } \ No newline at end of file diff --git a/LightTube/Controllers/ExportController.cs b/LightTube/Controllers/ExportController.cs index 2416cf5c..a9100bc1 100644 --- a/LightTube/Controllers/ExportController.cs +++ b/LightTube/Controllers/ExportController.cs @@ -21,7 +21,7 @@ public IActionResult LightTubeExport() Type = $"LightTube/{Utils.GetVersion()}", Host = Request.Host.ToString(), Subscriptions = [.. context.User.Subscriptions.Keys], - Playlists = DatabaseManager.Playlists.GetUserPlaylists(context.User.UserID, PlaylistVisibility.PRIVATE) + Playlists = DatabaseManager.Playlists.GetUserPlaylists(context.User.UserID, PlaylistVisibility.Private) .Select(x => new ImportedData.Playlist { Title = x.Name, diff --git a/LightTube/Controllers/FeedController.cs b/LightTube/Controllers/FeedController.cs index 16cb2bae..f0b54554 100644 --- a/LightTube/Controllers/FeedController.cs +++ b/LightTube/Controllers/FeedController.cs @@ -1,8 +1,8 @@ -using System.Diagnostics; using System.Text; using System.Web; using System.Xml; using InnerTube; +using InnerTube.Models; using LightTube.Contexts; using LightTube.Database; using LightTube.Database.Models; @@ -12,80 +12,70 @@ namespace LightTube.Controllers; [Route("/feed")] -public class FeedController(InnerTube.InnerTube youtube) : Controller +public class FeedController(SimpleInnerTubeClient innerTube) : Controller { - private InnerTube.InnerTube _youtube = youtube; - - [Route("channel/{c}/rss.xml")] + + [Route("channel/{c}.xml")] [HttpGet] public async Task ChannelFeed(string c) { - ChannelFeed ytchannel = await YoutubeRSS.GetChannelFeed(c); - try - { - XmlDocument document = new(); - XmlElement rss = document.CreateElement("rss"); - rss.SetAttribute("version", "2.0"); - - XmlElement channel = document.CreateElement("channel"); - - XmlElement title = document.CreateElement("title"); - title.InnerText = "LightTube channnel RSS feed for " + ytchannel.Name; - channel.AppendChild(title); - - XmlElement description = document.CreateElement("description"); - description.InnerText = $"LightTube channnel RSS feed for {ytchannel.Name}"; - channel.AppendChild(description); + ChannelFeed ytchannel = await YoutubeRss.GetChannelFeed(c); + XmlDocument document = new(); + XmlElement rss = document.CreateElement("rss"); + rss.SetAttribute("version", "2.0"); - foreach (FeedVideo video in ytchannel.Videos.Take(15)) - { - XmlElement item = document.CreateElement("item"); + XmlElement channel = document.CreateElement("channel"); - XmlElement id = document.CreateElement("id"); - id.InnerText = $"id:video:{video.Id}"; - item.AppendChild(id); + XmlElement title = document.CreateElement("title"); + title.InnerText = ytchannel.Name; + channel.AppendChild(title); - XmlElement vtitle = document.CreateElement("title"); - vtitle.InnerText = video.Title; - item.AppendChild(vtitle); + XmlElement description = document.CreateElement("description"); + description.InnerText = $"LightTube channel RSS feed for {ytchannel.Name}"; + channel.AppendChild(description); - XmlElement vdescription = document.CreateElement("description"); - vdescription.InnerText = video.Description; - item.AppendChild(vdescription); + foreach (FeedVideo video in ytchannel.Videos.Take(15)) + { + XmlElement item = document.CreateElement("item"); - XmlElement link = document.CreateElement("link"); - link.InnerText = $"https://{Request.Host}/watch?v={video.Id}"; - item.AppendChild(link); + XmlElement id = document.CreateElement("id"); + id.InnerText = $"id:video:{video.Id}"; + item.AppendChild(id); - XmlElement published = document.CreateElement("pubDate"); - published.InnerText = video.PublishedDate.ToString("R"); - item.AppendChild(published); + XmlElement vtitle = document.CreateElement("title"); + vtitle.InnerText = video.Title; + item.AppendChild(vtitle); - XmlElement author = document.CreateElement("author"); + XmlElement vdescription = document.CreateElement("description"); + vdescription.InnerText = video.Description; + item.AppendChild(vdescription); - XmlElement name = document.CreateElement("name"); - name.InnerText = video.ChannelName; - author.AppendChild(name); + XmlElement link = document.CreateElement("link"); + link.InnerText = $"https://{Request.Host}/watch?v={video.Id}"; + item.AppendChild(link); - XmlElement uri = document.CreateElement("uri"); - uri.InnerText = $"https://{Request.Host}/channel/{video.ChannelId}"; - author.AppendChild(uri); + XmlElement published = document.CreateElement("pubDate"); + published.InnerText = video.PublishedDate.ToString("R"); + item.AppendChild(published); - item.AppendChild(author); - channel.AppendChild(item); - } + XmlElement author = document.CreateElement("author"); - rss.AppendChild(channel); + XmlElement name = document.CreateElement("name"); + name.InnerText = video.ChannelName; + author.AppendChild(name); - document.AppendChild(rss); - return File(Encoding.UTF8.GetBytes(document.OuterXml), "application/xml"); + XmlElement uri = document.CreateElement("uri"); + uri.InnerText = $"https://{Request.Host}/channel/{video.ChannelId}"; + author.AppendChild(uri); + item.AppendChild(author); + channel.AppendChild(item); } - catch (Exception) - { - throw; - } + rss.AppendChild(channel); + + document.AppendChild(rss); + return File(Encoding.UTF8.GetBytes(document.OuterXml), "application/xml"); } [Route("subscriptions")] @@ -93,7 +83,7 @@ public async Task Subscription() { SubscriptionsContext ctx = new(HttpContext); if (ctx.User is null) return Redirect("/account/login?redirectUrl=%2ffeed%2fsubscriptions"); - ctx.Videos = await YoutubeRSS.GetMultipleFeeds(ctx.User.Subscriptions.Keys); + ctx.Videos = await YoutubeRss.GetMultipleFeeds(ctx.User.Subscriptions.Keys); return View(ctx); } @@ -114,7 +104,7 @@ public async Task RssFeed() string username = secretDecoded.Split(':')[0]; string password = secretDecoded.Split(':')[1]; DatabaseUser? user = await DatabaseManager.Users.GetUserFromUsernamePassword(username, password) ?? throw new Exception(); - FeedVideo[] feedVideos = await YoutubeRSS.GetMultipleFeeds(user.Subscriptions.Where(x => x.Value == SubscriptionType.NOTIFICATIONS_ON).Select(x => x.Key)); + FeedVideo[] feedVideos = await YoutubeRss.GetMultipleFeeds(user.Subscriptions.Where(x => x.Value == SubscriptionType.NOTIFICATIONS_ON).Select(x => x.Key)); XmlDocument document = new(); XmlElement rss = document.CreateElement("rss"); @@ -258,8 +248,9 @@ public async Task NewPlaylist(string title, string description, P DatabasePlaylist pl = await DatabaseManager.Playlists.CreatePlaylist(Request.Cookies["token"], title, description, visibility); if (firstVideo != null) { - InnerTubePlayer video = await _youtube.GetPlayerAsync(firstVideo); - await DatabaseManager.Playlists.AddVideoToPlaylist(Request.Cookies["token"], pl.Id, video); + InnerTubePlayer v = await innerTube.GetVideoPlayerAsync(firstVideo, true, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + await DatabaseManager.Playlists.AddVideoToPlaylist(Request.Cookies["token"], pl.Id, v); } return Ok(LocalizationManager.GetFromHttpContext(HttpContext).GetRawString("modal.close")); } @@ -356,10 +347,11 @@ public async Task DeletePlaylist(string id, string title, string [Route("/addToPlaylist")] public async Task AddToPlaylist(string v) { - InnerTubeNextResponse intr = await _youtube.GetVideoAsync(v); - PlaylistVideoContext> pvc = new(HttpContext, intr); + InnerTubeVideo videoDetails = await innerTube.GetVideoDetailsAsync(v, true, null, null, null, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + PlaylistVideoContext> pvc = new(HttpContext, videoDetails); if (pvc.User is null) return Redirect("/account/login?redirectUrl=" + HttpUtility.UrlEncode(Request.Path + Request.Query)); - pvc.Extra = DatabaseManager.Playlists.GetUserPlaylists(pvc.User.UserID, PlaylistVisibility.PRIVATE); + pvc.Extra = DatabaseManager.Playlists.GetUserPlaylists(pvc.User.UserID, PlaylistVisibility.Private); pvc.Buttons = [ new ModalButton("", "|", ""), @@ -377,7 +369,8 @@ public async Task AddToPlaylist(string playlist, string video) { if (playlist == "__NEW") return Redirect("/newPlaylist?v=" + video); - InnerTubePlayer v = await _youtube.GetPlayerAsync(video); + InnerTubePlayer v = await innerTube.GetVideoPlayerAsync(video, true, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); await DatabaseManager.Playlists.AddVideoToPlaylist(Request.Cookies["token"], playlist, v); return Ok(LocalizationManager.GetFromHttpContext(HttpContext).GetRawString("modal.close")); } diff --git a/LightTube/Controllers/HomeController.cs b/LightTube/Controllers/HomeController.cs index 3d3860c1..a741d070 100644 --- a/LightTube/Controllers/HomeController.cs +++ b/LightTube/Controllers/HomeController.cs @@ -24,8 +24,7 @@ public IActionResult CustomCss() if (fileName == null) return NotFound(); - using FileStream fs = System.IO.File.OpenRead(fileName); - return File(fs, "text/css"); + return File(System.IO.File.ReadAllBytes(fileName), "text/css"); } [Route("/lib/{name}")] @@ -53,4 +52,10 @@ public IActionResult DismissAlert(string redirectUrl) }); return Redirect(redirectUrl); } + + [Route("/{videoId:regex([[a-zA-Z0-9-_]]{{11}})}")] + public IActionResult VideoRedirect(string videoId) + { + return Redirect($"/watch?v={videoId}"); + } } \ No newline at end of file diff --git a/LightTube/Controllers/MediaController.cs b/LightTube/Controllers/MediaController.cs index f8409910..a988bdbe 100644 --- a/LightTube/Controllers/MediaController.cs +++ b/LightTube/Controllers/MediaController.cs @@ -4,6 +4,8 @@ using System.Web; using InnerTube; using InnerTube.Exceptions; +using InnerTube.Models; +using InnerTube.Protobuf.Responses; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Primitives; using Serilog; @@ -11,10 +13,9 @@ namespace LightTube.Controllers; [Route("/proxy")] -public class ProxyController(InnerTube.InnerTube youtube) : Controller +public class ProxyController(SimpleInnerTubeClient innerTube) : Controller { - private readonly InnerTube.InnerTube _youtube = youtube; - private readonly HttpClient client = new(); + private readonly HttpClient client = new(); private string[] _blockedHeaders = [ @@ -38,15 +39,16 @@ public async Task Media(string videoId, string formatId, string? audioTrackId, s if (!Configuration.ProxyEnabled) { Response.StatusCode = (int)HttpStatusCode.NotFound; - await Response.Body.WriteAsync(Encoding.UTF8.GetBytes("This instance has disabled media proxies.")); + await Response.Body.WriteAsync("This instance has disabled media proxies."u8.ToArray()); await Response.StartAsync(); } try { - InnerTubePlayer player = await _youtube.GetPlayerAsync(videoId, true, false); + InnerTubePlayer player = await innerTube.GetVideoPlayerAsync(videoId, true, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); List formats = [.. player.Formats, .. player.AdaptiveFormats]; - if (formats.All(x => x.Itag != formatId)) + if (formats.All(x => x.Itag.ToString() != formatId)) { Response.StatusCode = (int)HttpStatusCode.NotFound; await Response.Body.WriteAsync(Encoding.UTF8.GetBytes( @@ -57,8 +59,8 @@ public async Task Media(string videoId, string formatId, string? audioTrackId, s Format format = !string.IsNullOrWhiteSpace(audioTrackId) ? formats.First(x => x.AudioTrack?.Id == audioTrackId) - : formats.OrderBy(x => !x.AudioTrack?.AudioIsDefault).First(x => x.Itag == formatId); - string url = format.Url.ToString(); + : formats.OrderBy(x => !x.AudioTrack?.AudioIsDefault).First(x => x.Itag.ToString() == formatId); + string url = format.Url; if (!url.StartsWith("http://") && !url.StartsWith("https://")) url = "https://" + url; @@ -131,13 +133,14 @@ public async Task Media(string videoId, string formatId, string? audioTrackId, s try { - InnerTubePlayer player = await _youtube.GetPlayerAsync(videoId, true, true); + InnerTubePlayer player = await innerTube.GetVideoPlayerAsync(videoId, true, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); - if (player.HlsManifestUrl == null) + if (string.IsNullOrEmpty(player.HlsManifestUrl)) { Response.StatusCode = (int)HttpStatusCode.NotFound; return File( - new MemoryStream(Encoding.UTF8.GetBytes("This video does not have a valid HLS manifest URL")), + new MemoryStream("This video does not have a valid HLS manifest URL"u8.ToArray()), "text/plain"); } @@ -168,7 +171,8 @@ public async Task Media(string videoId, string formatId, string? audioTrackId, s try { - InnerTubePlayer player = await _youtube.GetPlayerAsync(videoId, true, false); + InnerTubePlayer player = await innerTube.GetVideoPlayerAsync(videoId, true, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); string manifest = Utils.GetDashManifest(player, useProxy ? $"https://{Request.Host}/proxy" : null, skipCaptions); @@ -244,7 +248,7 @@ public async Task HlsSegmentProxy(string path) if (!Configuration.ProxyEnabled) { Response.StatusCode = (int)HttpStatusCode.NotFound; - await Response.Body.WriteAsync(Encoding.UTF8.GetBytes("This instance has disabled media proxies.")); + await Response.Body.WriteAsync("This instance has disabled media proxies."u8.ToArray()); await Response.StartAsync(); } @@ -304,7 +308,8 @@ public async Task SubtitleProxy(string videoId, string vssId) { try { - InnerTubePlayer player = await _youtube.GetPlayerAsync(videoId); + InnerTubePlayer player = await innerTube.GetVideoPlayerAsync(videoId, true, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); InnerTubePlayer.VideoCaption? subtitle = player.Captions.FirstOrDefault(x => x.VssId == vssId); @@ -355,7 +360,7 @@ public async Task ThumbnailProxy(string videoId, int index = 0) if (!Configuration.ProxyEnabled) { Response.StatusCode = (int)HttpStatusCode.NotFound; - await Response.Body.WriteAsync(Encoding.UTF8.GetBytes("This instance has disabled media proxies.")); + await Response.Body.WriteAsync("This instance has disabled media proxies."u8.ToArray()); await Response.StartAsync(); } @@ -402,11 +407,12 @@ public async Task StoryboardProxy(string videoId) { try { - InnerTubePlayer player = await _youtube.GetPlayerAsync(videoId); + InnerTubePlayer player = await innerTube.GetVideoPlayerAsync(videoId, true, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); if (!player.Storyboard.Levels.Any()) { Response.StatusCode = (int)HttpStatusCode.NotFound; - await Response.Body.WriteAsync(Encoding.UTF8.GetBytes("No usable storyboard found.")); + await Response.Body.WriteAsync("No usable storyboard found."u8.ToArray()); await Response.StartAsync(); return; } @@ -449,15 +455,16 @@ public async Task VttThumbnailProxy(string videoId) { try { - InnerTubePlayer player = await _youtube.GetPlayerAsync(videoId); + InnerTubePlayer player = await innerTube.GetVideoPlayerAsync(videoId, true, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); if (!player.Storyboard.Levels.Any()) { Response.StatusCode = (int)HttpStatusCode.NotFound; - return File(new MemoryStream(Encoding.UTF8.GetBytes("No usable storyboard found.")), "text/plain"); + return File(new MemoryStream("No usable storyboard found."u8.ToArray()), "text/plain"); } string url = player.Storyboard.Levels[0].ToString(); - TimeSpan duration = player.Details.Length; + TimeSpan duration = player.Details.Length!.Value; StringBuilder manifest = new(); double timeBetween = duration.TotalMilliseconds / 100; diff --git a/LightTube/Controllers/OauthApiController.cs b/LightTube/Controllers/OauthApiController.cs index b3c50100..2ccdcccf 100644 --- a/LightTube/Controllers/OauthApiController.cs +++ b/LightTube/Controllers/OauthApiController.cs @@ -1,20 +1,23 @@ using System.Net; using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf; using InnerTube.Renderers; using LightTube.ApiModels; using LightTube.Attributes; +using LightTube.CustomRendererDatas; using LightTube.Database; using LightTube.Database.Models; +using LightTube.Localization; using Microsoft.AspNetCore.Mvc; namespace LightTube.Controllers; [Route("/api")] [ApiDisableable] -public class OauthApiController(InnerTube.InnerTube youtube) : Controller +public class OauthApiController(SimpleInnerTubeClient innerTube) : Controller { - private readonly InnerTube.InnerTube _youtube = youtube; - + private ApiResponse Error(string message, int code, HttpStatusCode statusCode) { @@ -44,13 +47,14 @@ public async Task> GetCurrentUser() [Route("playlists")] [HttpGet] [ApiAuthorization("playlists.read")] - public async Task>> GetPlaylists() + public async Task>> GetPlaylists() { DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); - if (user is null) return Error>("Unauthorized", 401, HttpStatusCode.Unauthorized); + if (user is null) return Error>("Unauthorized", 401, HttpStatusCode.Unauthorized); ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); - return new ApiResponse>(user.PlaylistRenderers(PlaylistVisibility.PRIVATE).Items, + return new ApiResponse>( + user.PlaylistRenderers(LocalizationManager.GetFromHttpContext(HttpContext), PlaylistVisibility.Private), userData); } @@ -71,7 +75,7 @@ public async Task> CreatePlaylist([FromBody] Creat ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); DatabasePlaylist playlist = await DatabaseManager.Playlists.CreatePlaylist( Request.Headers.Authorization.ToString(), request.Title, - request.Description ?? "", request.Visibility ?? PlaylistVisibility.PRIVATE); + request.Description ?? "", request.Visibility ?? PlaylistVisibility.Private); return new ApiResponse(playlist, userData); } catch (Exception e) @@ -96,8 +100,8 @@ public async Task> UpdatePlaylist(string id, [From { ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); await DatabaseManager.Playlists.EditPlaylist( - Request.Headers.Authorization.ToString()!, id, request.Title, - request.Description ?? "", request.Visibility ?? PlaylistVisibility.PRIVATE); + Request.Headers.Authorization.ToString(), id, request.Title, + request.Description ?? "", request.Visibility ?? PlaylistVisibility.Private); DatabasePlaylist playlist = DatabaseManager.Playlists.GetPlaylist(id)!; return new ApiResponse(playlist, userData); } @@ -138,7 +142,8 @@ public async Task> DeletePlaylist(string id) try { - InnerTubePlayer video = await _youtube.GetPlayerAsync(videoId); + InnerTubePlayer video = await innerTube.GetVideoPlayerAsync(videoId, true, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); await DatabaseManager.Playlists.AddVideoToPlaylist( Request.Headers.Authorization.ToString(), @@ -214,22 +219,76 @@ public async Task> DeleteVideoFromPlaylist(string playlistId [Route("feed")] [HttpGet] [ApiAuthorization("subscriptions.read")] - public async Task> GetSubscriptionFeed( + public async Task>> GetSubscriptionFeed( bool includeNonNotification = true, int limit = 10, int skip = 0) { DatabaseUser? user = await DatabaseManager.Oauth2.GetUserFromHttpRequest(Request); if (user is null) - return Error("Unauthorized", 401, HttpStatusCode.Unauthorized); + return Error>("Unauthorized", 401, HttpStatusCode.Unauthorized); + + Dictionary avatars = []; + foreach (string id in user.Subscriptions.Keys) + { + DatabaseChannel? channel = DatabaseManager.Cache.GetChannel(id); + if (channel is null) continue; + avatars.Add(id, channel.IconUrl); + } FeedVideo[] feed = includeNonNotification - ? await YoutubeRSS.GetMultipleFeeds(user.Subscriptions.Keys) - : await YoutubeRSS.GetMultipleFeeds(user.Subscriptions.Where(x => - x.Value == SubscriptionType.NOTIFICATIONS_OFF).Select(x => x.Key)); + ? await YoutubeRss.GetMultipleFeeds(user.Subscriptions.Keys) + : await YoutubeRss.GetMultipleFeeds(user.Subscriptions.Where(x => + x.Value == SubscriptionType.NOTIFICATIONS_ON).Select(x => x.Key)); feed = feed.Skip(skip).Take(limit).ToArray(); + IEnumerable renderers = feed.Select(x => new RendererContainer + { + Type = "video", + OriginalType = "lightTubeFeedVideo", + Data = new SubscriptionFeedVideoRendererData + { + VideoId = x.Id, + Title = x.Title, + Thumbnails = + [ + new Thumbnail + { + Url = x.Thumbnail, + Width = 0, + Height = 0 + } + ], + Author = new Channel("en", + x.ChannelId, + x.ChannelName, + null, + avatars.TryGetValue(x.ChannelId, out string? avatarUrl) + ? [ + new Thumbnail + { + Url = avatarUrl, + Width = 0, + Height = 0 + } + ] + : null, + null, + null), + Duration = TimeSpan.Zero, + PublishedText = x.PublishedDate.ToString("D"), + RelativePublishedDate = Utils.ToRelativePublishedDate(x.PublishedDate), + ViewCountText = + string.Format(LocalizationManager.GetFromHttpContext(HttpContext).GetRawString("channel.about.views"), x.ViewCount.ToString("N0")), + ViewCount = x.ViewCount, + Badges = [], + Description = x.Description, + PremiereStartTime = null, + ExactPublishDate = x.PublishedDate + } + }); + ApiUserData? userData = ApiUserData.GetFromDatabaseUser(user); - return new ApiResponse(feed, userData); + return new ApiResponse>(renderers, userData); } [Route("subscriptions")] @@ -252,9 +311,9 @@ public async Task> DeleteVideoFromPlaylist(string playlistId : SubscriptionType.NOTIFICATIONS_OFF : SubscriptionType.NONE; - InnerTubeChannelResponse channel = await _youtube.GetChannelAsync(req.ChannelId); + InnerTubeChannel channel = await innerTube.GetChannelAsync(req.ChannelId); (string? channelId, SubscriptionType subscriptionType) = await DatabaseManager.Users.UpdateSubscription( - Request.Headers.Authorization.ToString()!, req.ChannelId, + Request.Headers.Authorization.ToString(), req.ChannelId, type); if (req.Subscribed) await DatabaseManager.Cache.AddChannel(new DatabaseChannel(channel)); @@ -283,9 +342,9 @@ public async Task> Unsubscribe(string id try { - InnerTubeChannelResponse channel = await _youtube.GetChannelAsync(id); + InnerTubeChannel channel = await innerTube.GetChannelAsync(id); (string? channelId, SubscriptionType type) = await DatabaseManager.Users.UpdateSubscription( - Request.Headers.Authorization.ToString()!, id, + Request.Headers.Authorization.ToString(), id, SubscriptionType.NONE); userData?.Channels.Add(channelId, new ApiSubscriptionInfo(type)); return new ApiResponse(new UpdateSubscriptionResponse(channel, type), userData); diff --git a/LightTube/Controllers/OpenSearchController.cs b/LightTube/Controllers/OpenSearchController.cs index 52d35954..46e7eb98 100644 --- a/LightTube/Controllers/OpenSearchController.cs +++ b/LightTube/Controllers/OpenSearchController.cs @@ -1,16 +1,13 @@ using System.Text; using System.Web; using System.Xml; -using InnerTube; using Microsoft.AspNetCore.Mvc; namespace LightTube.Controllers; [Route("/opensearch")] -public class OpenSearchController(InnerTube.InnerTube youtube) : Controller +public class OpenSearchController : Controller { - private readonly InnerTube.InnerTube _youtube = youtube; - [Route("osdd.xml")] public IActionResult OpenSearchDescriptionDocument() { @@ -65,10 +62,10 @@ public IActionResult OpenSearchDescriptionDocument() } [Route("suggestions.json")] - public async Task Suggestions(string q) + public async Task Suggestions(string q, string hl = "en", string gl = "us") { object[] res = [q, new List(), new List(), new List()]; - InnerTubeSearchAutocomplete autocomplete = await _youtube.GetSearchAutocompleteAsync(q); + SearchAutocomplete autocomplete = await SearchAutocomplete.GetAsync(q); foreach (string s in autocomplete.Autocomplete) { (res[1] as List)!.Add(s); diff --git a/LightTube/Controllers/SettingsController.cs b/LightTube/Controllers/SettingsController.cs index 7844e056..811e6e0b 100644 --- a/LightTube/Controllers/SettingsController.cs +++ b/LightTube/Controllers/SettingsController.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using InnerTube; +using InnerTube.Models; +using LightTube.ApiModels; using LightTube.Contexts; using LightTube.Database; using LightTube.Database.Models; @@ -10,21 +12,19 @@ namespace LightTube.Controllers; [Route("/settings")] -public class SettingsController(InnerTube.InnerTube youtube) : Controller +public class SettingsController(SimpleInnerTubeClient innerTube) : Controller { - private readonly InnerTube.InnerTube _youtube = youtube; - [Route("/settings")] public IActionResult Settings() => RedirectPermanent("/settings/appearance"); [Route("content")] - public async Task Content() => RedirectPermanent("/settings/appearance"); + public IActionResult Content() => RedirectPermanent("/settings/appearance"); [Route("appearance")] [HttpGet] - public async Task Appearance() + public IActionResult Appearance() { - InnerTubeLocals locals = await _youtube.GetLocalsAsync(); + ApiLocals locals = Utils.GetLocals(); AppearanceSettingsContext ctx = new(HttpContext, locals, Configuration.CustomThemeDefs, LocalizationManager.GetAllLanguages()); return View(ctx); } @@ -148,7 +148,7 @@ public IActionResult ImportExport() { try { - InnerTubePlayer video = await _youtube.GetPlayerAsync(id, true); + InnerTubePlayer video = await innerTube.GetVideoPlayerAsync(id, true); videoNexts.Add(id, video); } catch (Exception e) @@ -180,16 +180,15 @@ public IActionResult ImportExport() playlist.Description, playlist.Visibility); foreach (string video in playlist.VideoIds) { - if (!videoNexts.ContainsKey(video)) continue; + if (!videoNexts.TryGetValue(video, out InnerTubePlayer? next)) continue; await DatabaseManager.Playlists.AddVideoToPlaylist(token, pl.Id, - videoNexts[video]); + next); } } }); return View(new ImportContext(HttpContext, - $"Import process started. It might take a few minutes for all the content to appear on your account\n{channelIds.Length} channels, {playlists.Length} playlists, {videos.Length} videos", - false)); + $"Import process started. It might take a few minutes for all the content to appear on your account\n{channelIds.Length} channels, {playlists.Length} playlists, {videos.Length} videos")); } catch (Exception e) { diff --git a/LightTube/Controllers/YoutubeController.cs b/LightTube/Controllers/YoutubeController.cs index cf9b6538..46f1a4b4 100644 --- a/LightTube/Controllers/YoutubeController.cs +++ b/LightTube/Controllers/YoutubeController.cs @@ -1,307 +1,343 @@ using System.Text.Json; using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf.Params; +using InnerTube.Protobuf.Responses; using LightTube.Contexts; using LightTube.Database; using LightTube.Database.Models; using LightTube.Localization; using Microsoft.AspNetCore.Mvc; +using Serilog; +using Endpoint = InnerTube.Protobuf.Endpoint; namespace LightTube.Controllers; -public class YoutubeController(InnerTube.InnerTube youtube, HttpClient client) : Controller +public class YoutubeController(SimpleInnerTubeClient innerTube, HttpClient client) : Controller { - private readonly InnerTube.InnerTube _youtube = youtube; - private readonly HttpClient _client = client; - - [Route("/embed/{v}")] - public async Task Embed(string v, bool contentCheckOk, bool compatibility = false) - { - InnerTubePlayer player; - Exception? e; - try - { - player = await _youtube.GetPlayerAsync(v, contentCheckOk, false, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - e = null; - } - catch (Exception ex) - { - player = null; - e = ex; - } - - SponsorBlockSegment[] sponsors; - try - { - sponsors = await SponsorBlockSegment.GetSponsors(v); - } - catch - { - sponsors = []; - } - - if (HttpContext.GetDefaultCompatibility()) - compatibility = true; - - InnerTubeNextResponse video = - await _youtube.GetVideoAsync(v, language: HttpContext.GetInnerTubeLanguage(), region: HttpContext.GetInnerTubeRegion()); - if (player is null || e is not null) - return View(new EmbedContext(HttpContext, e ?? new Exception("player is null"), video)); - return View(new EmbedContext(HttpContext, player, video, compatibility, sponsors)); - } - - [Route("/watch")] - public async Task Watch(string v, string? list, bool contentCheckOk, bool compatibility = false) - { - InnerTubePlayer? player; - Exception? e; - bool localPlaylist = list?.StartsWith("LT-PL") ?? false; - try - { - player = await _youtube.GetPlayerAsync(v, contentCheckOk, false, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - e = null; - if (player.Details.Id != v) - { - e = new Exception($"YouTube returned a different video than the requested one ({v} != {player.Details.Id})"); - player = null; - } - } - catch (Exception ex) - { - player = null; - e = ex; - } - - InnerTubeNextResponse video = - await _youtube.GetVideoAsync(v, localPlaylist ? null : list, language: HttpContext.GetInnerTubeLanguage(), - region: HttpContext.GetInnerTubeRegion()); - InnerTubeContinuationResponse? comments = null; - - if (HttpContext.GetDefaultCompatibility()) - compatibility = true; - - try - { - string commentsContinuation = InnerTube.Utils.PackCommentsContinuation(v, CommentsContext.Types.SortOrder.TopComments); - comments = await _youtube.GetVideoCommentsAsync(commentsContinuation, - language: HttpContext.GetInnerTubeLanguage(), - region: HttpContext.GetInnerTubeRegion()); - } - catch { /* comments arent enabled, ignore */ } - - int dislikes, likes; - try - { - HttpResponseMessage rydResponse = - await _client.GetAsync("https://returnyoutubedislikeapi.com/votes?videoId=" + v); - Dictionary rydJson = - JsonSerializer.Deserialize>( - await rydResponse.Content.ReadAsStringAsync())!; - dislikes = rydJson["dislikes"].GetInt32(); - likes = rydJson["likes"].GetInt32(); - } - catch - { - dislikes = -1; - likes = -1; - } - - SponsorBlockSegment[] sponsors; - try - { - sponsors = await SponsorBlockSegment.GetSponsors(v); - } - catch - { - sponsors = []; - } - - if (player is not null) - await DatabaseManager.Cache.AddVideo(new DatabaseVideo(player), true); - - if (localPlaylist && list != null) - { - DatabasePlaylist? pl = DatabaseManager.Playlists.GetPlaylist(list); - if (player is null || e is not null) - return View(new WatchContext(HttpContext, e ?? new Exception("player is null"), video, pl, comments, - dislikes, likes)); - return View(new WatchContext(HttpContext, player, video, pl, comments, compatibility, dislikes, likes, sponsors)); - } - else - { - if (player is null || e is not null) - return View(new WatchContext(HttpContext, e ?? new Exception("player is null"), video, comments, - dislikes, likes)); - return View(new WatchContext(HttpContext, player, video, comments, compatibility, dislikes, likes, sponsors)); - } - } - - [Route("/results")] - public async Task Search(string search_query, string? filter = null, string? continuation = null) - { - if (!string.IsNullOrWhiteSpace(search_query)) - Response.Cookies.Append("lastSearch", search_query); - if (continuation is null) - { - SearchParams searchParams = Request.GetSearchParams(); - - InnerTubeSearchResults search = - await _youtube.SearchAsync(search_query, searchParams, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - return View(new SearchContext(HttpContext, search_query, searchParams, search)); - } - else - { - InnerTubeContinuationResponse search = - await _youtube.ContinueSearchAsync(continuation, HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); - return View(new SearchContext(HttpContext, search_query, null, search)); - } - } - - [Route("/c/{vanity}")] - public async Task ChannelFromVanity(string vanity) - { - string? id = await _youtube.GetChannelIdFromVanity(vanity); - return Redirect(id is null ? "/" : $"/channel/{id}"); - } - - [Route("/@{vanity}")] - public async Task ChannelFromHandle(string vanity) - { - string? id = await _youtube.GetChannelIdFromVanity("@" + vanity); - return Redirect(id is null ? "/" : $"/channel/{id}"); - } - - [Route("/channel/{id}")] - public async Task Channel(string id, string? continuation = null) => - await Channel(id, ChannelTabs.Home, continuation); - - [Route("/channel/{id}/subscription")] - [HttpGet] - public async Task Subscription(string id) - { - if (id.StartsWith("LT")) return BadRequest("You cannot subscribe to other LightTube users"); - InnerTubeChannelResponse channel = - await _youtube.GetChannelAsync(id, ChannelTabs.Home, null, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - await DatabaseManager.Cache.AddChannel(new DatabaseChannel(channel), true); - SubscriptionContext ctx = new(HttpContext, channel); - if (ctx.User is null) - { - return Redirect($"/account/login?redirectUrl=%2Fchannel%2F{id}%2Fsubscription"); - } - return View(ctx); - } - - [Route("/channel/{id}/subscription")] - [HttpPost] - public async Task Subscription(string id, SubscriptionType type) - { - if (id.StartsWith("LT")) return BadRequest("You cannot subscribe to other LightTube users"); - (string? _, SubscriptionType subscriptionType) = - await DatabaseManager.Users.UpdateSubscription(Request.Cookies["token"] ?? "", id, type); - InnerTubeChannelResponse channel = - await _youtube.GetChannelAsync(id, ChannelTabs.Home, null, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - await DatabaseManager.Cache.AddChannel(new DatabaseChannel(channel)); - return Ok(LocalizationManager.GetFromHttpContext(HttpContext).GetRawString("modal.close")); - } - - [Route("/channel/{id}/{tab}")] - public async Task Channel(string id, ChannelTabs tab = ChannelTabs.Home, string? continuation = null) - { - if (id.StartsWith("LT")) - { - DatabaseUser? user = await DatabaseManager.Users.GetUserFromLTId(id); - return View(new ChannelContext(HttpContext, user, id)); - } - - if (continuation is null) - { - InnerTubeChannelResponse channel = - await _youtube.GetChannelAsync(id, tab, null, HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); - try - { - await DatabaseManager.Cache.AddChannel(new DatabaseChannel(channel), true); - } - catch (Exception) - { - // ignored - } - - return View(new ChannelContext(HttpContext, tab, channel, id)); - } - else - { - InnerTubeChannelResponse channel = - await _youtube.GetChannelAsync(id, tab, null, HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); - InnerTubeContinuationResponse cont = - await _youtube.ContinueChannelAsync(continuation, HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); - return View(new ChannelContext(HttpContext, tab, channel, cont, id)); - } - } - - [Route("/playlist")] - public async Task Playlist(string list, int? skip = null) - { - if (list.StartsWith("LT-PL")) - { - DatabasePlaylist? playlist = DatabaseManager.Playlists.GetPlaylist(list); - return View(new PlaylistContext(HttpContext, playlist)); - } - else - { - InnerTubePlaylist playlist = - await _youtube.GetPlaylistAsync(list, true, HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); - if (skip is null) - { - return View(new PlaylistContext(HttpContext, playlist)); - } - else - { - InnerTubeContinuationResponse continuationRes = - await _youtube.ContinuePlaylistAsync(list, skip.Value, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - return View(new PlaylistContext(HttpContext, playlist, continuationRes)); - } - } - } - - [Route("/shorts/{v}")] - public IActionResult Shorts(string v) => RedirectPermanent($"/watch?v={v}"); - - [Route("/download/{v}")] - public async Task Download(string v) - { - InnerTubePlayer? player; - Exception? e; - - try - { - player = await _youtube.GetPlayerAsync(v, true, false, HttpContext.GetInnerTubeLanguage(), - HttpContext.GetInnerTubeRegion()); - e = null; - } - catch (Exception ex) - { - player = null; - e = ex; - } - - if (player is null || e is not null) - return BadRequest(e?.Message ?? "player is null"); - if (player.Details.IsLive) - return BadRequest("You cannot download live videos"); - PlaylistVideoContext ctx = new(HttpContext); - ctx.ItemId = player.Details.Id; - ctx.ItemTitle = player.Details.Title; - ctx.ItemSubtitle = player.Details.Author.Title; - ctx.ItemThumbnail = $"https://i.ytimg.com/vi/{player.Details.Id}/hqdefault.jpg"; - ctx.Extra = player; - ctx.Title = ctx.Localization.GetRawString("download.title"); - ctx.AlignToStart = true; - ctx.Buttons = []; - return View(ctx); - } + [Route("/embed/{v}")] + public async Task Embed(string v, bool contentCheckOk, bool compatibility = false) + { + InnerTubePlayer? player; + Exception? e; + try + { + player = await innerTube.GetVideoPlayerAsync(v, contentCheckOk, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + e = null; + } + catch (Exception ex) + { + player = null; + e = ex; + } + + SponsorBlockSegment[] sponsors; + try + { + sponsors = await SponsorBlockSegment.GetSponsors(v); + } + catch + { + sponsors = []; + } + + if (HttpContext.GetDefaultCompatibility()) + compatibility = true; + + InnerTubeVideo video = await innerTube.GetVideoDetailsAsync(v, contentCheckOk, null, null, null, + language: HttpContext.GetInnerTubeLanguage(), region: HttpContext.GetInnerTubeRegion()); + if (player is null || e is not null) + return View(new EmbedContext(HttpContext, e ?? new Exception("player is null"), video)); + return View(new EmbedContext(HttpContext, player, video, compatibility, sponsors)); + } + + [Route("/watch")] + public async Task Watch(string v, string? list, bool contentCheckOk, bool compatibility = false) + { + InnerTubePlayer? player; + Exception? e; + bool localPlaylist = list?.StartsWith("LT-PL") ?? false; + try + { + player = await innerTube.GetVideoPlayerAsync(v, contentCheckOk, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + e = null; + if (player.Details.Id != v) + { + e = new Exception( + $"YouTube returned a different video than the requested one ({v} != {player.Details.Id})"); + player = null; + } + } + catch (Exception ex) + { + player = null; + e = ex; + } + + InnerTubeVideo video = await innerTube.GetVideoDetailsAsync(v, contentCheckOk, localPlaylist ? null : list, + null, null, language: HttpContext.GetInnerTubeLanguage(), region: HttpContext.GetInnerTubeRegion()); + ContinuationResponse? comments = null; + + if (HttpContext.GetDefaultCompatibility()) + compatibility = true; + + try + { + comments = await innerTube.GetVideoCommentsAsync(v, CommentsContext.Types.SortOrder.TopComments); + } + catch + { + /* comments arent enabled, ignore */ + } + + int dislikes; + try + { + HttpResponseMessage rydResponse = + await client.GetAsync("https://returnyoutubedislikeapi.com/votes?videoId=" + v); + Dictionary rydJson = + JsonSerializer.Deserialize>( + await rydResponse.Content.ReadAsStringAsync())!; + dislikes = rydJson["dislikes"].GetInt32(); + } + catch + { + dislikes = -1; + } + + SponsorBlockSegment[] sponsors; + try + { + sponsors = await SponsorBlockSegment.GetSponsors(v); + } + catch + { + sponsors = []; + } + + if (player is not null) + await DatabaseManager.Cache.AddVideo(new DatabaseVideo(player), true); + + if (localPlaylist && list != null) + { + DatabasePlaylist? pl = DatabaseManager.Playlists.GetPlaylist(list); + if (player is null || e is not null) + return View(new WatchContext(HttpContext, e ?? new Exception("player is null"), video, pl, comments, + dislikes)); + return View(new WatchContext(HttpContext, player, video, pl, comments, compatibility, dislikes, + sponsors)); + } + + if (player is null || e is not null) + return View(new WatchContext(HttpContext, e ?? new Exception("player is null"), video, comments, + dislikes)); + return View( + new WatchContext(HttpContext, player, video, comments, compatibility, dislikes, sponsors)); + } + + [Route("/results")] + public async Task Search(string search_query, string? filter = null, string? continuation = null, int? page = null) + { + if (!string.IsNullOrWhiteSpace(search_query)) + Response.Cookies.Append("lastSearch", search_query); + if (continuation is null) + { + SearchParams searchParams = Request.GetSearchParams(); + if (page != null && page > 0) + searchParams.Index = (page.Value - 1) * 20; + InnerTubeSearchResults search = + await innerTube.SearchAsync(search_query, searchParams, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + return View(new SearchContext(HttpContext, search_query, searchParams, search, page ?? 1, + search.Sidebar)); + } + else + { + SearchContinuationResponse search = + await innerTube.ContinueSearchAsync(continuation, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + return View(new SearchContext(HttpContext, search_query, null, search)); + } + } + + [Route("/c/{vanity}")] + public async Task ChannelFromVanity(string vanity) + { + ResolveUrlResponse endpoint = await innerTube.ResolveUrl("https://youtube.com/c/" + vanity); + return Redirect(endpoint.Endpoint.EndpointTypeCase == Endpoint.EndpointTypeOneofCase.BrowseEndpoint + ? $"/channel/{endpoint.Endpoint.BrowseEndpoint.BrowseId}" + : "/"); + } + + [Route("/@{handle}")] + public async Task ChannelFromHandle(string handle) + { + ResolveUrlResponse endpoint = await innerTube.ResolveUrl("https://youtube.com/@" + handle); + return Redirect(endpoint.Endpoint.EndpointTypeCase == Endpoint.EndpointTypeOneofCase.BrowseEndpoint + ? $"/channel/{endpoint.Endpoint.BrowseEndpoint.BrowseId}" + : "/"); + } + + [Route("/channel/{id}")] + public async Task Channel(string id, string? continuation = null) => + await Channel(id, ChannelTabs.Featured, continuation); + + [Route("/channel/{id}/subscription")] + [HttpGet] + public async Task Subscription(string id) + { + if (id.StartsWith("LT")) return BadRequest("You cannot subscribe to other LightTube users"); + InnerTubeChannel channel = + await innerTube.GetChannelAsync(id, ChannelTabs.Featured, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + await DatabaseManager.Cache.AddChannel(new DatabaseChannel(channel), true); + SubscriptionContext ctx = new(HttpContext, channel); + if (ctx.User is null) + { + return Redirect($"/account/login?redirectUrl=%2Fchannel%2F{id}%2Fsubscription"); + } + + return View(ctx); + } + + [Route("/channel/{id}/subscription")] + [HttpPost] + public async Task Subscription(string id, SubscriptionType type) + { + if (id.StartsWith("LT")) return BadRequest("You cannot subscribe to other LightTube users"); + await DatabaseManager.Users.UpdateSubscription(Request.Cookies["token"] ?? "", id, type); + InnerTubeChannel channel = + await innerTube.GetChannelAsync(id, ChannelTabs.Featured, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + await DatabaseManager.Cache.AddChannel(new DatabaseChannel(channel)); + return Ok(LocalizationManager.GetFromHttpContext(HttpContext).GetRawString("modal.close")); + } + + [Route("/channel/{id}/about")] + public async Task Channel(string id) + { + if (id.StartsWith("LT")) + { + // nuh uh + return Redirect($"/channel/{id}"); + } + + InnerTubeChannel channel = await innerTube.GetChannelAsync(id, ChannelTabs.Featured, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + InnerTubeAboutChannel? about = await innerTube.GetAboutChannelAsync(id, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + if (about == null) + { + return Redirect($"/channel/{id}"); + } + + try + { + await DatabaseManager.Cache.AddChannel(new DatabaseChannel(channel), true); + } + catch (Exception) + { + // ignored + } + + return View(new ChannelContext(HttpContext, ChannelTabs.About, channel, id, about)); + } + + [Route("/channel/{id}/{tab}")] + public async Task Channel(string id, ChannelTabs tab = ChannelTabs.Featured, string? continuation = null) + { + if (id.StartsWith("LT")) + { + DatabaseUser? user = await DatabaseManager.Users.GetUserFromLTId(id); + return View(new ChannelContext(HttpContext, user, id)); + } + + if (continuation is null) + { + InnerTubeChannel channel = await innerTube.GetChannelAsync(id, tab, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + try + { + await DatabaseManager.Cache.AddChannel(new DatabaseChannel(channel), true); + } + catch (Exception) + { + // ignored + } + + return View(new ChannelContext(HttpContext, tab, channel, id)); + } + else + { + InnerTubeChannel channel = await innerTube.GetChannelAsync(id, tab, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + ContinuationResponse cont = await innerTube.ContinueChannelAsync(continuation, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + return View(new ChannelContext(HttpContext, tab, channel, cont, id)); + } + } + + [Route("/playlist")] + public async Task Playlist(string list, string? continuation = null) + { + if (list.StartsWith("LT-PL")) + { + DatabasePlaylist? playlist = DatabaseManager.Playlists.GetPlaylist(list); + return View(new PlaylistContext(HttpContext, playlist)); + } + else + { + InnerTubePlaylist playlist = await innerTube.GetPlaylistAsync(list, true, PlaylistFilter.All, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + if (continuation is null) + { + return View(new PlaylistContext(HttpContext, playlist)); + } + else + { + ContinuationResponse continuationRes = await innerTube.ContinuePlaylistAsync(continuation, + HttpContext.GetInnerTubeLanguage(), HttpContext.GetInnerTubeRegion()); + return View(new PlaylistContext(HttpContext, playlist, continuationRes)); + } + } + } + + [Route("/shorts/{v}")] + public IActionResult Shorts(string v) => RedirectPermanent($"/watch?v={v}"); + + [Route("/download/{v}")] + public async Task Download(string v) + { + InnerTubePlayer? player; + Exception? e; + + try + { + player = await innerTube.GetVideoPlayerAsync(v, true, HttpContext.GetInnerTubeLanguage(), + HttpContext.GetInnerTubeRegion()); + e = null; + } + catch (Exception ex) + { + player = null; + e = ex; + } + + if (player is null || e is not null) + return BadRequest(e?.Message ?? "player is null"); + if (player.Details.IsLive) + return BadRequest("You cannot download live videos"); + PlaylistVideoContext ctx = new(HttpContext); + ctx.ItemId = player.Details.Id; + ctx.ItemTitle = player.Details.Title; + ctx.ItemSubtitle = player.Details.Author.Title; + ctx.ItemThumbnail = $"https://i.ytimg.com/vi/{player.Details.Id}/hqdefault.jpg"; + ctx.Extra = player; + ctx.Title = ctx.Localization.GetRawString("download.title"); + ctx.AlignToStart = true; + ctx.Buttons = []; + return View(ctx); + } } \ No newline at end of file diff --git a/LightTube/CustomRendererDatas/EditablePlaylistVideoRendererData.cs b/LightTube/CustomRendererDatas/EditablePlaylistVideoRendererData.cs new file mode 100644 index 00000000..65437fa2 --- /dev/null +++ b/LightTube/CustomRendererDatas/EditablePlaylistVideoRendererData.cs @@ -0,0 +1,8 @@ +using InnerTube.Renderers; + +namespace LightTube.CustomRendererDatas; + +public class EditablePlaylistVideoRendererData : PlaylistVideoRendererData +{ + public bool Editable { get; set; } +} \ No newline at end of file diff --git a/LightTube/CustomRendererDatas/SubscriptionFeedVideoRendererData.cs b/LightTube/CustomRendererDatas/SubscriptionFeedVideoRendererData.cs new file mode 100644 index 00000000..5a27df18 --- /dev/null +++ b/LightTube/CustomRendererDatas/SubscriptionFeedVideoRendererData.cs @@ -0,0 +1,8 @@ +using InnerTube.Renderers; + +namespace LightTube.CustomRendererDatas; + +public class SubscriptionFeedVideoRendererData : PlaylistVideoRendererData +{ + public DateTimeOffset ExactPublishDate { get; set; } +} \ No newline at end of file diff --git a/LightTube/Database/DatabaseManager.cs b/LightTube/Database/DatabaseManager.cs index 9a6bf9d8..fb41b9d6 100644 --- a/LightTube/Database/DatabaseManager.cs +++ b/LightTube/Database/DatabaseManager.cs @@ -1,5 +1,7 @@ using LightTube.Chores; using LightTube.Database.Models; +using LightTube.Database.Serialization; +using MongoDB.Bson.Serialization; using MongoDB.Driver; namespace LightTube.Database; @@ -33,6 +35,8 @@ public static void Init(string connstr) Cache = new CacheManager(ChannelCacheCollection, VideoCacheCollection); Oauth2 = new Oauth2Manager(Oauth2TokensCollection); Playlists = new PlaylistManager(PlaylistCollection, VideoCacheCollection); + + BsonSerializer.RegisterSerializationProvider(new LightTubeBsonSerializationProvider()); ChoreManager.QueueChore("DatabaseCleanup"); } diff --git a/LightTube/Database/Models/DatabaseChannel.cs b/LightTube/Database/Models/DatabaseChannel.cs index 45395775..e8e54c6d 100644 --- a/LightTube/Database/Models/DatabaseChannel.cs +++ b/LightTube/Database/Models/DatabaseChannel.cs @@ -1,4 +1,5 @@ using InnerTube; +using InnerTube.Models; using MongoDB.Bson.Serialization.Attributes; namespace LightTube.Database.Models; @@ -16,11 +17,11 @@ public DatabaseChannel() } - public DatabaseChannel(InnerTubeChannelResponse channel) + public DatabaseChannel(InnerTubeChannel channel) { ChannelId = channel.Header!.Id; Name = channel.Header!.Title; Subscribers = channel.Header!.SubscriberCountText; - IconUrl = channel.Header!.Avatars.Last().Url.ToString(); + IconUrl = channel.Header!.Avatars.Last().Url; } } \ No newline at end of file diff --git a/LightTube/Database/Models/DatabasePlaylist.cs b/LightTube/Database/Models/DatabasePlaylist.cs index cfb00f68..b5ff59e4 100644 --- a/LightTube/Database/Models/DatabasePlaylist.cs +++ b/LightTube/Database/Models/DatabasePlaylist.cs @@ -1,53 +1,220 @@ -using InnerTube; -using Newtonsoft.Json.Linq; +using InnerTube.Models; +using InnerTube.Protobuf; +using LightTube.Localization; +using Endpoint = InnerTube.Protobuf.Endpoint; namespace LightTube.Database.Models; public class DatabasePlaylist { - private const string INNERTUBE_PLAYLIST_INFO_TEMPLATE = "{\"playlistId\":\"%%PLAYLIST_ID%%\",\"title\":\"%%TITLE%%\",\"totalVideos\":%%VIDEO_COUNT%%,\"currentIndex\":%%CURRENT_INDEX%%,\"localCurrentIndex\":%%CURRENT_INDEX%%,\"longBylineText\":{\"runs\":[{\"text\":\"%%CHANNEL_TITLE%%\",\"navigationEndpoint\":{\"browseEndpoint\":{\"browseId\":\"%%CHANNEL_ID%%\"}}}]},\"isInfinite\":false,\"isCourse\":false,\"ownerBadges\":[],\"contents\":[%%CONTENTS%%]}"; - private const string INNERTUBE_GRID_PLAYLIST_RENDERER_TEMPLATE = "{\"gridPlaylistRenderer\":{\"playlistId\":\"%%ID%%\",\"title\":{\"simpleText\":\"%%TITLE%%\"},\"videoCountShortText\":{\"simpleText\":\"%%VIDEOCOUNT%%\"},\"thumbnailRenderer\":{\"playlistVideoThumbnailRenderer\":{\"thumbnail\":{\"thumbnails\":[{\"url\":\"%%THUMBNAIL%%\",\"width\":0,\"height\":0}]}}}}}"; - private const string ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - public string Id; - public string Name; - public string Description; - public PlaylistVisibility Visibility; - public List VideoIds; - public string Author; - public DateTimeOffset LastUpdated; + private const string ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + public string Id; + public string Name; + public string Description; + public PlaylistVisibility Visibility; + public List VideoIds; + public string Author; + public DateTimeOffset LastUpdated; - public InnerTubePlaylistInfo? GetInnerTubePlaylistInfo(string currentVideoId) - { - string json = INNERTUBE_PLAYLIST_INFO_TEMPLATE - .Replace("%%PLAYLIST_ID%%", Id) - .Replace("%%TITLE%%", Name.Replace("\"", "\\\"")) - .Replace("%%VIDEO_COUNT%%", VideoIds.Count.ToString()) - .Replace("%%CURRENT_INDEX%%", VideoIds.IndexOf(currentVideoId).ToString()) - .Replace("%%CHANNEL_TITLE%%", Author.Replace("\"", "\\\"")) - .Replace("%%CHANNEL_ID%%", DatabaseManager.Users.GetUserFromId(Author).Result?.LTChannelID) - .Replace("%%CONTENTS%%", DatabaseManager.Playlists.GetPlaylistPanelVideosJson(Id, currentVideoId)); - return new InnerTubePlaylistInfo(JObject.Parse(json)); - } + public static string GenerateId() + { + Random rng = new(); + string playlistId = "LT-PL"; + while (playlistId.Length < 24) + playlistId += ID_ALPHABET[rng.Next(0, ID_ALPHABET.Length)]; + return playlistId; + } - public string GetInnerTubeGridPlaylistJson() => INNERTUBE_GRID_PLAYLIST_RENDERER_TEMPLATE - .Replace("%%ID%%", Id) - .Replace("%%TITLE%%", Name) - .Replace("%%VIDEOCOUNT%%", VideoIds.Count.ToString()) - .Replace("%%THUMBNAIL%%", $"https://i.ytimg.com/vi/{VideoIds.FirstOrDefault()}/hqdefault.jpg"); + public VideoPlaylistInfo? GetVideoPlaylistInfo(string detailsId, DatabaseUser author, List videos, + LocalizationManager localization) + { + Playlist pl = new() + { + PlaylistId = Id, + Title = Name, + TotalVideos = VideoIds.Count, + CurrentIndex = VideoIds.IndexOf(detailsId), + LocalCurrentIndex = VideoIds.IndexOf(detailsId), + LongBylineText = new Text + { + Runs = + { + new Text.Types.Run + { + NavigationEndpoint = new Endpoint + { + BrowseEndpoint = new BrowseEndpoint + { + BrowseId = author.LTChannelID, + CanonicalBaseUrl = $"/@LT_{author.UserID}" + } + }, + Text = author.UserID + } + } + }, + IsCourse = false, + IsInfinite = false + }; + int i = 0; + // todo: add null checks for uncached videos + pl.Contents.AddRange(videos.Select(x => + { + if (x is null) + { + return new RendererWrapper + { + PlaylistPanelVideoRenderer = new PlaylistPanelVideoRenderer + { + VideoId = "", + Title = new Text + { + SimpleText = localization.GetRawString("playlist.video.uncached") + }, + Thumbnail = new Thumbnails + { + Thumbnails_ = { new Thumbnail + { + Url = "https://i.ytimg.com/vi/___________/hqdefault.jpg", + Width = 120, + Height = 90 + } + } + }, + LengthText = new Text + { + SimpleText = "00:00" + }, + IndexText = new Text + { + SimpleText = (++i).ToString() + } + } + }; + } + return new RendererWrapper + { + PlaylistPanelVideoRenderer = new PlaylistPanelVideoRenderer + { + VideoId = x.Id, + Title = new Text + { + SimpleText = x.Title + }, + Thumbnail = new Thumbnails + { + Thumbnails_ = { x.Thumbnails } + }, + ShortBylineText = new Text + { + Runs = + { + new Text.Types.Run + { + NavigationEndpoint = new Endpoint + { + BrowseEndpoint = new BrowseEndpoint + { + BrowseId = x.Channel.Id, + CanonicalBaseUrl = $"/channel/{x.Channel.Id}" + } + }, + Text = x.Channel.Name + } + } + }, + LengthText = new Text + { + SimpleText = x.Duration + }, + IndexText = new Text + { + SimpleText = (++i).ToString() + } + } + }; + })); + return new VideoPlaylistInfo(pl, "en"); + } - public static string GenerateId() - { - Random rng = new(); - string playlistId = "LT-PL"; - while (playlistId.Length < 24) - playlistId += ID_ALPHABET[rng.Next(0, ID_ALPHABET.Length)]; - return playlistId; - } + public PlaylistHeaderRenderer GetHeaderRenderer(DatabaseUser author, LocalizationManager localization) => + new() + { + Title = new Text + { + SimpleText = Name + }, + NumVideosText = new Text + { + SimpleText = string.Format(localization.GetRawString("videos.count"), VideoIds.Count) + }, + ViewCountText = new Text + { + SimpleText = localization.GetRawString("lighttube.views") + }, + Byline = new RendererWrapper + { + PlaylistBylineRenderer = new PlaylistBylineRenderer + { + Text = + { + new Text + { + SimpleText = string.Format(localization.GetRawString("lastupdated"), + LastUpdated.ToString("MMMM dd, yyyy")) + } + } + } + }, + DescriptionText = new Text + { + SimpleText = Description + }, + OwnerText = new Text + { + Runs = + { + new Text.Types.Run + { + NavigationEndpoint = new Endpoint + { + BrowseEndpoint = new BrowseEndpoint + { + BrowseId = author.LTChannelID, + CanonicalBaseUrl = $"/@LT_{author.UserID}" + } + }, + Text = author.UserID + } + } + }, + CinematicContainer = new RendererWrapper + { + CinematicContainerRenderer = new CinematicContainerRenderer + { + BackgroundImageConfig = new CinematicContainerRenderer.Types.BackgroundConfig + { + Thumbnails = new Thumbnails + { + Thumbnails_ = + { + new Thumbnail + { + Url = $"https://i.ytimg.com/vi/{VideoIds.FirstOrDefault()}/hqdefault.jpg", + Width = 480, + Height = 360 + } + } + } + } + } + } + }; } public enum PlaylistVisibility { - PRIVATE, - UNLISTED, - VISIBLE + Private, + Unlisted, + Visible } \ No newline at end of file diff --git a/LightTube/Database/Models/DatabaseUser.cs b/LightTube/Database/Models/DatabaseUser.cs index e03e6b1f..7a3d646b 100644 --- a/LightTube/Database/Models/DatabaseUser.cs +++ b/LightTube/Database/Models/DatabaseUser.cs @@ -1,33 +1,19 @@ -using InnerTube.Renderers; +using InnerTube.Protobuf; +using InnerTube.Renderers; +using LightTube.Localization; using MongoDB.Bson.Serialization.Attributes; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace LightTube.Database.Models; [BsonIgnoreExtraElements] public class DatabaseUser { - private const string INNERTUBE_GRID_RENDERER_TEMPLATE = "{\"items\": [%%CONTENTS%%]}"; - - private const string INNERTUBE_MESSAGE_RENDERER_TEMPLATE = - "{\"messageRenderer\":{\"text\":{\"simpleText\":\"%%MESSAGE%%\"}}}"; - private const string ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - public string UserID { get; set; } + [JsonProperty("userId")] public string UserID { get; set; } [JsonIgnore] public string PasswordHash { get; set; } [JsonIgnore] public Dictionary Subscriptions { get; set; } - public string LTChannelID { get; set; } - - [JsonIgnore] - [BsonIgnoreIfNull] - [Obsolete("Use Subscriptions dictionary instead")] - public string[]? SubscribedChannels; - - [JsonIgnore] - [BsonIgnoreIfNull] - [Obsolete("Use UserID instead")] - public string? Email; + [JsonProperty("ltChannelId")] public string LTChannelID { get; set; } public static DatabaseUser CreateUser(string userId, string password) => new() @@ -38,28 +24,6 @@ public class DatabaseUser LTChannelID = GetChannelId(userId) }; - public void Migrate() - { -#pragma warning disable CS0618 - if (SubscribedChannels is not null) - { - Subscriptions ??= []; - foreach (string id in SubscribedChannels) - if (!Subscriptions.ContainsKey(id)) - Subscriptions.Add(id, SubscriptionType.NOTIFICATIONS_ON); - SubscribedChannels = null; - } - - if (Email is not null && UserID is null) - { - UserID = Email; - Email = null; - } -#pragma warning restore CS0618 - - LTChannelID ??= GetChannelId(UserID); - } - public static string GetChannelId(string userId) { Random rng = new(userId.GetHashCode()); @@ -69,17 +33,43 @@ public static string GetChannelId(string userId) return channelId; } - public GridRenderer PlaylistRenderers(PlaylistVisibility minVisibility = PlaylistVisibility.VISIBLE) + public List PlaylistRenderers(LocalizationManager localization, PlaylistVisibility minVisibility = PlaylistVisibility.Visible) { - IEnumerable playlists = - DatabaseManager.Playlists.GetUserPlaylists(UserID, minVisibility); - string playlistsJson = playlists.Any() - ? string.Join(',', playlists.Select(x => x.GetInnerTubeGridPlaylistJson())) - : INNERTUBE_MESSAGE_RENDERER_TEMPLATE.Replace("%%MESSAGE%%", - "This user doesn't have any public playlists."); + DatabasePlaylist[] playlists = + DatabaseManager.Playlists.GetUserPlaylists(UserID, minVisibility).ToArray(); + if (playlists.Length == 0) + { + return + [ + new RendererContainer + { + Type = "message", + OriginalType = "messageRenderer", + Data = new MessageRendererData(localization.GetRawString("channel.noplaylists")) + } + ]; + } - string json = INNERTUBE_GRID_RENDERER_TEMPLATE - .Replace("%%CONTENTS%%", playlistsJson); - return new GridRenderer(JObject.Parse(json)); + return playlists.Select(x => new RendererContainer + { + Type = "playlist", + OriginalType = "gridPlaylistRenderer", + Data = new PlaylistRendererData + { + PlaylistId = x.Id, + Thumbnails = [ + new Thumbnail + { + Url = $"https://i.ytimg.com/vi/{x.VideoIds.FirstOrDefault()}/hqdefault.jpg", + Width = 480, + Height = 360 + } + ], + Title = x.Name, + VideoCountText = string.Format(localization.GetRawString("playlist.videos.count"), x.VideoIds.Count), + SidebarThumbnails = [], + Author = null + } + }).ToList(); } } \ No newline at end of file diff --git a/LightTube/Database/Models/DatabaseVideo.cs b/LightTube/Database/Models/DatabaseVideo.cs index a984a8ee..9eee506d 100644 --- a/LightTube/Database/Models/DatabaseVideo.cs +++ b/LightTube/Database/Models/DatabaseVideo.cs @@ -1,4 +1,6 @@ using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf; using MongoDB.Bson.Serialization.Attributes; namespace LightTube.Database.Models; @@ -9,7 +11,7 @@ public class DatabaseVideo public string Id; public string Title; public Thumbnail[] Thumbnails; - public string UploadedAt; + public string? UploadedAt; public long Views; [BsonIgnore] public string ViewsCount => $"{Views:N0} views"; public DatabaseVideoAuthor Channel; @@ -23,25 +25,14 @@ public DatabaseVideo(InnerTubePlayer player) { Id = player.Details.Id; Title = player.Details.Title; - Thumbnails = [ - new() - { - Url = player.Details.Thumbnails[0].Url - } - ]; + Thumbnails = player.Details.Thumbnails.ToArray(); UploadedAt = ""; Views = 0; Channel = new() { - Id = player.Details.Author.Id!, - Name = player.Details.Author.Title, - Avatars = [ - new Thumbnail() - { - Url = player.Details.Author.Avatar! - } - ] + Id = player.Details.Author.Id, + Name = player.Details.Author.Title }; - Duration = player.Details.Length.ToDurationString(); + Duration = player.Details.Length!.Value.ToDurationString(); } } \ No newline at end of file diff --git a/LightTube/Database/Models/DatabaseVideoAuthor.cs b/LightTube/Database/Models/DatabaseVideoAuthor.cs index bf89f715..7f748fbc 100644 --- a/LightTube/Database/Models/DatabaseVideoAuthor.cs +++ b/LightTube/Database/Models/DatabaseVideoAuthor.cs @@ -1,4 +1,5 @@ using InnerTube; +using InnerTube.Protobuf; using MongoDB.Bson.Serialization.Attributes; namespace LightTube.Database.Models; @@ -8,5 +9,4 @@ public class DatabaseVideoAuthor { public string Id; public string Name; - public Thumbnail[] Avatars; } \ No newline at end of file diff --git a/LightTube/Database/PlaylistManager.cs b/LightTube/Database/PlaylistManager.cs index c7feaa98..d3e5bfe1 100644 --- a/LightTube/Database/PlaylistManager.cs +++ b/LightTube/Database/PlaylistManager.cs @@ -1,6 +1,10 @@ using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf; using InnerTube.Renderers; +using LightTube.CustomRendererDatas; using LightTube.Database.Models; +using LightTube.Localization; using MongoDB.Driver; using Newtonsoft.Json.Linq; @@ -10,8 +14,6 @@ public class PlaylistManager( IMongoCollection playlistCollection, IMongoCollection videoCacheCollection) { - private const string INNERTUBE_PLAYLIST_VIDEO_RENDERER_TEMPLATE = "{\"videoId\":\"%%ID%%\",\"isPlayable\":true,\"thumbnail\":{\"thumbnails\":[{\"url\":\"%%THUMBNAIL%%\",\"width\":0,\"height\":0}]},\"title\":{\"runs\":[{\"text\":\"%%TITLE%%\"}]},\"index\":{\"simpleText\":\"%%INDEX%%\"},\"shortBylineText\":{\"runs\":[{\"text\":\"%%CHANNEL_TITLE%%\",\"navigationEndpoint\":{\"browseEndpoint\":{\"browseId\":\"%%CHANNEL_ID%%\"}}}]},\"lengthText\":{\"simpleText\":\"%%DURATION%%\"},\"navigationEndpoint\":{\"watchEndpoint\":{\"videoId\":\"%%ID%%\"}},\"lengthSeconds\":\"%%DURATION_SECONDS%%\",\"isPlayable\":true,\"thumbnailOverlays\":[{\"thumbnailOverlayTimeStatusRenderer\":{\"text\":{\"simpleText\":\"%%DURATION%%\"}}}],\"videoInfo\":{\"runs\":[{\"text\":\"%%VIEWS%%\"},{\"text\":\" • \"},{\"text\":\"%%UPLOADED_AT%%\"}]}}"; - private const string INNERTUBE_PLAYLIST_PANEL_VIDEO_RENDERER_TEMPLATE = "{\"title\":{\"simpleText\":\"%%TITLE%%\"},\"thumbnail\":{\"thumbnails\":[{\"url\":\"%%THUMBNAIL%%\",\"width\":0,\"height\":0}]},\"lengthText\":{\"simpleText\":\"%%DURATION%%\"},\"indexText\":{\"simpleText\":\"%%INDEX%%\"},\"selected\":%%SELECTED%%,\"navigationEndpoint\":{\"watchEndpoint\":{\"params\":\"OAE%3D\"}},\"videoId\":\"%%ID%%\",\"shortBylineText\":{\"runs\":[{\"text\":\"%%CHANNEL_TITLE%%\",\"navigationEndpoint\":{\"browseEndpoint\":{\"browseId\":\"%%CHANNEL_ID%%\"}}}]}}"; public IMongoCollection PlaylistCollection { get; } = playlistCollection; public IMongoCollection VideoCacheCollection { get; } = videoCacheCollection; @@ -23,84 +25,65 @@ public IEnumerable GetUserPlaylists(string userId, PlaylistVis return unfiltered.ToList().Where(x => x.Visibility >= minVisibility); } - public IEnumerable GetPlaylistVideos(string id, bool editable) + public IEnumerable GetPlaylistVideoRenderers(string id, bool editable, LocalizationManager localization) { DatabasePlaylist? pl = GetPlaylist(id); if (pl == null) return []; - List renderers = []; + List renderers = []; for (int i = 0; i < pl.VideoIds.Count; i++) { string videoId = pl.VideoIds[i]; DatabaseVideo? video = VideoCacheCollection.FindSync(x => x.Id == videoId).FirstOrDefault(); - string json = INNERTUBE_PLAYLIST_VIDEO_RENDERER_TEMPLATE - .Replace("%%ID%%", editable ? videoId + "!" : videoId) - .Replace("%%INDEX%%", (i + 1).ToString()) - .Replace("%%TITLE%%", video?.Title.Replace("\"", "\\\"") ?? "Uncached video. Click to fix") - .Replace("%%THUMBNAIL%%", video?.Thumbnails.LastOrDefault()?.Url.ToString() ?? "https://i.ytimg.com/vi//hqdefault.jpg") - .Replace("%%DURATION%%", video?.Duration ?? "00:00") - .Replace("%%DURATION_SECONDS%%", InnerTube.Utils.ParseDuration(video?.Duration ?? "00:00").TotalSeconds.ToString()) - .Replace("%%UPLADED_AT%%", video?.UploadedAt ?? "???") - .Replace("%%CHANNEL_TITLE%%", video?.Channel.Name.Replace("\"", "\\\"") ?? "???") - .Replace("%%CHANNEL_ID%%", video?.Channel.Id ?? "???") - .Replace("%%VIEWS%%", (video?.Views ?? 0).ToString()); - renderers.Add(new PlaylistVideoRenderer(JObject.Parse(json))); - } - - return renderers; - } - - public IEnumerable GetPlaylistPanelVideos(string id, string currentVideoId) - { - DatabasePlaylist? pl = GetPlaylist(id); - if (pl == null) return []; - - List renderers = []; - - for (int i = 0; i < pl.VideoIds.Count; i++) - { - string videoId = pl.VideoIds[i]; - DatabaseVideo? video = VideoCacheCollection.FindSync(x => x.Id == videoId).FirstOrDefault(); - string json = INNERTUBE_PLAYLIST_PANEL_VIDEO_RENDERER_TEMPLATE - .Replace("%%ID%%", videoId) - .Replace("%%SELECTED%%", (currentVideoId == videoId).ToString().ToLower()) - .Replace("%%INDEX%%", currentVideoId == videoId ? ">" : (i + 1).ToString()) - .Replace("%%TITLE%%", video?.Title.Replace("\"", "\\\"") ?? "Uncached video. Click to fix") - .Replace("%%THUMBNAIL%%", video?.Thumbnails.LastOrDefault()?.Url.ToString() ?? "https://i.ytimg.com/vi//hqdefault.jpg") - .Replace("%%DURATION%%", video?.Duration ?? "00:00") - .Replace("%%CHANNEL_TITLE%%", video?.Channel.Name.Replace("\"", "\\\"") ?? "???") - .Replace("%%CHANNEL_ID%%", video?.Channel.Id ?? "???"); - renderers.Add(new PlaylistPanelVideoRenderer(JObject.Parse(json))); + RendererContainer container = new() + { + Type = "video", + OriginalType = "playlistVideoContainer", + Data = new EditablePlaylistVideoRendererData + { + VideoId = videoId, + Title = video?.Title.Replace("\"", "\\\"") ?? localization.GetRawString("playlist.video.uncached"), + Thumbnails = + [ + new Thumbnail + { + Url = video?.Thumbnails.LastOrDefault()?.Url.ToString() ?? "https://i.ytimg.com/vi//hqdefault.jpg", + Width = 480, + Height = 360 + } + ], + Author = video?.Channel.Id != null + ? new Channel("en", + video.Channel.Id, + video?.Channel.Name.Replace("\"", "\\\"") ?? "???", + null, + null, + null, + null + ) + : null, + Duration = InnerTube.Utils.ParseDuration(video?.Duration ?? "00:00"), + PublishedText = video?.UploadedAt, + ViewCountText = (video?.Views ?? 0).ToString(), + Badges = [], + Description = null, + VideoIndexText = (i + 1).ToString(), + Editable = editable + } + }; + renderers.Add(container); } return renderers; } - public string GetPlaylistPanelVideosJson(string id, string currentVideoId) + public List GetPlaylistVideos(string playlistId, LocalizationManager localization) { - DatabasePlaylist? pl = GetPlaylist(id); - if (pl == null) return ""; - - List renderers = []; - - for (int i = 0; i < pl.VideoIds.Count; i++) - { - string videoId = pl.VideoIds[i]; - DatabaseVideo? video = VideoCacheCollection.FindSync(x => x.Id == videoId).FirstOrDefault(); - string json = $"{{\"playlistPanelVideoRenderer\":{INNERTUBE_PLAYLIST_PANEL_VIDEO_RENDERER_TEMPLATE}}}" - .Replace("%%ID%%", videoId) - .Replace("%%SELECTED%%", (currentVideoId == videoId).ToString().ToLower()) - .Replace("%%INDEX%%", currentVideoId == videoId ? "▶" : (i + 1).ToString()) - .Replace("%%TITLE%%", video?.Title.Replace("\"", "\\\"") ?? "Uncached video. Click to fix") - .Replace("%%THUMBNAIL%%", video?.Thumbnails.LastOrDefault()?.Url.ToString() ?? "https://i.ytimg.com/vi//hqdefault.jpg") - .Replace("%%DURATION%%", video?.Duration ?? "00:00") - .Replace("%%CHANNEL_TITLE%%", video?.Channel.Name.Replace("\"", "\\\"") ?? "???") - .Replace("%%CHANNEL_ID%%", video?.Channel.Id ?? "???"); - renderers.Add(json); - } - - return string.Join(",", renderers); + DatabasePlaylist? pl = GetPlaylist(playlistId); + return pl == null + ? [] + : pl.VideoIds.Select(id => VideoCacheCollection.FindSync(x => x.Id == id).FirstOrDefault()).ToList(); } public async Task CreatePlaylist(string token, string title, string description, PlaylistVisibility visibility) @@ -144,29 +127,18 @@ public async Task AddVideoToPlaylist(string token, string playlistId, InnerTubeP playlist.LastUpdated = DateTimeOffset.UtcNow; await PlaylistCollection.ReplaceOneAsync(x => x.Id == playlistId, playlist); - await DatabaseManager.Cache.AddVideo(new DatabaseVideo() + await DatabaseManager.Cache.AddVideo(new DatabaseVideo { Id = video.Details.Id, Title = video.Details.Title, - Thumbnails = [ - new() - { - Url = new Uri($"https://i.ytimg.com/vi/{video.Details.Id}/hqdefault.jpg") - } - ], + Thumbnails = video.Details.Thumbnails, Views = 0, - Channel = new() + Channel = new DatabaseVideoAuthor { Id = video.Details.Author.Id!, - Name = video.Details.Author.Title, - Avatars = [ - new() - { - Url = video.Details.Author.Avatar! - } - ] + Name = video.Details.Author.Title }, - Duration = video.Details.Length.ToDurationString() + Duration = video.Details.Length!.Value.ToDurationString() }); } diff --git a/LightTube/Database/Serialization/BsonNullableIntSerializer.cs b/LightTube/Database/Serialization/BsonNullableIntSerializer.cs new file mode 100644 index 00000000..1b702691 --- /dev/null +++ b/LightTube/Database/Serialization/BsonNullableIntSerializer.cs @@ -0,0 +1,28 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace LightTube.Database.Serialization; + +public class BsonNullableIntSerializer : SerializerBase +{ + public override int Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + BsonType type = context.Reader.GetCurrentBsonType(); + switch (type) + { + case BsonType.Null: + context.Reader.ReadNull(); + return 0; + case BsonType.Int32: + return context.Reader.ReadInt32(); + default: + throw new NotSupportedException($"Cannot convert a {type} to a Int32."); + } + } + + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, int value) + { + context.Writer.WriteInt32(value); + } +} \ No newline at end of file diff --git a/LightTube/Database/Serialization/BsonNullableStringSerializer.cs b/LightTube/Database/Serialization/BsonNullableStringSerializer.cs new file mode 100644 index 00000000..1682e777 --- /dev/null +++ b/LightTube/Database/Serialization/BsonNullableStringSerializer.cs @@ -0,0 +1,28 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace LightTube.Database.Serialization; + +public class BsonNullableStringSerializer : SerializerBase +{ + public override string Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + BsonType type = context.Reader.GetCurrentBsonType(); + switch (type) + { + case BsonType.Null: + context.Reader.ReadNull(); + return ""; + case BsonType.String: + return context.Reader.ReadString(); + default: + return ""; + } + } + + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, string value) + { + context.Writer.WriteString(value ?? ""); + } +} \ No newline at end of file diff --git a/LightTube/Database/Serialization/LightTubeBsonSerializationProvider.cs b/LightTube/Database/Serialization/LightTubeBsonSerializationProvider.cs new file mode 100644 index 00000000..944bf5ca --- /dev/null +++ b/LightTube/Database/Serialization/LightTubeBsonSerializationProvider.cs @@ -0,0 +1,12 @@ +using MongoDB.Bson.Serialization; + +namespace LightTube.Database.Serialization; + +public class LightTubeBsonSerializationProvider : IBsonSerializationProvider +{ + public IBsonSerializer? GetSerializer(Type type) + { + return type == typeof(int) ? new BsonNullableIntSerializer() : + type == typeof(string) ? new BsonNullableStringSerializer() : null; + } +} \ No newline at end of file diff --git a/LightTube/Importer/ImporterUtility.cs b/LightTube/Importer/ImporterUtility.cs index 66d8b22e..77881017 100644 --- a/LightTube/Importer/ImporterUtility.cs +++ b/LightTube/Importer/ImporterUtility.cs @@ -108,10 +108,10 @@ private static ImportedData ExtractTakeoutZip(byte[] data) item.TimeUpdated = DateTimeOffset.Parse(infoParts[3]); item.Visibility = infoParts[6] switch { - "Public" => PlaylistVisibility.VISIBLE, - "Unlisted" => PlaylistVisibility.UNLISTED, - "Private" => PlaylistVisibility.PRIVATE, - _ => PlaylistVisibility.PRIVATE + "Public" => PlaylistVisibility.Visible, + "Unlisted" => PlaylistVisibility.Unlisted, + "Private" => PlaylistVisibility.Private, + _ => PlaylistVisibility.Private }; item.VideoIds = videosPart.Select(x => x.Split(',')[0]).ToArray(); importedData.Playlists.Add(item); @@ -174,10 +174,10 @@ private static ImportedData ExtractInvidiousJson(byte[] data) TimeUpdated = null, Visibility = playlist["privacy"]!.ToObject()! switch { - "Public" => PlaylistVisibility.VISIBLE, - "Unlisted" => PlaylistVisibility.UNLISTED, - "Private" => PlaylistVisibility.PRIVATE, - _ => PlaylistVisibility.PRIVATE + "Public" => PlaylistVisibility.Visible, + "Unlisted" => PlaylistVisibility.Unlisted, + "Private" => PlaylistVisibility.Private, + _ => PlaylistVisibility.Private }, VideoIds = playlist["videos"]!.ToObject()! }); @@ -228,7 +228,7 @@ private static ImportedData ExtractPipedPlaylists(byte[] data) // Piped doesn't seem to have playlist privacy, and // from my testing, I could just access a playlist I // created without logging in (which makes it not private) - Visibility = PlaylistVisibility.UNLISTED, + Visibility = PlaylistVisibility.Unlisted, VideoIds = playlist["videos"]!.ToObject()!.Select(x => x.Split("?v=")[1]).ToArray() }); } diff --git a/LightTube/JsCache.cs b/LightTube/JsCache.cs index 6714a387..285852ff 100644 --- a/LightTube/JsCache.cs +++ b/LightTube/JsCache.cs @@ -8,8 +8,9 @@ public static class JsCache private static Dictionary LibraryUrls = new() { ["hls.js"] = new Uri("https://cdn.jsdelivr.net/npm/hls.js@1.4.0/dist/hls.min.js"), - ["ltplayer.js"] = new Uri("https://raw.githubusercontent.com/kuylar/LTPlayer/master/dist/player.min.js"), - ["ltplayer.css"] = new Uri("https://raw.githubusercontent.com/kuylar/LTPlayer/master/dist/player.min.css"), + // TODO: Move this to lighttube-org + ["ltplayer.js"] = new Uri("https://raw.githubusercontent.com/kuylar/LTPlayer/fix/few-fixes/dist/player.min.js"), + ["ltplayer.css"] = new Uri("https://raw.githubusercontent.com/kuylar/LTPlayer/fix/few-fixes/dist/player.min.css"), }; private static Dictionary Hashes = []; public static DateTimeOffset CacheUpdateTime = DateTimeOffset.MinValue; diff --git a/LightTube/LightTube.csproj b/LightTube/LightTube.csproj index 34539512..e4c1b208 100644 --- a/LightTube/LightTube.csproj +++ b/LightTube/LightTube.csproj @@ -10,7 +10,7 @@ - + diff --git a/LightTube/Program.cs b/LightTube/Program.cs index 2763c537..e15337e8 100644 --- a/LightTube/Program.cs +++ b/LightTube/Program.cs @@ -4,6 +4,8 @@ using LightTube.Chores; using LightTube.Database; using LightTube.Localization; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; using Serilog; using Serilog.Events; @@ -27,10 +29,15 @@ .WriteTo.Console()); // Add services to the container. - builder.Services.AddControllersWithViews().AddNewtonsoftJson(); + builder.Services + .AddControllersWithViews() + .AddNewtonsoftJson(options => + { + options.SerializerSettings.Converters.Add(new StringEnumConverter(new DefaultNamingStrategy(), false)); + }); InnerTubeAuthorization? auth = Configuration.InnerTubeAuthorization; - builder.Services.AddSingleton(new InnerTube.InnerTube(new InnerTubeConfiguration + builder.Services.AddSingleton(new SimpleInnerTubeClient(new InnerTubeConfiguration { Authorization = auth, CacheSize = Configuration.CacheSize, diff --git a/LightTube/Resources/Localization/en.json b/LightTube/Resources/Localization/en.json index 820fbcb3..5048a1ec 100644 --- a/LightTube/Resources/Localization/en.json +++ b/LightTube/Resources/Localization/en.json @@ -24,13 +24,22 @@ "account.register.disabled": "This instance doesn't allow account registrations", "account.register.disabled.home": "Go to home", + "channel.about.description": "Description", + "channel.about.artistbio": "Artist Biography", + "channel.about.views": "{0} views", + "channel.about.joined": "Joined {0}", + "channel.about.links": "Links", "channel.banner": "Channel Banner", "channel.supporter.avatar": "User Avatar", "channel.accountsettings": "Account Settings", - "channel.tab.home": "Home", + "channel.header.subscribers": "{0} subscribers", + "channel.header.videos": "{0} videos", + "channel.recognition.title": "Our members", + "channel.recognition.subtitle": "Thank you, channel members!", + "channel.tab.featured": "Home", "channel.tab.videos": "Videos", "channel.tab.shorts": "Shorts", - "channel.tab.live": "Live", + "channel.tab.streams": "Live", "channel.tab.playlists": "Playlists", "channel.tab.podcasts": "Podcasts", "channel.tab.releases": "Releases", @@ -39,6 +48,8 @@ "channel.tab.store": "Store", "channel.tab.about": "About", "channel.tab.search": "Search", + "channel.tagline.lighttube": "LightTube Channel", + "channel.noplaylists": "This user doesn't have any public playlists.", "download.title": "Download video", "download.format.select": "Please select a format to download.", @@ -109,12 +120,15 @@ "playlist.removevideo.body": "Are you sure that you want to remove this video from playlist \"{0}\"", "playlist.removevideo.confirm": "Remove", "playlist.unavailable": "Playlist unavailable", + "playlist.video.uncached": "Uncached video. Load the video to update the cache", "playlist.videos.count": "{0} videos", "playlist.view.full": "View Full Playlist", "playlist.visibility.0": "Private", "playlist.visibility.1": "Unlisted", "playlist.visibility.2": "Public", + "post.view.youtube": "View on YouTube", + "rss.title": "RSS", "rss.explain.title": "Use an RSS feed reader with LightTube", "rss.explain.description": "To get a customized RSS feed that contains all the videos of the channels you're subscribed to, just enter the following parameters in your RSS reader:", @@ -226,9 +240,12 @@ "video.subtitle": "{0} views • {1}", "video.movie": "Movie", "video.unavailable": "Unavailable", + "video.trailer.title": "Trailer", + "video.trailer.body": "Livestream will begin in {0}", "watch.noscript.resolution": "Current video resolution:", "watch.noscript.resolution.switch": "Switch to", + "watch.noscript.resolution.switch.unavailable": "No other resolutions are available", "watch.like.unavailable": "Like", "watch.dislike.unavailable": "Dislike", "watch.comments": "Comments", diff --git a/LightTube/SearchAutocomplete.cs b/LightTube/SearchAutocomplete.cs new file mode 100644 index 00000000..862380a5 --- /dev/null +++ b/LightTube/SearchAutocomplete.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Web; + +namespace LightTube; + +public class SearchAutocomplete(string query, string[] autocomplete) +{ + private static HttpClient client = new(); + public string Query { get; } = query; + public string[] Autocomplete { get; } = autocomplete; + + public static async Task GetAsync(string query, string language = "en", string region = "us") + { + string url = "https://suggestqueries-clients6.youtube.com/complete/search?client=firefox&ds=yt" + + $"&hl={HttpUtility.UrlEncode(language.ToLower())}" + + $"&gl={HttpUtility.UrlEncode(region.ToLower())}" + + $"&q={HttpUtility.UrlEncode(query)}"; + string json = await client.GetStringAsync(url); + object[] parsed = JsonSerializer.Deserialize(json)!; + return new SearchAutocomplete(parsed[0] as string ?? query, parsed[1] as string[] ?? []); + } +} \ No newline at end of file diff --git a/LightTube/Utils.cs b/LightTube/Utils.cs index bbcb012a..fa5e0e9e 100644 --- a/LightTube/Utils.cs +++ b/LightTube/Utils.cs @@ -1,5 +1,4 @@ using System.Collections.Specialized; -using System.Diagnostics; using System.Globalization; using System.Net; using System.Reflection; @@ -7,7 +6,11 @@ using System.Text; using System.Web; using System.Xml; -using InnerTube; +using InnerTube.Models; +using InnerTube.Protobuf.Params; +using InnerTube.Protobuf.Responses; +using LightTube.ApiModels; +using LightTube.Contexts; using LightTube.Database; using LightTube.Database.Models; using LightTube.Localization; @@ -17,497 +20,752 @@ namespace LightTube; public static class Utils { - private static string? _version; - private static string? _itVersion; - public static string UserIdAlphabet => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - - public static string[] OauthScopes = - [ - "playlists.read", - "playlists.write", - "subscriptions.read", - "subscriptions.write" - ]; - - public static string GetInnerTubeRegion(this HttpContext context) => - context.Request.Headers.TryGetValue("X-Content-Region", out StringValues h) - ? h.ToString() - : context.Request.Cookies.TryGetValue("gl", out string region) - ? region - : Configuration.DefaultContentRegion; - - public static string GetInnerTubeLanguage(this HttpContext context) => - context.Request.Headers.TryGetValue("X-Content-Language", out StringValues h) - ? h.ToString() - : context.Request.Cookies.TryGetValue("hl", out string language) && language != "localized" - ? language - : LocalizationManager.GetFromHttpContext(context).GetRawString("language.innertube"); - - public static bool IsInnerTubeLanguageLocalized(this HttpContext context) => - context.Request.Cookies.TryGetValue("hl", out string language) ? language == "localized" : true; - - public static bool GetDefaultRecommendationsVisibility(this HttpContext context) => - context.Request.Cookies.TryGetValue("recommendations", out string recommendations) - ? recommendations == "visible" - : true; - - public static bool GetDefaultCompatibility(this HttpContext context) => - context.Request.Cookies.TryGetValue("compatibility", out string compatibility) - ? compatibility == "true" - : false; - - public static string GetVersion() - { - if (_version is null) - { + private static string? version; + private static string? itVersion; + public static string UserIdAlphabet => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + public static string[] OauthScopes = + [ + "playlists.read", + "playlists.write", + "subscriptions.read", + "subscriptions.write" + ]; + + public static string GetInnerTubeRegion(this HttpContext context) => + context.Request.Headers.TryGetValue("X-Content-Region", out StringValues h) + ? h.ToString() + : context.Request.Cookies.TryGetValue("gl", out string? region) + ? region + : Configuration.DefaultContentRegion; + + public static string GetInnerTubeLanguage(this HttpContext context) => + context.Request.Headers.TryGetValue("X-Content-Language", out StringValues h) + ? h.ToString() + : context.Request.Cookies.TryGetValue("hl", out string? language) && language != "localized" + ? language + : LocalizationManager.GetFromHttpContext(context).GetRawString("language.innertube"); + + public static bool IsInnerTubeLanguageLocalized(this HttpContext context) => + !context.Request.Cookies.TryGetValue("hl", out string? language) || language == "localized"; + + public static bool GetDefaultRecommendationsVisibility(this HttpContext context) => + !context.Request.Cookies.TryGetValue("recommendations", out string? recommendations) || + recommendations == "visible"; + + public static bool GetDefaultCompatibility(this HttpContext context) => + context.Request.Cookies.TryGetValue("compatibility", out string? compatibility) && compatibility == "true"; + + public static string GetVersion() + { + if (version is null) + { #if DEBUG - DateTime buildTime = DateTime.Today; - _version = $"{buildTime.Year}.{buildTime.Month}.{buildTime.Day} (dev)"; + DateTime buildTime = DateTime.Today; + version = $"{buildTime.Year}.{buildTime.Month}.{buildTime.Day} (dev)"; #else - _version = Assembly.GetExecutingAssembly().GetName().Version!.ToString()[2..]; + version = Assembly.GetExecutingAssembly().GetName().Version!.ToString()[2..]; #endif - } - - return _version; - } - - public static string GetInnerTubeVersion() - { - _itVersion ??= typeof(InnerTube.InnerTube).Assembly.GetName().Version!.ToString(); - - return _itVersion; - } - - public static string GetCodecFromMimeType(string mime) - { - try - { - return mime.Split("codecs=\"")[1].Replace("\"", ""); - } - catch - { - return ""; - } - } - - public static async Task GetProxiedHlsManifest(string url, string? proxyRoot = null, - bool skipCaptions = false) - { - if (!url.StartsWith("http://") && !url.StartsWith("https://")) - url = "https://" + url; - - HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); - request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; - - using HttpWebResponse response = (HttpWebResponse)request.GetResponse(); - - await using Stream stream = response.GetResponseStream(); - using StreamReader reader = new(stream); - string manifest = await reader.ReadToEndAsync(); - StringBuilder proxyManifest = new(); - - List types = []; - - if (proxyRoot is not null) - foreach (string s in manifest.Split("\n")) - { - string? manifestType = null; - string? manifestUrl = null; - - if (s.StartsWith("https://www.youtube.com/api/timedtext")) - { - manifestUrl = s; - manifestType = "caption"; - } - else if (s.Contains(".googlevideo.com/videoplayback")) - { - manifestType = "segment"; - manifestUrl = s; - } - else if (s.StartsWith("http")) - { - manifestUrl = s; - manifestType = s[46..].Split("/")[0]; - } - else if (s.StartsWith("#EXT-X-MEDIA:URI=")) - { - manifestUrl = s[18..].Split('"')[0]; - manifestType = s[64..].Split("/")[0]; - } - - string? proxiedUrl = null; - - if (manifestUrl != null) - { - switch (manifestType) - { - case "hls_playlist": - // MPEG-TS playlist - proxiedUrl = "/hls/playlist/" + - HttpUtility.UrlEncode(manifestUrl.Split(manifestType)[1]); - break; - case "hls_timedtext_playlist": - // subtitle playlist - proxiedUrl = "/hls/timedtext/" + - HttpUtility.UrlEncode(manifestUrl.Split(manifestType)[1]); - break; - case "caption": - // subtitles - NameValueCollection qs = HttpUtility.ParseQueryString(manifestUrl.Split("?")[1]); - proxiedUrl = $"/caption/{qs.Get("v")}/{qs.Get("lang")}"; - break; - case "segment": - // HLS segment - proxiedUrl = "/hls/segment/" + - HttpUtility.UrlEncode(manifestUrl.Split("://")[1]); - break; - } - } - - types.Add(manifestType); - - if (skipCaptions && manifestType == "caption") continue; - proxyManifest.AppendLine(proxiedUrl is not null && manifestUrl is not null - //TODO: check if http or https - ? s.Replace(manifestUrl, proxyRoot + proxiedUrl) - : s); - } - else - proxyManifest.Append(manifest); - - - return proxyManifest.ToString(); - } - - public static string GetDashManifest(InnerTubePlayer player, string? proxyUrl = null, bool skipCaptions = false) - { - XmlDocument doc = new(); - - XmlDeclaration xmlDeclaration = doc.CreateXmlDeclaration("1.0", "UTF-8", null); - doc.InsertBefore(xmlDeclaration, doc.DocumentElement); - - XmlElement mpdRoot = doc.CreateElement("MPD"); - mpdRoot.SetAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); - mpdRoot.SetAttribute("xmlns", "urn:mpeg:dash:schema:mpd:2011"); - mpdRoot.SetAttribute("xsi:schemaLocation", "urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd"); - mpdRoot.SetAttribute("profiles", "urn:mpeg:dash:profile:isoff-main:2011"); - mpdRoot.SetAttribute("type", "static"); - mpdRoot.SetAttribute("minBufferTime", "PT1.500S"); - StringBuilder duration = new("PT"); - if (player.Details.Length.TotalHours > 0) - duration.Append($"{player.Details.Length.Hours}H"); - if (player.Details.Length.Minutes > 0) - duration.Append($"{player.Details.Length.Minutes}M"); - if (player.Details.Length.Seconds > 0) - duration.Append(player.Details.Length.Seconds); - mpdRoot.SetAttribute("mediaPresentationDuration", $"{duration}.{player.Details.Length.Milliseconds}S"); - doc.AppendChild(mpdRoot); - - XmlElement period = doc.CreateElement("Period"); - - period.AppendChild(doc.CreateComment("Audio Adaptation Sets")); - List audios = player.AdaptiveFormats - .Where(x => x.AudioChannels.HasValue) - .ToList(); - IEnumerable> grouped = audios.GroupBy(x => x.AudioTrack?.Id); - foreach (IGrouping formatGroup in grouped.OrderBy(x => x.First().AudioTrack?.AudioIsDefault)) - { - XmlElement audioAdaptationSet = doc.CreateElement("AdaptationSet"); - - audioAdaptationSet.SetAttribute("mimeType", - HttpUtility.ParseQueryString(audios.First().Url.Query).Get("mime")); - audioAdaptationSet.SetAttribute("subsegmentAlignment", "true"); - audioAdaptationSet.SetAttribute("contentType", "audio"); - audioAdaptationSet.SetAttribute("lang", formatGroup.Key); - - if (formatGroup.First().AudioTrack != null) - { - XmlElement label = doc.CreateElement("Label"); - label.InnerText = formatGroup.First().AudioTrack?.DisplayName; - audioAdaptationSet.AppendChild(label); - } - - foreach (Format format in formatGroup) - { - XmlElement representation = doc.CreateElement("Representation"); - representation.SetAttribute("id", format.Itag); - representation.SetAttribute("codecs", GetCodecFromMimeType(format.MimeType)); - representation.SetAttribute("startWithSAP", "1"); - representation.SetAttribute("bandwidth", format.Bitrate.ToString()); - - XmlElement audioChannelConfiguration = doc.CreateElement("AudioChannelConfiguration"); - audioChannelConfiguration.SetAttribute("schemeIdUri", - "urn:mpeg:dash:23003:3:audio_channel_configuration:2011"); - audioChannelConfiguration.SetAttribute("value", format.AudioChannels?.ToString()); - representation.AppendChild(audioChannelConfiguration); - - XmlElement baseUrl = doc.CreateElement("BaseURL"); - baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) - ? format.Url.ToString() - : $"{proxyUrl}/media/{player.Details.Id}/{format.Itag}?audioTrackId={format.AudioTrack?.Id}"; - representation.AppendChild(baseUrl); - - if (format.IndexRange != null && format.InitRange != null) - { - XmlElement segmentBase = doc.CreateElement("SegmentBase"); - // sometimes wrong?? idk - segmentBase.SetAttribute("indexRange", $"{format.IndexRange.Start}-{format.IndexRange.End}"); - segmentBase.SetAttribute("indexRangeExact", "true"); - - XmlElement initialization = doc.CreateElement("Initialization"); - initialization.SetAttribute("range", $"{format.InitRange.Start}-{format.InitRange.End}"); - - segmentBase.AppendChild(initialization); - representation.AppendChild(segmentBase); - } - - audioAdaptationSet.AppendChild(representation); - } - - period.AppendChild(audioAdaptationSet); - } - - period.AppendChild(doc.CreateComment("Video Adaptation Set")); - - List videos = player.AdaptiveFormats.Where(x => !x.AudioChannels.HasValue).ToList(); - - XmlElement videoAdaptationSet = doc.CreateElement("AdaptationSet"); - videoAdaptationSet.SetAttribute("mimeType", - HttpUtility.ParseQueryString(videos.FirstOrDefault()?.Url.Query ?? "mime=video/mp4") - .Get("mime")); - videoAdaptationSet.SetAttribute("subsegmentAlignment", "true"); - videoAdaptationSet.SetAttribute("contentType", "video"); - - foreach (Format format in videos) - { - XmlElement representation = doc.CreateElement("Representation"); - representation.SetAttribute("id", format.Itag); - representation.SetAttribute("codecs", GetCodecFromMimeType(format.MimeType)); - representation.SetAttribute("startWithSAP", "1"); - representation.SetAttribute("width", format.Width.ToString()); - representation.SetAttribute("height", format.Height.ToString()); - representation.SetAttribute("bandwidth", format.Bitrate.ToString()); - - XmlElement baseUrl = doc.CreateElement("BaseURL"); - baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) - ? format.Url.ToString() - : $"{proxyUrl}/media/{player.Details.Id}/{format.Itag}"; - representation.AppendChild(baseUrl); - - if (format.IndexRange != null && format.InitRange != null) - { - XmlElement segmentBase = doc.CreateElement("SegmentBase"); - segmentBase.SetAttribute("indexRange", $"{format.IndexRange.Start}-{format.IndexRange.End}"); - segmentBase.SetAttribute("indexRangeExact", "true"); - - XmlElement initialization = doc.CreateElement("Initialization"); - initialization.SetAttribute("range", $"{format.InitRange.Start}-{format.InitRange.End}"); - - segmentBase.AppendChild(initialization); - representation.AppendChild(segmentBase); - } - - videoAdaptationSet.AppendChild(representation); - } - - period.AppendChild(videoAdaptationSet); - - if (!skipCaptions) - { - period.AppendChild(doc.CreateComment("Subtitle Adaptation Sets")); - foreach (InnerTubePlayer.VideoCaption subtitle in player.Captions) - { - period.AppendChild(doc.CreateComment(subtitle.Label)); - XmlElement adaptationSet = doc.CreateElement("AdaptationSet"); - adaptationSet.SetAttribute("mimeType", "text/vtt"); - adaptationSet.SetAttribute("lang", subtitle.LanguageCode); - - XmlElement representation = doc.CreateElement("Representation"); - representation.SetAttribute("id", $"caption_{subtitle.LanguageCode.ToLower()}"); - representation.SetAttribute("bandwidth", "256"); // ...why do we need this for a plaintext file - - XmlElement baseUrl = doc.CreateElement("BaseURL"); - string url = subtitle.BaseUrl.ToString(); - url = url.Replace("fmt=srv3", "fmt=vtt"); - baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) - ? url - : $"{proxyUrl}/caption/{player.Details.Id}/{subtitle.LanguageCode}"; - - representation.AppendChild(baseUrl); - adaptationSet.AppendChild(representation); - period.AppendChild(adaptationSet); - } - } - - mpdRoot.AppendChild(period); - return doc.OuterXml; - } - - public static string ToKMB(this int num) => - num switch - { - > 999999999 or < -999999999 => num.ToString("0,,,.###B", CultureInfo.InvariantCulture), - > 999999 or < -999999 => num.ToString("0,,.##M", CultureInfo.InvariantCulture), - > 999 or < -999 => num.ToString("0,.#K", CultureInfo.InvariantCulture), - var _ => num.ToString(CultureInfo.InvariantCulture) - }; - - public static string ToDurationString(this TimeSpan ts) - { - string str = ts.ToString(); - return str.StartsWith("00:") ? str[3..] : str; - } - - public static string GetContinuationUrl(string currentPath, string contToken) - { - string[] parts = currentPath.Split("?"); - NameValueCollection query = parts.Length > 1 - ? HttpUtility.ParseQueryString(parts[1]) - : []; - query.Set("continuation", contToken); - return $"{parts[0]}?{query.AllKeys.Select(x => x + "=" + query.Get(x)).Aggregate((a, b) => $"{a}&{b}")}"; - } - - public static string GetSkipUrl(string currentPath, int skipAmount) - { - string[] parts = currentPath.Split("?"); - NameValueCollection query = parts.Length > 1 - ? HttpUtility.ParseQueryString(parts[1]) - : []; - query.Set("skip", skipAmount.ToString()); - return $"{parts[0]}?{query.AllKeys.Select(x => x + "=" + query.Get(x)).Aggregate((a, b) => $"{a}&{b}")}"; - } - - public static SubscriptionType GetSubscriptionType(this HttpContext context, string? channelId) - { - if (channelId is null) return SubscriptionType.NONE; - DatabaseUser? user = DatabaseManager.Users.GetUserFromToken(context.Request.Cookies["token"] ?? "").Result; - if (user is null) return SubscriptionType.NONE; - return user.Subscriptions.TryGetValue(channelId, out SubscriptionType type) ? type : SubscriptionType.NONE; - } - - public static string GetExtension(this Format format) - { - if (format.MimeType.StartsWith("video")) - format.MimeType - .Split("/").Last() - .Split(";").First(); - else - { - if (format.MimeType.Contains("opus")) - return "opus"; - if (format.MimeType.Contains("mp4a")) - return "mp3"; - } - - return "mp4"; - } - - public static string[] FindInvalidScopes(string[] scopes) => - scopes.Where(x => !OauthScopes.Contains(x)).ToArray(); - - public static IEnumerable GetScopeDescriptions(string[] modelScopes, LocalizationManager localization) - { - List descriptions = []; - - // dangerous ones are at the top - if (modelScopes.Contains("logins.read")) - descriptions.Add("!" + localization.GetRawString("oauth2.scope.logins.read")); - if (modelScopes.Contains("logins.delete")) - descriptions.Add("!" + localization.GetRawString("oauth2.scope.logins.write")); - - descriptions.Add("Access YouTube data"); - - if (modelScopes.Contains("playlists.read") && modelScopes.Contains("playlists.write")) - descriptions.Add(localization.GetRawString("oauth2.scope.playlists.rw")); - else if (modelScopes.Contains("playlists.read")) - descriptions.Add(localization.GetRawString("oauth2.scope.playlists.read")); - else if (modelScopes.Contains("playlists.write")) - descriptions.Add(localization.GetRawString("oauth2.scope.playlists.write")); - - if (modelScopes.Contains("subscriptions.read")) - { - descriptions.Add(localization.GetRawString("oauth2.scope.subscriptions.read")); - descriptions.Add(localization.GetRawString("oauth2.scope.subscriptions.feed")); - } - - if (modelScopes.Contains("subscriptions.write")) - descriptions.Add(localization.GetRawString("oauth2.scope.subscriptions.write")); - - return descriptions; - } - - public static string GenerateToken(int length) - { - string tokenAlphabet = @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - Random rng = new(); - StringBuilder sb = new(); - for (int i = 0; i < length; i++) - sb.Append(tokenAlphabet[rng.Next(0, tokenAlphabet.Length)]); - return sb.ToString(); - } - - public static SearchParams GetSearchParams(this HttpRequest request) - { - SearchParams searchParams = new() - { - Filters = new SearchFilters(), - QueryFlags = new QueryFlags() - }; - - if (request.Query.TryGetValue("uploadDate", out StringValues uploadDateValues) && - int.TryParse(uploadDateValues, out int uploadDate)) - searchParams.Filters.UploadedIn = (SearchFilters.Types.UploadDate)uploadDate; - - if (request.Query.TryGetValue("type", out StringValues typeValues) && int.TryParse(typeValues, out int type)) - searchParams.Filters.Type = (SearchFilters.Types.ItemType)type; - - if (request.Query.TryGetValue("duration", out StringValues durationValues) && - int.TryParse(durationValues, out int duration)) - searchParams.Filters.Duration = (SearchFilters.Types.VideoDuration)duration; - - if (request.Query.TryGetValue("sortField", out StringValues sortFieldValues) && - int.TryParse(sortFieldValues, out int sortField)) - searchParams.SortBy = (SearchParams.Types.SortField)sortField; - - if (request.Query.TryGetValue("live", out StringValues _)) searchParams.Filters.Live = true; - if (request.Query.TryGetValue("_4k", out StringValues _)) searchParams.Filters.Resolution4K = true; - if (request.Query.TryGetValue("hd", out StringValues _)) searchParams.Filters.Hd = true; - if (request.Query.TryGetValue("subs", out StringValues _)) searchParams.Filters.Subtitles = true; - if (request.Query.TryGetValue("cc", out StringValues _)) searchParams.Filters.CreativeCommons = true; - if (request.Query.TryGetValue("vr360", out StringValues _)) searchParams.Filters.Vr360 = true; - if (request.Query.TryGetValue("vr180", out StringValues _)) searchParams.Filters.Vr180 = true; - if (request.Query.TryGetValue("_3d", out StringValues _)) searchParams.Filters.Resolution3D = true; - if (request.Query.TryGetValue("hdr", out StringValues _)) searchParams.Filters.Hdr = true; - if (request.Query.TryGetValue("location", out StringValues _)) searchParams.Filters.Location = true; - if (request.Query.TryGetValue("purchased", out StringValues _)) searchParams.Filters.Purchased = true; - if (request.Query.TryGetValue("exact", out StringValues _)) searchParams.QueryFlags.ExactSearch = true; - - return searchParams; - } - - public static bool ShouldShowAlert(HttpRequest request) - { - if (Configuration.AlertHash == null) return false; - - if (request.Cookies.TryGetValue("dismissedAlert", out string? cookieVal)) - return cookieVal != Configuration.AlertHash; - - return true; - } - - public static string Md5Sum(string input) - { - using MD5 md5 = MD5.Create(); - byte[] inputBytes = Encoding.ASCII.GetBytes(input); - byte[] hashBytes = md5.ComputeHash(inputBytes); - return Convert.ToHexString(hashBytes); - } - - public static float ExtractHeaderQualityValue(string s) - { - // https://developer.mozilla.org/en-US/docs/Glossary/Quality_values - string[] parts = s.Split("q="); - return parts.Length > 1 && float.TryParse(parts[1], out float val) ? val : 1; - } + } + + return version; + } + + public static string GetInnerTubeVersion() + { + itVersion ??= typeof(InnerTube.InnerTube).Assembly.GetName().Version!.ToString(); + + return itVersion; + } + + public static string GetCodecFromMimeType(string mime) + { + try + { + return mime.Split("codecs=\"")[1].Replace("\"", ""); + } + catch + { + return ""; + } + } + + public static async Task GetProxiedHlsManifest(string url, string? proxyRoot = null, + bool skipCaptions = false) + { + if (!url.StartsWith("http://") && !url.StartsWith("https://")) + url = "https://" + url; + + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); + request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; + + using HttpWebResponse response = (HttpWebResponse)request.GetResponse(); + + await using Stream stream = response.GetResponseStream(); + using StreamReader reader = new(stream); + string manifest = await reader.ReadToEndAsync(); + StringBuilder proxyManifest = new(); + + List types = []; + + if (proxyRoot is not null) + foreach (string s in manifest.Split("\n")) + { + string? manifestType = null; + string? manifestUrl = null; + + if (s.StartsWith("https://www.youtube.com/api/timedtext")) + { + manifestUrl = s; + manifestType = "caption"; + } + else if (s.Contains(".googlevideo.com/videoplayback")) + { + manifestType = "segment"; + manifestUrl = s; + } + else if (s.StartsWith("http")) + { + manifestUrl = s; + manifestType = s[46..].Split("/")[0]; + } + else if (s.StartsWith("#EXT-X-MEDIA:URI=")) + { + manifestUrl = s[18..].Split('"')[0]; + manifestType = s[64..].Split("/")[0]; + } + + string? proxiedUrl = null; + + if (manifestUrl != null) + { + switch (manifestType) + { + case "hls_playlist": + // MPEG-TS playlist + proxiedUrl = "/hls/playlist/" + + HttpUtility.UrlEncode(manifestUrl.Split(manifestType)[1]); + break; + case "hls_timedtext_playlist": + // subtitle playlist + proxiedUrl = "/hls/timedtext/" + + HttpUtility.UrlEncode(manifestUrl.Split(manifestType)[1]); + break; + case "caption": + // subtitles + NameValueCollection qs = HttpUtility.ParseQueryString(manifestUrl.Split("?")[1]); + proxiedUrl = $"/caption/{qs.Get("v")}/{qs.Get("lang")}"; + break; + case "segment": + // HLS segment + proxiedUrl = "/hls/segment/" + + HttpUtility.UrlEncode(manifestUrl.Split("://")[1]); + break; + } + } + + types.Add(manifestType); + + if (skipCaptions && manifestType == "caption") continue; + proxyManifest.AppendLine(proxiedUrl is not null && manifestUrl is not null + //TODO: check if http or https + ? s.Replace(manifestUrl, proxyRoot + proxiedUrl) + : s); + } + else + proxyManifest.Append(manifest); + + + return proxyManifest.ToString(); + } + + public static string GetDashManifest(InnerTubePlayer player, string? proxyUrl = null, bool skipCaptions = false) + { + XmlDocument doc = new(); + + XmlDeclaration xmlDeclaration = doc.CreateXmlDeclaration("1.0", "UTF-8", null); + doc.InsertBefore(xmlDeclaration, doc.DocumentElement); + + XmlElement mpdRoot = doc.CreateElement("MPD"); + mpdRoot.SetAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); + mpdRoot.SetAttribute("xmlns", "urn:mpeg:dash:schema:mpd:2011"); + mpdRoot.SetAttribute("xsi:schemaLocation", "urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd"); + mpdRoot.SetAttribute("profiles", "urn:mpeg:dash:profile:isoff-main:2011"); + mpdRoot.SetAttribute("type", "static"); + mpdRoot.SetAttribute("minBufferTime", "PT1.500S"); + StringBuilder duration = new("PT"); + if (player.Details.Length!.Value.TotalHours > 0) + duration.Append($"{player.Details.Length!.Value.Hours}H"); + if (player.Details.Length!.Value.Minutes > 0) + duration.Append($"{player.Details.Length!.Value.Minutes}M"); + if (player.Details.Length!.Value.Seconds > 0) + duration.Append(player.Details.Length!.Value.Seconds); + mpdRoot.SetAttribute("mediaPresentationDuration", $"{duration}.{player.Details.Length!.Value.Milliseconds}S"); + doc.AppendChild(mpdRoot); + + XmlElement period = doc.CreateElement("Period"); + + period.AppendChild(doc.CreateComment("Audio Adaptation Sets")); + List audios = player.AdaptiveFormats + .Where(x => x.AudioChannels != null) + .ToList(); + IEnumerable> grouped = audios.GroupBy(x => x.AudioTrack?.Id); + foreach (IGrouping formatGroup in grouped.OrderBy(x => x.First().AudioTrack?.AudioIsDefault)) + { + XmlElement audioAdaptationSet = doc.CreateElement("AdaptationSet"); + + audioAdaptationSet.SetAttribute("mimeType", + HttpUtility.ParseQueryString(audios.First().Url.Split('?')[1]).Get("mime")); + audioAdaptationSet.SetAttribute("subsegmentAlignment", "true"); + audioAdaptationSet.SetAttribute("contentType", "audio"); + audioAdaptationSet.SetAttribute("lang", formatGroup.Key); + + if (formatGroup.First().AudioTrack != null) + { + XmlElement label = doc.CreateElement("Label"); + label.InnerText = formatGroup.First().AudioTrack.DisplayName; + audioAdaptationSet.AppendChild(label); + } + + foreach (Format format in formatGroup) + { + XmlElement representation = doc.CreateElement("Representation"); + representation.SetAttribute("id", format.Itag.ToString()); + representation.SetAttribute("codecs", GetCodecFromMimeType(format.Mime)); + representation.SetAttribute("startWithSAP", "1"); + representation.SetAttribute("bandwidth", format.Bitrate.ToString()); + + XmlElement audioChannelConfiguration = doc.CreateElement("AudioChannelConfiguration"); + audioChannelConfiguration.SetAttribute("schemeIdUri", + "urn:mpeg:dash:23003:3:audio_channel_configuration:2011"); + audioChannelConfiguration.SetAttribute("value", format.AudioChannels.ToString()); + representation.AppendChild(audioChannelConfiguration); + + XmlElement baseUrl = doc.CreateElement("BaseURL"); + baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) + ? format.Url + : $"{proxyUrl}/media/{player.Details.Id}/{format.Itag}?audioTrackId={format.AudioTrack?.Id}"; + representation.AppendChild(baseUrl); + + if (format.IndexRange != null && format.InitRange != null) + { + XmlElement segmentBase = doc.CreateElement("SegmentBase"); + // sometimes wrong?? idk + segmentBase.SetAttribute("indexRange", $"{format.IndexRange.Start}-{format.IndexRange.End}"); + segmentBase.SetAttribute("indexRangeExact", "true"); + + XmlElement initialization = doc.CreateElement("Initialization"); + initialization.SetAttribute("range", $"{format.InitRange.Start}-{format.InitRange.End}"); + + segmentBase.AppendChild(initialization); + representation.AppendChild(segmentBase); + } + + audioAdaptationSet.AppendChild(representation); + } + + period.AppendChild(audioAdaptationSet); + } + + period.AppendChild(doc.CreateComment("Video Adaptation Set")); + + List videos = player.AdaptiveFormats.Where(x => x.AudioChannels == null).ToList(); + + XmlElement videoAdaptationSet = doc.CreateElement("AdaptationSet"); + videoAdaptationSet.SetAttribute("mimeType", + HttpUtility.ParseQueryString(videos.FirstOrDefault()?.Url.Split('?')[0] ?? "mime=video/mp4") + .Get("mime")); + videoAdaptationSet.SetAttribute("subsegmentAlignment", "true"); + videoAdaptationSet.SetAttribute("contentType", "video"); + + foreach (Format format in videos) + { + XmlElement representation = doc.CreateElement("Representation"); + representation.SetAttribute("id", format.Itag.ToString()); + representation.SetAttribute("codecs", GetCodecFromMimeType(format.Mime)); + representation.SetAttribute("startWithSAP", "1"); + representation.SetAttribute("width", format.Width.ToString()); + representation.SetAttribute("height", format.Height.ToString()); + representation.SetAttribute("bandwidth", format.Bitrate.ToString()); + + XmlElement baseUrl = doc.CreateElement("BaseURL"); + baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) + ? format.Url + : $"{proxyUrl}/media/{player.Details.Id}/{format.Itag}"; + representation.AppendChild(baseUrl); + + if (format.IndexRange != null && format.InitRange != null) + { + XmlElement segmentBase = doc.CreateElement("SegmentBase"); + segmentBase.SetAttribute("indexRange", $"{format.IndexRange.Start}-{format.IndexRange.End}"); + segmentBase.SetAttribute("indexRangeExact", "true"); + + XmlElement initialization = doc.CreateElement("Initialization"); + initialization.SetAttribute("range", $"{format.InitRange.Start}-{format.InitRange.End}"); + + segmentBase.AppendChild(initialization); + representation.AppendChild(segmentBase); + } + + videoAdaptationSet.AppendChild(representation); + } + + period.AppendChild(videoAdaptationSet); + + if (!skipCaptions) + { + period.AppendChild(doc.CreateComment("Subtitle Adaptation Sets")); + foreach (InnerTubePlayer.VideoCaption subtitle in player.Captions) + { + period.AppendChild(doc.CreateComment(subtitle.Label)); + XmlElement adaptationSet = doc.CreateElement("AdaptationSet"); + adaptationSet.SetAttribute("mimeType", "text/vtt"); + adaptationSet.SetAttribute("lang", subtitle.LanguageCode); + + XmlElement representation = doc.CreateElement("Representation"); + representation.SetAttribute("id", $"caption_{subtitle.LanguageCode.ToLower()}"); + representation.SetAttribute("bandwidth", "256"); // ...why do we need this for a plaintext file + + XmlElement baseUrl = doc.CreateElement("BaseURL"); + string url = subtitle.BaseUrl.ToString(); + url = url.Replace("fmt=srv3", "fmt=vtt"); + baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) + ? url + : $"{proxyUrl}/caption/{player.Details.Id}/{subtitle.LanguageCode}"; + + representation.AppendChild(baseUrl); + adaptationSet.AppendChild(representation); + period.AppendChild(adaptationSet); + } + } + + mpdRoot.AppendChild(period); + return doc.OuterXml; + } + + public static string ToKMB(this long num) => + num switch + { + > 999999999 or < -999999999 => num.ToString("0,,,.###B", CultureInfo.InvariantCulture), + > 999999 or < -999999 => num.ToString("0,,.##M", CultureInfo.InvariantCulture), + > 999 or < -999 => num.ToString("0,.#K", CultureInfo.InvariantCulture), + var _ => num.ToString(CultureInfo.InvariantCulture) + }; + + public static string ToDurationString(this TimeSpan ts) + { + string str = ts.ToString(); + return str.StartsWith("00:") ? str[3..] : str; + } + + public static string GetContinuationUrl(string currentPath, string contToken) + { + string[] parts = currentPath.Split("?"); + NameValueCollection query = parts.Length > 1 + ? HttpUtility.ParseQueryString(parts[1]) + : []; + query.Set("continuation", contToken); + return $"{parts[0]}?{query.AllKeys.Select(x => x + "=" + query.Get(x)).Aggregate((a, b) => $"{a}&{b}")}"; + } + + public static SubscriptionType GetSubscriptionType(this HttpContext context, string? channelId) + { + if (channelId is null) return SubscriptionType.NONE; + DatabaseUser? user = DatabaseManager.Users.GetUserFromToken(context.Request.Cookies["token"] ?? "").Result; + if (user is null) return SubscriptionType.NONE; + return user.Subscriptions.TryGetValue(channelId, out SubscriptionType type) ? type : SubscriptionType.NONE; + } + + public static string GetExtension(this Format format) + { + if (format.Mime.StartsWith("video")) + return format.Mime + .Split("/").Last() + .Split(";").First(); + if (format.Mime.Contains("opus")) + return "opus"; + if (format.Mime.Contains("mp4a")) + return "mp3"; + + return "mp4"; + } + + public static string[] FindInvalidScopes(string[] scopes) => + scopes.Where(x => !OauthScopes.Contains(x)).ToArray(); + + public static IEnumerable GetScopeDescriptions(string[] modelScopes, LocalizationManager localization) + { + List descriptions = []; + + // dangerous ones are at the top + if (modelScopes.Contains("logins.read")) + descriptions.Add("!" + localization.GetRawString("oauth2.scope.logins.read")); + if (modelScopes.Contains("logins.delete")) + descriptions.Add("!" + localization.GetRawString("oauth2.scope.logins.write")); + + descriptions.Add("Access YouTube data"); + + if (modelScopes.Contains("playlists.read") && modelScopes.Contains("playlists.write")) + descriptions.Add(localization.GetRawString("oauth2.scope.playlists.rw")); + else if (modelScopes.Contains("playlists.read")) + descriptions.Add(localization.GetRawString("oauth2.scope.playlists.read")); + else if (modelScopes.Contains("playlists.write")) + descriptions.Add(localization.GetRawString("oauth2.scope.playlists.write")); + + if (modelScopes.Contains("subscriptions.read")) + { + descriptions.Add(localization.GetRawString("oauth2.scope.subscriptions.read")); + descriptions.Add(localization.GetRawString("oauth2.scope.subscriptions.feed")); + } + + if (modelScopes.Contains("subscriptions.write")) + descriptions.Add(localization.GetRawString("oauth2.scope.subscriptions.write")); + + return descriptions; + } + + public static string GenerateToken(int length) + { + string tokenAlphabet = @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + Random rng = new(); + StringBuilder sb = new(); + for (int i = 0; i < length; i++) + sb.Append(tokenAlphabet[rng.Next(0, tokenAlphabet.Length)]); + return sb.ToString(); + } + + public static SearchParams GetSearchParams(this HttpRequest request) + { + SearchParams searchParams = new() + { + Filters = new SearchFilters(), + QueryFlags = new QueryFlags() + }; + + if (request.Query.TryGetValue("uploadDate", out StringValues uploadDateValues) && + int.TryParse(uploadDateValues, out int uploadDate)) + searchParams.Filters.UploadedIn = (SearchFilters.Types.UploadDate)uploadDate; + + if (request.Query.TryGetValue("type", out StringValues typeValues) && int.TryParse(typeValues, out int type)) + searchParams.Filters.Type = (SearchFilters.Types.ItemType)type; + + if (request.Query.TryGetValue("duration", out StringValues durationValues) && + int.TryParse(durationValues, out int duration)) + searchParams.Filters.Duration = (SearchFilters.Types.VideoDuration)duration; + + if (request.Query.TryGetValue("sortField", out StringValues sortFieldValues) && + int.TryParse(sortFieldValues, out int sortField)) + searchParams.SortBy = (SearchParams.Types.SortField)sortField; + + if (request.Query.TryGetValue("live", out StringValues _)) searchParams.Filters.Live = true; + if (request.Query.TryGetValue("_4k", out StringValues _)) searchParams.Filters.Resolution4K = true; + if (request.Query.TryGetValue("hd", out StringValues _)) searchParams.Filters.Hd = true; + if (request.Query.TryGetValue("subs", out StringValues _)) searchParams.Filters.Subtitles = true; + if (request.Query.TryGetValue("cc", out StringValues _)) searchParams.Filters.CreativeCommons = true; + if (request.Query.TryGetValue("vr360", out StringValues _)) searchParams.Filters.Vr360 = true; + if (request.Query.TryGetValue("vr180", out StringValues _)) searchParams.Filters.Vr180 = true; + if (request.Query.TryGetValue("_3d", out StringValues _)) searchParams.Filters.Resolution3D = true; + if (request.Query.TryGetValue("hdr", out StringValues _)) searchParams.Filters.Hdr = true; + if (request.Query.TryGetValue("location", out StringValues _)) searchParams.Filters.Location = true; + if (request.Query.TryGetValue("purchased", out StringValues _)) searchParams.Filters.Purchased = true; + if (request.Query.TryGetValue("exact", out StringValues _)) searchParams.QueryFlags.ExactSearch = true; + + return searchParams; + } + + public static string BuildSearchQueryString(string query, SearchParams? filters, int? page) + { + StringBuilder sb = new(); + + sb.Append("search_query=" + HttpUtility.UrlEncode(query)); + if (page != null) + sb.Append("&page=" + page); + + if (filters != null) + { + if (filters.Filters.UploadedIn != SearchFilters.Types.UploadDate.UnsetDate) + sb.AppendLine("&uploadDate=" + (int)filters.SortBy); + if (filters.Filters.Type != SearchFilters.Types.ItemType.UnsetType) + sb.AppendLine("&type=" + (int)filters.SortBy); + if (filters.Filters.Duration != SearchFilters.Types.VideoDuration.UnsetDuration) + sb.AppendLine("&duration=" + (int)filters.SortBy); + if (filters.SortBy != SearchParams.Types.SortField.Relevance) + sb.AppendLine("&sortField=" + (int)filters.SortBy); + + if (filters.Filters?.Live == true) sb.AppendLine("&live=on"); + if (filters.Filters?.Resolution4K == true) sb.AppendLine("&_4k=on"); + if (filters.Filters?.Hd == true) sb.AppendLine("&hd=on"); + if (filters.Filters?.Subtitles == true) sb.AppendLine("&subs=on"); + if (filters.Filters?.CreativeCommons == true) sb.AppendLine("&cc=on"); + if (filters.Filters?.Vr360 == true) sb.AppendLine("&vr360=on"); + if (filters.Filters?.Vr180 == true) sb.AppendLine("&vr180=on"); + if (filters.Filters?.Resolution3D == true) sb.AppendLine("&_3d=on"); + if (filters.Filters?.Hdr == true) sb.AppendLine("&hdr=on"); + if (filters.Filters?.Location == true) sb.AppendLine("&location=on"); + if (filters.Filters?.Purchased == true) sb.AppendLine("&purchased=on"); + if (filters.QueryFlags?.ExactSearch == true) sb.AppendLine("&exact=on"); + } + + return sb.ToString(); + } + + public static bool ShouldShowAlert(HttpRequest request) + { + if (Configuration.AlertHash == null) return false; + + if (request.Cookies.TryGetValue("dismissedAlert", out string? cookieVal)) + return cookieVal != Configuration.AlertHash; + + return true; + } + + public static string Md5Sum(string input) + { + using MD5 md5 = MD5.Create(); + byte[] inputBytes = Encoding.ASCII.GetBytes(input); + byte[] hashBytes = md5.ComputeHash(inputBytes); + return Convert.ToHexString(hashBytes); + } + + public static float ExtractHeaderQualityValue(string s) + { + // https://developer.mozilla.org/en-US/docs/Glossary/Quality_values + string[] parts = s.Split("q="); + return parts.Length > 1 && float.TryParse(parts[1], out float val) ? val : 1; + } + + public static ApiLocals GetLocals() => + new() + { + Languages = new Dictionary + { + ["af"] = "Afrikaans", + ["az"] = "Azərbaycan", + ["id"] = "Bahasa Indonesia", + ["ms"] = "Bahasa Malaysia", + ["bs"] = "Bosanski", + ["ca"] = "Català", + ["cs"] = "Čeština", + ["da"] = "Dansk", + ["de"] = "Deutsch", + ["et"] = "Eesti", + ["en-IN"] = "English (India)", + ["en-GB"] = "English (UK)", + ["en"] = "English (US)", + ["es"] = "Español (España)", + ["es-419"] = "Español (Latinoamérica)", + ["es-US"] = "Español (US)", + ["eu"] = "Euskara", + ["fil"] = "Filipino", + ["fr"] = "Français", + ["fr-CA"] = "Français (Canada)", + ["gl"] = "Galego", + ["hr"] = "Hrvatski", + ["zu"] = "IsiZulu", + ["is"] = "Íslenska", + ["it"] = "Italiano", + ["sw"] = "Kiswahili", + ["lv"] = "Latviešu valoda", + ["lt"] = "Lietuvių", + ["hu"] = "Magyar", + ["nl"] = "Nederlands", + ["no"] = "Norsk", + ["uz"] = "O‘zbek", + ["pl"] = "Polski", + ["pt-PT"] = "Português", + ["pt"] = "Português (Brasil)", + ["ro"] = "Română", + ["sq"] = "Shqip", + ["sk"] = "Slovenčina", + ["sl"] = "Slovenščina", + ["sr-Latn"] = "Srpski", + ["fi"] = "Suomi", + ["sv"] = "Svenska", + ["vi"] = "Tiếng Việt", + ["tr"] = "Türkçe", + ["be"] = "Беларуская", + ["bg"] = "Български", + ["ky"] = "Кыргызча", + ["kk"] = "Қазақ Тілі", + ["mk"] = "Македонски", + ["mn"] = "Монгол", + ["ru"] = "Русский", + ["sr"] = "Српски", + ["uk"] = "Українська", + ["el"] = "Ελληνικά", + ["hy"] = "Հայերեն", + ["iw"] = "עברית", + ["ur"] = "اردو", + ["ar"] = "العربية", + ["fa"] = "فارسی", + ["ne"] = "नेपाली", + ["mr"] = "मराठी", + ["hi"] = "हिन्दी", + ["as"] = "অসমীয়া", + ["bn"] = "বাংলা", + ["pa"] = "ਪੰਜਾਬੀ", + ["gu"] = "ગુજરાતી", + ["or"] = "ଓଡ଼ିଆ", + ["ta"] = "தமிழ்", + ["te"] = "తెలుగు", + ["kn"] = "ಕನ್ನಡ", + ["ml"] = "മലയാളം", + ["si"] = "සිංහල", + ["th"] = "ภาษาไทย", + ["lo"] = "ລາວ", + ["my"] = "ဗမာ", + ["ka"] = "ქართული", + ["am"] = "አማርኛ", + ["km"] = "ខ្មែរ", + ["zh-CN"] = "中文 (简体)", + ["zh-TW"] = "中文 (繁體)", + ["zh-HK"] = "中文 (香港)", + ["ja"] = "日本語", + ["ko"] = "한국어" + }, + Regions = new Dictionary + { + ["DZ"] = "Algeria", + ["AR"] = "Argentina", + ["AU"] = "Australia", + ["AT"] = "Austria", + ["AZ"] = "Azerbaijan", + ["BH"] = "Bahrain", + ["BD"] = "Bangladesh", + ["BY"] = "Belarus", + ["BE"] = "Belgium", + ["BO"] = "Bolivia", + ["BA"] = "Bosnia and Herzegovina", + ["BR"] = "Brazil", + ["BG"] = "Bulgaria", + ["KH"] = "Cambodia", + ["CA"] = "Canada", + ["CL"] = "Chile", + ["CO"] = "Colombia", + ["CR"] = "Costa Rica", + ["HR"] = "Croatia", + ["CY"] = "Cyprus", + ["CZ"] = "Czechia", + ["DK"] = "Denmark", + ["DO"] = "Dominican Republic", + ["EC"] = "Ecuador", + ["EG"] = "Egypt", + ["SV"] = "El Salvador", + ["EE"] = "Estonia", + ["FI"] = "Finland", + ["FR"] = "France", + ["GE"] = "Georgia", + ["DE"] = "Germany", + ["GH"] = "Ghana", + ["GR"] = "Greece", + ["GT"] = "Guatemala", + ["HN"] = "Honduras", + ["HK"] = "Hong Kong", + ["HU"] = "Hungary", + ["IS"] = "Iceland", + ["IN"] = "India", + ["ID"] = "Indonesia", + ["IQ"] = "Iraq", + ["IE"] = "Ireland", + ["IL"] = "Israel", + ["IT"] = "Italy", + ["JM"] = "Jamaica", + ["JP"] = "Japan", + ["JO"] = "Jordan", + ["KZ"] = "Kazakhstan", + ["KE"] = "Kenya", + ["KW"] = "Kuwait", + ["LA"] = "Laos", + ["LV"] = "Latvia", + ["LB"] = "Lebanon", + ["LY"] = "Libya", + ["LI"] = "Liechtenstein", + ["LT"] = "Lithuania", + ["LU"] = "Luxembourg", + ["MY"] = "Malaysia", + ["MT"] = "Malta", + ["MX"] = "Mexico", + ["MD"] = "Moldova", + ["ME"] = "Montenegro", + ["MA"] = "Morocco", + ["NP"] = "Nepal", + ["NL"] = "Netherlands", + ["NZ"] = "New Zealand", + ["NI"] = "Nicaragua", + ["NG"] = "Nigeria", + ["MK"] = "North Macedonia", + ["NO"] = "Norway", + ["OM"] = "Oman", + ["PK"] = "Pakistan", + ["PA"] = "Panama", + ["PG"] = "Papua New Guinea", + ["PY"] = "Paraguay", + ["PE"] = "Peru", + ["PH"] = "Philippines", + ["PL"] = "Poland", + ["PT"] = "Portugal", + ["PR"] = "Puerto Rico", + ["QA"] = "Qatar", + ["RO"] = "Romania", + ["RU"] = "Russia", + ["SA"] = "Saudi Arabia", + ["SN"] = "Senegal", + ["RS"] = "Serbia", + ["SG"] = "Singapore", + ["SK"] = "Slovakia", + ["SI"] = "Slovenia", + ["ZA"] = "South Africa", + ["KR"] = "South Korea", + ["ES"] = "Spain", + ["LK"] = "Sri Lanka", + ["SE"] = "Sweden", + ["CH"] = "Switzerland", + ["TW"] = "Taiwan", + ["TZ"] = "Tanzania", + ["TH"] = "Thailand", + ["TN"] = "Tunisia", + ["TR"] = "Turkey", + ["UG"] = "Uganda", + ["UA"] = "Ukraine", + ["AE"] = "United Arab Emirates", + ["GB"] = "United Kingdom", + ["US"] = "United States", + ["UY"] = "Uruguay", + ["VE"] = "Venezuela", + ["VN"] = "Vietnam", + ["YE"] = "Yemen", + ["ZW"] = "Zimbabwe" + } + }; + + public static string GetAspectRatio(WatchContext context) + { + Format? format = context.Player.Player?.AdaptiveFormats.LastOrDefault(x => x.Mime.StartsWith("video")); + return format != null + ? Math.Max(1f, Math.Min((float)format.Width / (float)format.Height, 3)).ToString(CultureInfo.InvariantCulture) + : "16/9"; + } + + public static string ToRelativePublishedDate(DateTimeOffset date) + { + TimeSpan diff = DateTimeOffset.Now - date; + int totalDays = (int)Math.Floor(diff.TotalDays); + switch (totalDays) + { + case > 365: + return $"-{Math.Floor(diff.TotalDays / 365f)}Y"; + case > 30: + return $"-{Math.Floor(diff.TotalDays / 30f)}M"; + case > 7: + return $"-{Math.Floor(diff.TotalDays / 7f)}W"; + case > 0: + return $"-{Math.Floor(diff.TotalDays)}D"; + } + + if (diff.Hours >= 1) + return $"-{diff.Hours}h"; + if (diff.Minutes >= 1) + return $"-{diff.Minutes}m"; + return $"-{diff.Seconds}m"; + } } \ No newline at end of file diff --git a/LightTube/Views/Feed/Library.cshtml b/LightTube/Views/Feed/Library.cshtml index 45482b1f..acd05b9f 100644 --- a/LightTube/Views/Feed/Library.cshtml +++ b/LightTube/Views/Feed/Library.cshtml @@ -25,7 +25,7 @@
- + @playlist.Name
diff --git a/LightTube/Views/Shared/ChannelTabItem.cshtml b/LightTube/Views/Shared/ChannelTabItem.cshtml index 162a9ec1..428efd84 100644 --- a/LightTube/Views/Shared/ChannelTabItem.cshtml +++ b/LightTube/Views/Shared/ChannelTabItem.cshtml @@ -1,12 +1,12 @@ @using InnerTube -@model (ChannelTabs Tab, string Id, LightTube.Localization.LocalizationManager Localization) +@using ChannelTab = InnerTube.Models.ChannelTab +@model (ChannelTab Tab, string Id, bool HasAbout, LightTube.Localization.LocalizationManager Localization) @{ string className = "channel-tabs__item"; - string lastPathPart = Context.Request.Path.Value.Split("/").Last().Split("?").First(); - if (lastPathPart == Model.Tab.ToString().ToLower() || (lastPathPart == Model.Id && Model.Tab == ChannelTabs.Home)) + if (Model.Tab.Selected && !Model.HasAbout) { className += " active"; } } -@Model.Localization.GetString("channel.tab." + Model.Tab.ToString().ToLower()) \ No newline at end of file +@Model.Localization.GetString("channel.tab." + Model.Tab.Tab.ToString().ToLower()) \ No newline at end of file diff --git a/LightTube/Views/Shared/Player.cshtml b/LightTube/Views/Shared/Player.cshtml index f6f585d4..3ea3c248 100644 --- a/LightTube/Views/Shared/Player.cshtml +++ b/LightTube/Views/Shared/Player.cshtml @@ -1,6 +1,7 @@ -@using InnerTube -@using System.Text.Json +@using System.Text.Json @using InnerTube.Exceptions +@using InnerTube.Models +@using InnerTube.Protobuf.Responses @using JsonSerializer = System.Text.Json.JsonSerializer @model PlayerContext @@ -22,16 +23,16 @@
- @if (e.Code == "LOGIN_REQUIRED") + @if (e.Code == PlayabilityStatus.Types.Status.LoginRequired) { -
@e.Error
+
@e.Reason

@Model.Localization.GetString("error.player.agerestricted")

} else { -
@e.Error
-

@e.Description

- @if (e.Code is "CONTENT_CHECK_REQUIRED" or "AGE_CHECK_REQUIRED") +
@e.Reason
+

@e.Subreason

+ @if (e.Code is PlayabilityStatus.Types.Status.ContentCheckRequired) { @Model.Localization.GetString("error.player.contentcheck") } @@ -79,16 +80,16 @@ else if ((Model.UseHls || Model.UseDash) && !Model.Player.Formats.Any())