owo
16
core/InnerTube/CacheItem.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace InnerTube
|
||||||
|
{
|
||||||
|
public class CacheItem<T>
|
||||||
|
{
|
||||||
|
public T Item;
|
||||||
|
public DateTimeOffset ExpireTime;
|
||||||
|
|
||||||
|
public CacheItem(T item, TimeSpan expiresIn)
|
||||||
|
{
|
||||||
|
Item = item;
|
||||||
|
ExpireTime = DateTimeOffset.Now.Add(expiresIn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
core/InnerTube/Enums.cs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
namespace InnerTube
|
||||||
|
{
|
||||||
|
public enum ChannelTabs
|
||||||
|
{
|
||||||
|
Home,
|
||||||
|
Videos,
|
||||||
|
Playlists,
|
||||||
|
Community,
|
||||||
|
Channels,
|
||||||
|
About
|
||||||
|
}
|
||||||
|
}
|
11
core/InnerTube/InnerTube.csproj
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net5.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
380
core/InnerTube/Models/DynamicItem.cs
Normal file
|
@ -0,0 +1,380 @@
|
||||||
|
using System;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class DynamicItem
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public string Title;
|
||||||
|
public Thumbnail[] Thumbnails;
|
||||||
|
|
||||||
|
public virtual XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("DynamicItem");
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VideoItem : DynamicItem
|
||||||
|
{
|
||||||
|
public string UploadedAt;
|
||||||
|
public long Views;
|
||||||
|
public Channel Channel;
|
||||||
|
public string Duration;
|
||||||
|
public string Description;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Video");
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
item.SetAttribute("duration", Duration);
|
||||||
|
item.SetAttribute("views", Views.ToString());
|
||||||
|
item.SetAttribute("uploadedAt", UploadedAt);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
if (Channel is not null)
|
||||||
|
item.AppendChild(Channel.GetXmlElement(doc));
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Description))
|
||||||
|
{
|
||||||
|
XmlElement description = doc.CreateElement("Description");
|
||||||
|
description.InnerText = Description;
|
||||||
|
item.AppendChild(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaylistItem : DynamicItem
|
||||||
|
{
|
||||||
|
public int VideoCount;
|
||||||
|
public string FirstVideoId;
|
||||||
|
public Channel Channel;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Playlist");
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
item.SetAttribute("videoCount", VideoCount.ToString());
|
||||||
|
item.SetAttribute("firstVideoId", FirstVideoId);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
item.AppendChild(Channel.GetXmlElement(doc));
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RadioItem : DynamicItem
|
||||||
|
{
|
||||||
|
public string FirstVideoId;
|
||||||
|
public Channel Channel;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Radio");
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
item.SetAttribute("firstVideoId", FirstVideoId);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
item.AppendChild(Channel.GetXmlElement(doc));
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChannelItem : DynamicItem
|
||||||
|
{
|
||||||
|
public string Url;
|
||||||
|
public string Description;
|
||||||
|
public long VideoCount;
|
||||||
|
public string Subscribers;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Channel");
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
item.SetAttribute("videoCount", VideoCount.ToString());
|
||||||
|
item.SetAttribute("subscribers", Subscribers);
|
||||||
|
if (!string.IsNullOrWhiteSpace(Url))
|
||||||
|
item.SetAttribute("customUrl", Url);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Name");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
|
||||||
|
XmlElement description = doc.CreateElement("Description");
|
||||||
|
description.InnerText = Description;
|
||||||
|
item.AppendChild(description);
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Avatar");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ContinuationItem : DynamicItem
|
||||||
|
{
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Continuation");
|
||||||
|
item.SetAttribute("key", Id);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ShelfItem : DynamicItem
|
||||||
|
{
|
||||||
|
public DynamicItem[] Items;
|
||||||
|
public int CollapsedItemCount;
|
||||||
|
public BadgeItem[] Badges;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Shelf");
|
||||||
|
item.SetAttribute("title", Title);
|
||||||
|
item.SetAttribute("collapsedItemCount", CollapsedItemCount.ToString());
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Badges.Length > 0)
|
||||||
|
{
|
||||||
|
XmlElement badges = doc.CreateElement("Badges");
|
||||||
|
foreach (BadgeItem badge in Badges) badges.AppendChild(badge.GetXmlElement(doc));
|
||||||
|
item.AppendChild(badges);
|
||||||
|
}
|
||||||
|
|
||||||
|
XmlElement items = doc.CreateElement("Items");
|
||||||
|
foreach (DynamicItem dynamicItem in Items) items.AppendChild(dynamicItem.GetXmlElement(doc));
|
||||||
|
item.AppendChild(items);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HorizontalCardListItem : DynamicItem
|
||||||
|
{
|
||||||
|
public DynamicItem[] Items;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("CardList");
|
||||||
|
item.SetAttribute("title", Title);
|
||||||
|
|
||||||
|
foreach (DynamicItem dynamicItem in Items) item.AppendChild(dynamicItem.GetXmlElement(doc));
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CardItem : DynamicItem
|
||||||
|
{
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Card");
|
||||||
|
item.SetAttribute("title", Title);
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaylistVideoItem : DynamicItem
|
||||||
|
{
|
||||||
|
public long Index;
|
||||||
|
public Channel Channel;
|
||||||
|
public string Duration;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Video");
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
item.SetAttribute("index", Index.ToString());
|
||||||
|
item.SetAttribute("duration", Duration);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
item.AppendChild(Channel.GetXmlElement(doc));
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ItemSectionItem : DynamicItem
|
||||||
|
{
|
||||||
|
public DynamicItem[] Contents;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement section = doc.CreateElement("ItemSection");
|
||||||
|
foreach (DynamicItem item in Contents) section.AppendChild(item.GetXmlElement(doc));
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MessageItem : DynamicItem
|
||||||
|
{
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement message = doc.CreateElement("Message");
|
||||||
|
message.InnerText = Title;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChannelAboutItem : DynamicItem
|
||||||
|
{
|
||||||
|
public string Description;
|
||||||
|
public string Country;
|
||||||
|
public string Joined;
|
||||||
|
public string ViewCount;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement about = doc.CreateElement("About");
|
||||||
|
XmlElement description = doc.CreateElement("Description");
|
||||||
|
description.InnerText = Description;
|
||||||
|
about.AppendChild(description);
|
||||||
|
XmlElement country = doc.CreateElement("Location");
|
||||||
|
country.InnerText = Country;
|
||||||
|
about.AppendChild(country);
|
||||||
|
XmlElement joined = doc.CreateElement("Joined");
|
||||||
|
joined.InnerText = Joined;
|
||||||
|
about.AppendChild(joined);
|
||||||
|
XmlElement viewCount = doc.CreateElement("ViewCount");
|
||||||
|
viewCount.InnerText = ViewCount;
|
||||||
|
about.AppendChild(viewCount);
|
||||||
|
return about;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BadgeItem : DynamicItem
|
||||||
|
{
|
||||||
|
public string Style;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement badge = doc.CreateElement("Badge");
|
||||||
|
badge.SetAttribute("style", Style);
|
||||||
|
badge.InnerText = Title;
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StationItem : DynamicItem
|
||||||
|
{
|
||||||
|
public int VideoCount;
|
||||||
|
public string FirstVideoId;
|
||||||
|
public string Description;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Station");
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
item.SetAttribute("videoCount", VideoCount.ToString());
|
||||||
|
item.SetAttribute("firstVideoId", FirstVideoId);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
|
||||||
|
XmlElement description = doc.CreateElement("Description");
|
||||||
|
description.InnerText = Description;
|
||||||
|
item.AppendChild(description);
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
67
core/InnerTube/Models/RequestContext.cs
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class RequestContext
|
||||||
|
{
|
||||||
|
[JsonProperty("context")] public Context Context;
|
||||||
|
|
||||||
|
public static string BuildRequestContextJson(Dictionary<string, object> additionalFields, string language = "en",
|
||||||
|
string region = "US", string clientName = "WEB", string clientVersion = "2.20220224.07.00")
|
||||||
|
{
|
||||||
|
RequestContext ctx = new()
|
||||||
|
{
|
||||||
|
Context = new Context(
|
||||||
|
new RequestClient(language, region, clientName, clientVersion),
|
||||||
|
new RequestUser(false))
|
||||||
|
};
|
||||||
|
|
||||||
|
string json1 = JsonConvert.SerializeObject(ctx);
|
||||||
|
Dictionary<string, object> json2 = JsonConvert.DeserializeObject<Dictionary<string, object>>(json1);
|
||||||
|
foreach (KeyValuePair<string,object> pair in additionalFields) json2.Add(pair.Key, pair.Value);
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(json2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Context
|
||||||
|
{
|
||||||
|
[JsonProperty("client")] public RequestClient RequestClient { get; set; }
|
||||||
|
[JsonProperty("user")] public RequestUser RequestUser { get; set; }
|
||||||
|
|
||||||
|
public Context(RequestClient requestClient, RequestUser requestUser)
|
||||||
|
{
|
||||||
|
RequestClient = requestClient;
|
||||||
|
RequestUser = requestUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RequestClient
|
||||||
|
{
|
||||||
|
[JsonProperty("hl")] public string Language { get; set; }
|
||||||
|
[JsonProperty("gl")] public string Region { get; set; }
|
||||||
|
[JsonProperty("clientName")] public string ClientName { get; set; }
|
||||||
|
[JsonProperty("clientVersion")] public string ClientVersion { get; set; }
|
||||||
|
[JsonProperty("deviceModel")] public string DeviceModel { get; set; }
|
||||||
|
|
||||||
|
public RequestClient(string language, string region, string clientName, string clientVersion)
|
||||||
|
{
|
||||||
|
Language = language;
|
||||||
|
Region = region;
|
||||||
|
ClientName = clientName;
|
||||||
|
ClientVersion = clientVersion;
|
||||||
|
if (clientName == "IOS") DeviceModel = "iPhone14,3";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RequestUser
|
||||||
|
{
|
||||||
|
[JsonProperty("lockedSafetyMode")] public bool LockedSafetyMode { get; set; }
|
||||||
|
|
||||||
|
public RequestUser(bool lockedSafetyMode)
|
||||||
|
{
|
||||||
|
LockedSafetyMode = lockedSafetyMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
71
core/InnerTube/Models/YoutubeChannel.cs
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
using System.Xml;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class YoutubeChannel
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public string Name;
|
||||||
|
public string Url;
|
||||||
|
public Thumbnail[] Avatars;
|
||||||
|
public Thumbnail[] Banners;
|
||||||
|
public string Description;
|
||||||
|
public DynamicItem[] Videos;
|
||||||
|
public string Subscribers;
|
||||||
|
|
||||||
|
public string GetHtmlDescription()
|
||||||
|
{
|
||||||
|
return Utils.GetHtmlDescription(Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public XmlDocument GetXmlDocument()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement channel = doc.CreateElement("Channel");
|
||||||
|
channel.SetAttribute("id", Id);
|
||||||
|
if (Id != Url)
|
||||||
|
channel.SetAttribute("customUrl", Url);
|
||||||
|
|
||||||
|
XmlElement metadata = doc.CreateElement("Metadata");
|
||||||
|
|
||||||
|
XmlElement name = doc.CreateElement("Name");
|
||||||
|
name.InnerText = Name;
|
||||||
|
metadata.AppendChild(name);
|
||||||
|
|
||||||
|
XmlElement avatars = doc.CreateElement("Avatars");
|
||||||
|
foreach (Thumbnail t in Avatars)
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
avatars.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
metadata.AppendChild(avatars);
|
||||||
|
|
||||||
|
XmlElement banners = doc.CreateElement("Banners");
|
||||||
|
foreach (Thumbnail t in Banners)
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
banners.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
metadata.AppendChild(banners);
|
||||||
|
|
||||||
|
XmlElement subscriberCount = doc.CreateElement("Subscribers");
|
||||||
|
subscriberCount.InnerText = Subscribers;
|
||||||
|
metadata.AppendChild(subscriberCount);
|
||||||
|
|
||||||
|
channel.AppendChild(metadata);
|
||||||
|
|
||||||
|
XmlElement contents = doc.CreateElement("Contents");
|
||||||
|
foreach (DynamicItem item in Videos) contents.AppendChild(item.GetXmlElement(doc));
|
||||||
|
channel.AppendChild(contents);
|
||||||
|
|
||||||
|
doc.AppendChild(channel);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
core/InnerTube/Models/YoutubeLocals.cs
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Xml;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class YoutubeLocals
|
||||||
|
{
|
||||||
|
public Dictionary<string, string> Languages { get; set; }
|
||||||
|
public Dictionary<string, string> Regions { get; set; }
|
||||||
|
|
||||||
|
public XmlDocument GetXmlDocument()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement locals = doc.CreateElement("Locals");
|
||||||
|
|
||||||
|
XmlElement languages = doc.CreateElement("Languages");
|
||||||
|
foreach (KeyValuePair<string, string> l in Languages)
|
||||||
|
{
|
||||||
|
XmlElement language = doc.CreateElement("Language");
|
||||||
|
language.SetAttribute("hl", l.Key);
|
||||||
|
language.InnerText = l.Value;
|
||||||
|
languages.AppendChild(language);
|
||||||
|
}
|
||||||
|
locals.AppendChild(languages);
|
||||||
|
|
||||||
|
XmlElement regions = doc.CreateElement("Regions");
|
||||||
|
foreach (KeyValuePair<string, string> r in Regions)
|
||||||
|
{
|
||||||
|
XmlElement region = doc.CreateElement("Region");
|
||||||
|
region.SetAttribute("gl", r.Key);
|
||||||
|
region.InnerText = r.Value;
|
||||||
|
regions.AppendChild(region);
|
||||||
|
}
|
||||||
|
locals.AppendChild(regions);
|
||||||
|
|
||||||
|
doc.AppendChild(locals);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
230
core/InnerTube/Models/YoutubePlayer.cs
Normal file
|
@ -0,0 +1,230 @@
|
||||||
|
using System;
|
||||||
|
using System.Xml;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class YoutubePlayer
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public string[] Tags { get; set; }
|
||||||
|
public Channel Channel { get; set; }
|
||||||
|
public long? Duration { get; set; }
|
||||||
|
public bool IsLive { get; set; }
|
||||||
|
public Chapter[] Chapters { get; set; }
|
||||||
|
public Thumbnail[] Thumbnails { get; set; }
|
||||||
|
public Format[] Formats { get; set; }
|
||||||
|
public Format[] AdaptiveFormats { get; set; }
|
||||||
|
public string HlsManifestUrl { get; set; }
|
||||||
|
public Subtitle[] Subtitles { get; set; }
|
||||||
|
public string[] Storyboards { get; set; }
|
||||||
|
public string ExpiresInSeconds { get; set; }
|
||||||
|
public string ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
public string GetHtmlDescription()
|
||||||
|
{
|
||||||
|
return Utils.GetHtmlDescription(Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public XmlDocument GetXmlDocument()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||||
|
{
|
||||||
|
XmlElement error = doc.CreateElement("Error");
|
||||||
|
error.InnerText = ErrorMessage;
|
||||||
|
doc.AppendChild(error);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
XmlElement player = doc.CreateElement("Player");
|
||||||
|
player.SetAttribute("id", Id);
|
||||||
|
player.SetAttribute("duration", Duration.ToString());
|
||||||
|
player.SetAttribute("isLive", IsLive.ToString());
|
||||||
|
player.SetAttribute("expiresInSeconds", ExpiresInSeconds);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
player.AppendChild(title);
|
||||||
|
|
||||||
|
XmlElement description = doc.CreateElement("Description");
|
||||||
|
description.InnerText = Description;
|
||||||
|
player.AppendChild(description);
|
||||||
|
|
||||||
|
XmlElement tags = doc.CreateElement("Tags");
|
||||||
|
foreach (string tag in Tags ?? Array.Empty<string>())
|
||||||
|
{
|
||||||
|
XmlElement tagElement = doc.CreateElement("Tag");
|
||||||
|
tagElement.InnerText = tag;
|
||||||
|
tags.AppendChild(tagElement);
|
||||||
|
}
|
||||||
|
player.AppendChild(tags);
|
||||||
|
|
||||||
|
player.AppendChild(Channel.GetXmlElement(doc));
|
||||||
|
|
||||||
|
XmlElement thumbnails = doc.CreateElement("Thumbnails");
|
||||||
|
foreach (Thumbnail t in Thumbnails)
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
thumbnails.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
player.AppendChild(thumbnails);
|
||||||
|
|
||||||
|
XmlElement formats = doc.CreateElement("Formats");
|
||||||
|
foreach (Format f in Formats ?? Array.Empty<Format>()) formats.AppendChild(f.GetXmlElement(doc));
|
||||||
|
player.AppendChild(formats);
|
||||||
|
|
||||||
|
XmlElement adaptiveFormats = doc.CreateElement("AdaptiveFormats");
|
||||||
|
foreach (Format f in AdaptiveFormats ?? Array.Empty<Format>()) adaptiveFormats.AppendChild(f.GetXmlElement(doc));
|
||||||
|
player.AppendChild(adaptiveFormats);
|
||||||
|
|
||||||
|
XmlElement storyboards = doc.CreateElement("Storyboards");
|
||||||
|
foreach (string s in Storyboards)
|
||||||
|
{
|
||||||
|
XmlElement storyboard = doc.CreateElement("Storyboard");
|
||||||
|
storyboard.InnerText = s;
|
||||||
|
storyboards.AppendChild(storyboard);
|
||||||
|
}
|
||||||
|
player.AppendChild(storyboards);
|
||||||
|
|
||||||
|
XmlElement subtitles = doc.CreateElement("Subtitles");
|
||||||
|
foreach (Subtitle s in Subtitles ?? Array.Empty<Subtitle>()) subtitles.AppendChild(s.GetXmlElement(doc));
|
||||||
|
player.AppendChild(subtitles);
|
||||||
|
|
||||||
|
doc.AppendChild(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Chapter
|
||||||
|
{
|
||||||
|
[JsonProperty("title")] public string Title { get; set; }
|
||||||
|
[JsonProperty("start_time")] public long StartTime { get; set; }
|
||||||
|
[JsonProperty("end_time")] public long EndTime { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Format
|
||||||
|
{
|
||||||
|
[JsonProperty("format")] public string FormatName { get; set; }
|
||||||
|
[JsonProperty("format_id")] public string FormatId { get; set; }
|
||||||
|
[JsonProperty("format_note")] public string FormatNote { get; set; }
|
||||||
|
[JsonProperty("filesize")] public long? Filesize { get; set; }
|
||||||
|
[JsonProperty("quality")] public long Quality { get; set; }
|
||||||
|
[JsonProperty("bitrate")] public double Bitrate { get; set; }
|
||||||
|
[JsonProperty("audio_codec")] public string AudioCodec { get; set; }
|
||||||
|
[JsonProperty("video_codec")] public string VideoCodec { get; set; }
|
||||||
|
[JsonProperty("audio_sample_rate")] public long? AudioSampleRate { get; set; }
|
||||||
|
[JsonProperty("resolution")] public string Resolution { get; set; }
|
||||||
|
[JsonProperty("url")] public string Url { get; set; }
|
||||||
|
[JsonProperty("init_range")] public Range InitRange { get; set; }
|
||||||
|
[JsonProperty("index_range")] public Range IndexRange { get; set; }
|
||||||
|
|
||||||
|
public XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement format = doc.CreateElement("Format");
|
||||||
|
|
||||||
|
format.SetAttribute("id", FormatId);
|
||||||
|
format.SetAttribute("label", FormatName);
|
||||||
|
format.SetAttribute("filesize", Filesize.ToString());
|
||||||
|
format.SetAttribute("quality", Bitrate.ToString());
|
||||||
|
format.SetAttribute("audioCodec", AudioCodec);
|
||||||
|
format.SetAttribute("videoCodec", VideoCodec);
|
||||||
|
if (AudioSampleRate != null)
|
||||||
|
format.SetAttribute("audioSampleRate", AudioSampleRate.ToString());
|
||||||
|
else
|
||||||
|
format.SetAttribute("resolution", Resolution);
|
||||||
|
|
||||||
|
XmlElement url = doc.CreateElement("URL");
|
||||||
|
url.InnerText = Url;
|
||||||
|
format.AppendChild(url);
|
||||||
|
|
||||||
|
if (InitRange != null && IndexRange != null)
|
||||||
|
{
|
||||||
|
XmlElement initRange = doc.CreateElement("InitRange");
|
||||||
|
initRange.SetAttribute("start", InitRange.Start);
|
||||||
|
initRange.SetAttribute("end", InitRange.End);
|
||||||
|
format.AppendChild(initRange);
|
||||||
|
|
||||||
|
XmlElement indexRange = doc.CreateElement("IndexRange");
|
||||||
|
indexRange.SetAttribute("start", IndexRange.Start);
|
||||||
|
indexRange.SetAttribute("end", IndexRange.End);
|
||||||
|
format.AppendChild(indexRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
return format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Range
|
||||||
|
{
|
||||||
|
[JsonProperty("start")] public string Start { get; set; }
|
||||||
|
[JsonProperty("end")] public string End { get; set; }
|
||||||
|
|
||||||
|
public Range(string start, string end)
|
||||||
|
{
|
||||||
|
Start = start;
|
||||||
|
End = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Channel
|
||||||
|
{
|
||||||
|
[JsonProperty("name")] public string Name { get; set; }
|
||||||
|
[JsonProperty("id")] public string Id { get; set; }
|
||||||
|
[JsonProperty("subscriberCount")] public string SubscriberCount { get; set; }
|
||||||
|
[JsonProperty("avatars")] public Thumbnail[] Avatars { get; set; }
|
||||||
|
|
||||||
|
public XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement channel = doc.CreateElement("Channel");
|
||||||
|
channel.SetAttribute("id", Id);
|
||||||
|
if (!string.IsNullOrWhiteSpace(SubscriberCount))
|
||||||
|
channel.SetAttribute("subscriberCount", SubscriberCount);
|
||||||
|
|
||||||
|
XmlElement name = doc.CreateElement("Name");
|
||||||
|
name.InnerText = Name;
|
||||||
|
channel.AppendChild(name);
|
||||||
|
|
||||||
|
foreach (Thumbnail avatarThumb in Avatars ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement avatar = doc.CreateElement("Avatar");
|
||||||
|
avatar.SetAttribute("width", avatarThumb.Width.ToString());
|
||||||
|
avatar.SetAttribute("height", avatarThumb.Height.ToString());
|
||||||
|
avatar.InnerText = avatarThumb.Url;
|
||||||
|
channel.AppendChild(avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Subtitle
|
||||||
|
{
|
||||||
|
[JsonProperty("ext")] public string Ext { get; set; }
|
||||||
|
[JsonProperty("name")] public string Language { get; set; }
|
||||||
|
[JsonProperty("url")] public string Url { get; set; }
|
||||||
|
|
||||||
|
public XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement subtitle = doc.CreateElement("Subtitle");
|
||||||
|
subtitle.SetAttribute("ext", Ext);
|
||||||
|
subtitle.SetAttribute("language", Language);
|
||||||
|
subtitle.InnerText = Url;
|
||||||
|
return subtitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Thumbnail
|
||||||
|
{
|
||||||
|
[JsonProperty("height")] public long Height { get; set; }
|
||||||
|
[JsonProperty("url")] public string Url { get; set; }
|
||||||
|
[JsonProperty("width")] public long Width { get; set; }
|
||||||
|
}
|
||||||
|
}
|
68
core/InnerTube/Models/YoutubePlaylist.cs
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
using System.Xml;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class YoutubePlaylist
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public string Title;
|
||||||
|
public string Description;
|
||||||
|
public string VideoCount;
|
||||||
|
public string ViewCount;
|
||||||
|
public string LastUpdated;
|
||||||
|
public Thumbnail[] Thumbnail;
|
||||||
|
public Channel Channel;
|
||||||
|
public DynamicItem[] Videos;
|
||||||
|
public string ContinuationKey;
|
||||||
|
|
||||||
|
public string GetHtmlDescription() => Utils.GetHtmlDescription(Description);
|
||||||
|
|
||||||
|
public XmlDocument GetXmlDocument()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement playlist = doc.CreateElement("Playlist");
|
||||||
|
playlist.SetAttribute("id", Id);
|
||||||
|
playlist.SetAttribute("continuation", ContinuationKey);
|
||||||
|
|
||||||
|
XmlElement metadata = doc.CreateElement("Metadata");
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
metadata.AppendChild(title);
|
||||||
|
|
||||||
|
metadata.AppendChild(Channel.GetXmlElement(doc));
|
||||||
|
|
||||||
|
XmlElement thumbnails = doc.CreateElement("Thumbnails");
|
||||||
|
foreach (Thumbnail t in Thumbnail)
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
thumbnails.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
metadata.AppendChild(thumbnails);
|
||||||
|
|
||||||
|
XmlElement videoCount = doc.CreateElement("VideoCount");
|
||||||
|
XmlElement viewCount = doc.CreateElement("ViewCount");
|
||||||
|
XmlElement lastUpdated = doc.CreateElement("LastUpdated");
|
||||||
|
|
||||||
|
videoCount.InnerText = VideoCount;
|
||||||
|
viewCount.InnerText = ViewCount;
|
||||||
|
lastUpdated.InnerText = LastUpdated;
|
||||||
|
|
||||||
|
metadata.AppendChild(videoCount);
|
||||||
|
metadata.AppendChild(viewCount);
|
||||||
|
metadata.AppendChild(lastUpdated);
|
||||||
|
|
||||||
|
playlist.AppendChild(metadata);
|
||||||
|
|
||||||
|
XmlElement results = doc.CreateElement("Videos");
|
||||||
|
foreach (DynamicItem result in Videos) results.AppendChild(result.GetXmlElement(doc));
|
||||||
|
playlist.AppendChild(results);
|
||||||
|
|
||||||
|
doc.AppendChild(playlist);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
39
core/InnerTube/Models/YoutubeSearchResults.cs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
using System.Xml;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class YoutubeSearchResults
|
||||||
|
{
|
||||||
|
public string[] Refinements;
|
||||||
|
public long EstimatedResults;
|
||||||
|
public DynamicItem[] Results;
|
||||||
|
public string ContinuationKey;
|
||||||
|
|
||||||
|
public XmlDocument GetXmlDocument()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement search = doc.CreateElement("Search");
|
||||||
|
search.SetAttribute("estimatedResults", EstimatedResults.ToString());
|
||||||
|
search.SetAttribute("continuation", ContinuationKey);
|
||||||
|
|
||||||
|
if (Refinements.Length > 0)
|
||||||
|
{
|
||||||
|
XmlElement refinements = doc.CreateElement("Refinements");
|
||||||
|
foreach (string refinementText in Refinements)
|
||||||
|
{
|
||||||
|
XmlElement refinement = doc.CreateElement("Refinement");
|
||||||
|
refinement.InnerText = refinementText;
|
||||||
|
refinements.AppendChild(refinement);
|
||||||
|
}
|
||||||
|
search.AppendChild(refinements);
|
||||||
|
}
|
||||||
|
|
||||||
|
XmlElement results = doc.CreateElement("Results");
|
||||||
|
foreach (DynamicItem result in Results) results.AppendChild(result.GetXmlElement(doc));
|
||||||
|
search.AppendChild(results);
|
||||||
|
|
||||||
|
doc.AppendChild(search);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
38
core/InnerTube/Models/YoutubeStoryboardSpec.cs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class YoutubeStoryboardSpec
|
||||||
|
{
|
||||||
|
public Dictionary<string, string> Urls = new();
|
||||||
|
public YoutubeStoryboardSpec(string specStr, long duration)
|
||||||
|
{
|
||||||
|
if (specStr is null) return;
|
||||||
|
List<string> spec = new(specStr.Split("|"));
|
||||||
|
string baseUrl = spec[0];
|
||||||
|
spec.RemoveAt(0);
|
||||||
|
spec.Reverse();
|
||||||
|
int L = spec.Count - 1;
|
||||||
|
for (int i = 0; i < spec.Count; i++)
|
||||||
|
{
|
||||||
|
string[] args = spec[i].Split("#");
|
||||||
|
int width = int.Parse(args[0]);
|
||||||
|
int height = int.Parse(args[1]);
|
||||||
|
int frameCount = int.Parse(args[2]);
|
||||||
|
int cols = int.Parse(args[3]);
|
||||||
|
int rows = int.Parse(args[4]);
|
||||||
|
string N = args[6];
|
||||||
|
string sigh = args[7];
|
||||||
|
string url = baseUrl
|
||||||
|
.Replace("$L", (spec.Count - 1 - i).ToString())
|
||||||
|
.Replace("$N", N) + "&sigh=" + sigh;
|
||||||
|
float fragmentCount = frameCount / (cols * rows);
|
||||||
|
float fragmentDuration = duration / fragmentCount;
|
||||||
|
|
||||||
|
for (int j = 0; j < Math.Ceiling(fragmentCount); j++)
|
||||||
|
Urls.TryAdd($"L{spec.Count - 1 - i}", url.Replace("$M", j.ToString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
70
core/InnerTube/Models/YoutubeTrends.cs
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
using System;
|
||||||
|
using System.Xml;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class YoutubeTrends
|
||||||
|
{
|
||||||
|
public TrendCategory[] Categories;
|
||||||
|
public DynamicItem[] Videos;
|
||||||
|
|
||||||
|
public XmlDocument GetXmlDocument()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement explore = doc.CreateElement("Explore");
|
||||||
|
|
||||||
|
XmlElement categories = doc.CreateElement("Categories");
|
||||||
|
foreach (TrendCategory category in Categories ?? Array.Empty<TrendCategory>()) categories.AppendChild(category.GetXmlElement(doc));
|
||||||
|
explore.AppendChild(categories);
|
||||||
|
|
||||||
|
XmlElement contents = doc.CreateElement("Videos");
|
||||||
|
foreach (DynamicItem item in Videos ?? Array.Empty<DynamicItem>()) contents.AppendChild(item.GetXmlElement(doc));
|
||||||
|
explore.AppendChild(contents);
|
||||||
|
|
||||||
|
doc.AppendChild(explore);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TrendCategory
|
||||||
|
{
|
||||||
|
public string Label;
|
||||||
|
public Thumbnail[] BackgroundImage;
|
||||||
|
public Thumbnail[] Icon;
|
||||||
|
public string Id;
|
||||||
|
|
||||||
|
public XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement category = doc.CreateElement("Category");
|
||||||
|
category.SetAttribute("id", Id);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Name");
|
||||||
|
title.InnerText = Label;
|
||||||
|
category.AppendChild(title);
|
||||||
|
|
||||||
|
XmlElement backgroundImages = doc.CreateElement("BackgroundImage");
|
||||||
|
foreach (Thumbnail t in BackgroundImage ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
backgroundImages.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
category.AppendChild(backgroundImages);
|
||||||
|
|
||||||
|
XmlElement icons = doc.CreateElement("Icon");
|
||||||
|
foreach (Thumbnail t in Icon ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
icons.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
category.AppendChild(icons);
|
||||||
|
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
core/InnerTube/Models/YoutubeVideo.cs
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
using System;
|
||||||
|
using System.Xml;
|
||||||
|
|
||||||
|
namespace InnerTube.Models
|
||||||
|
{
|
||||||
|
public class YoutubeVideo
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public string Title;
|
||||||
|
public string Description;
|
||||||
|
public Channel Channel;
|
||||||
|
public string UploadDate;
|
||||||
|
public DynamicItem[] Recommended;
|
||||||
|
public string Views;
|
||||||
|
|
||||||
|
public string GetHtmlDescription() => InnerTube.Utils.GetHtmlDescription(Description);
|
||||||
|
|
||||||
|
public XmlDocument GetXmlDocument()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement item = doc.CreateElement("Video");
|
||||||
|
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
item.SetAttribute("views", Views);
|
||||||
|
item.SetAttribute("uploadDate", UploadDate);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
|
||||||
|
XmlElement description = doc.CreateElement("Description");
|
||||||
|
description.InnerText = Description;
|
||||||
|
item.AppendChild(description);
|
||||||
|
|
||||||
|
item.AppendChild(Channel.GetXmlElement(doc));
|
||||||
|
|
||||||
|
XmlElement recommendations = doc.CreateElement("Recommendations");
|
||||||
|
foreach (DynamicItem f in Recommended ?? Array.Empty<DynamicItem>()) recommendations.AppendChild(f.GetXmlElement(doc));
|
||||||
|
item.AppendChild(recommendations);
|
||||||
|
|
||||||
|
doc.AppendChild(item);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
44
core/InnerTube/ReturnYouTubeDislike.cs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace InnerTube
|
||||||
|
{
|
||||||
|
public static class ReturnYouTubeDislike
|
||||||
|
{
|
||||||
|
private static HttpClient _client = new();
|
||||||
|
private static Dictionary<string, YoutubeDislikes> DislikesCache = new();
|
||||||
|
|
||||||
|
// TODO: better cache
|
||||||
|
public static async Task<YoutubeDislikes> GetDislikes(string videoId)
|
||||||
|
{
|
||||||
|
if (DislikesCache.ContainsKey(videoId))
|
||||||
|
return DislikesCache[videoId];
|
||||||
|
|
||||||
|
HttpResponseMessage response = await _client.GetAsync("https://returnyoutubedislikeapi.com/votes?videoId=" + videoId);
|
||||||
|
string json = await response.Content.ReadAsStringAsync();
|
||||||
|
YoutubeDislikes dislikes = JsonConvert.DeserializeObject<YoutubeDislikes>(json);
|
||||||
|
if (dislikes is not null)
|
||||||
|
DislikesCache.Add(videoId, dislikes);
|
||||||
|
return dislikes ?? new YoutubeDislikes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class YoutubeDislikes
|
||||||
|
{
|
||||||
|
[JsonProperty("id")] public string Id { get; set; }
|
||||||
|
[JsonProperty("dateCreated")] public string DateCreated { get; set; }
|
||||||
|
[JsonProperty("likes")] public long Likes { get; set; }
|
||||||
|
[JsonProperty("dislikes")] public long Dislikes { get; set; }
|
||||||
|
[JsonProperty("rating")] public double Rating { get; set; }
|
||||||
|
[JsonProperty("viewCount")] public long Views { get; set; }
|
||||||
|
[JsonProperty("deleted")] public bool Deleted { get; set; }
|
||||||
|
|
||||||
|
public float GetLikePercentage()
|
||||||
|
{
|
||||||
|
return Likes / (float)(Likes + Dislikes) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
457
core/InnerTube/Utils.cs
Normal file
|
@ -0,0 +1,457 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Web;
|
||||||
|
using System.Xml;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace InnerTube
|
||||||
|
{
|
||||||
|
public static class Utils
|
||||||
|
{
|
||||||
|
private static string Sapisid;
|
||||||
|
private static string Psid;
|
||||||
|
private static bool UseAuthorization;
|
||||||
|
|
||||||
|
public static string GetHtmlDescription(string description) => description?.Replace("\n", "<br>") ?? "";
|
||||||
|
|
||||||
|
public static string GetMpdManifest(this YoutubePlayer player, string proxyUrl, string videoCodec = null, string audioCodec = null)
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
|
||||||
|
XmlDeclaration xmlDeclaration = doc.CreateXmlDeclaration("1.0", "UTF-8", null);
|
||||||
|
XmlElement root = doc.DocumentElement;
|
||||||
|
doc.InsertBefore(xmlDeclaration, root);
|
||||||
|
|
||||||
|
XmlElement mpdRoot = doc.CreateElement(string.Empty, "MPD", string.Empty);
|
||||||
|
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-on-demand:2011");
|
||||||
|
mpdRoot.SetAttribute("profiles", "urn:mpeg:dash:profile:isoff-main:2011");
|
||||||
|
mpdRoot.SetAttribute("type", "static");
|
||||||
|
mpdRoot.SetAttribute("minBufferTime", "PT1.500S");
|
||||||
|
TimeSpan durationTs = TimeSpan.FromMilliseconds(double.Parse(HttpUtility
|
||||||
|
.ParseQueryString(player.Formats.First().Url.Split("?")[1])
|
||||||
|
.Get("dur")?.Replace(".", "") ?? "0"));
|
||||||
|
StringBuilder duration = new("PT");
|
||||||
|
if (durationTs.TotalHours > 0)
|
||||||
|
duration.Append($"{durationTs.Hours}H");
|
||||||
|
if (durationTs.Minutes > 0)
|
||||||
|
duration.Append($"{durationTs.Minutes}M");
|
||||||
|
if (durationTs.Seconds > 0)
|
||||||
|
duration.Append(durationTs.Seconds);
|
||||||
|
mpdRoot.SetAttribute("mediaPresentationDuration", $"{duration}.{durationTs.Milliseconds}S");
|
||||||
|
doc.AppendChild(mpdRoot);
|
||||||
|
|
||||||
|
XmlElement period = doc.CreateElement("Period");
|
||||||
|
|
||||||
|
period.AppendChild(doc.CreateComment("Audio Adaptation Set"));
|
||||||
|
XmlElement audioAdaptationSet = doc.CreateElement("AdaptationSet");
|
||||||
|
List<Format> audios;
|
||||||
|
if (audioCodec != "all")
|
||||||
|
audios = player.AdaptiveFormats
|
||||||
|
.Where(x => x.AudioSampleRate.HasValue && x.FormatId != "17" &&
|
||||||
|
(audioCodec == null || x.AudioCodec.ToLower().Contains(audioCodec.ToLower())))
|
||||||
|
.GroupBy(x => x.FormatNote)
|
||||||
|
.Select(x => x.Last())
|
||||||
|
.ToList();
|
||||||
|
else
|
||||||
|
audios = player.AdaptiveFormats
|
||||||
|
.Where(x => x.AudioSampleRate.HasValue && x.FormatId != "17")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
audioAdaptationSet.SetAttribute("mimeType",
|
||||||
|
HttpUtility.ParseQueryString(audios.First().Url.Split("?")[1]).Get("mime"));
|
||||||
|
audioAdaptationSet.SetAttribute("subsegmentAlignment", "true");
|
||||||
|
audioAdaptationSet.SetAttribute("contentType", "audio");
|
||||||
|
foreach (Format format in audios)
|
||||||
|
{
|
||||||
|
XmlElement representation = doc.CreateElement("Representation");
|
||||||
|
representation.SetAttribute("id", format.FormatId);
|
||||||
|
representation.SetAttribute("codecs", format.AudioCodec);
|
||||||
|
representation.SetAttribute("startWithSAP", "1");
|
||||||
|
representation.SetAttribute("bandwidth",
|
||||||
|
Math.Floor((format.Filesize ?? 1) / (double)player.Duration).ToString());
|
||||||
|
|
||||||
|
XmlElement audioChannelConfiguration = doc.CreateElement("AudioChannelConfiguration");
|
||||||
|
audioChannelConfiguration.SetAttribute("schemeIdUri",
|
||||||
|
"urn:mpeg:dash:23003:3:audio_channel_configuration:2011");
|
||||||
|
audioChannelConfiguration.SetAttribute("value", "2");
|
||||||
|
representation.AppendChild(audioChannelConfiguration);
|
||||||
|
|
||||||
|
XmlElement baseUrl = doc.CreateElement("BaseURL");
|
||||||
|
baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) ? format.Url : $"{proxyUrl}media/{player.Id}/{format.FormatId}";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
audioAdaptationSet.AppendChild(representation);
|
||||||
|
}
|
||||||
|
|
||||||
|
period.AppendChild(audioAdaptationSet);
|
||||||
|
|
||||||
|
period.AppendChild(doc.CreateComment("Video Adaptation Set"));
|
||||||
|
|
||||||
|
List<Format> videos;
|
||||||
|
if (videoCodec != "all")
|
||||||
|
videos = player.AdaptiveFormats.Where(x => !x.AudioSampleRate.HasValue && x.FormatId != "17" &&
|
||||||
|
(videoCodec == null || x.VideoCodec.ToLower()
|
||||||
|
.Contains(videoCodec.ToLower())))
|
||||||
|
.GroupBy(x => x.FormatNote)
|
||||||
|
.Select(x => x.Last())
|
||||||
|
.ToList();
|
||||||
|
else
|
||||||
|
videos = player.AdaptiveFormats.Where(x => x.Resolution != "audio only" && x.FormatId != "17").ToList();
|
||||||
|
|
||||||
|
|
||||||
|
XmlElement videoAdaptationSet = doc.CreateElement("AdaptationSet");
|
||||||
|
videoAdaptationSet.SetAttribute("mimeType",
|
||||||
|
HttpUtility.ParseQueryString(videos.FirstOrDefault()?.Url?.Split("?")[1] ?? "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.FormatId);
|
||||||
|
representation.SetAttribute("codecs", format.VideoCodec);
|
||||||
|
representation.SetAttribute("startWithSAP", "1");
|
||||||
|
string[] widthAndHeight = format.Resolution.Split("x");
|
||||||
|
representation.SetAttribute("width", widthAndHeight[0]);
|
||||||
|
representation.SetAttribute("height", widthAndHeight[1]);
|
||||||
|
representation.SetAttribute("bandwidth",
|
||||||
|
Math.Floor((format.Filesize ?? 1) / (double)player.Duration).ToString());
|
||||||
|
|
||||||
|
XmlElement baseUrl = doc.CreateElement("BaseURL");
|
||||||
|
baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) ? format.Url : $"{proxyUrl}media/{player.Id}/{format.FormatId}";
|
||||||
|
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);
|
||||||
|
|
||||||
|
period.AppendChild(doc.CreateComment("Subtitle Adaptation Sets"));
|
||||||
|
foreach (Subtitle subtitle in player.Subtitles ?? Array.Empty<Subtitle>())
|
||||||
|
{
|
||||||
|
period.AppendChild(doc.CreateComment(subtitle.Language));
|
||||||
|
XmlElement adaptationSet = doc.CreateElement("AdaptationSet");
|
||||||
|
adaptationSet.SetAttribute("mimeType", "text/vtt");
|
||||||
|
adaptationSet.SetAttribute("lang", subtitle.Language);
|
||||||
|
|
||||||
|
XmlElement representation = doc.CreateElement("Representation");
|
||||||
|
representation.SetAttribute("id", $"caption_{subtitle.Language.ToLower()}");
|
||||||
|
representation.SetAttribute("bandwidth", "256"); // ...why do we need this for a plaintext file
|
||||||
|
|
||||||
|
XmlElement baseUrl = doc.CreateElement("BaseURL");
|
||||||
|
string url = subtitle.Url;
|
||||||
|
url = url.Replace("fmt=srv3", "fmt=vtt");
|
||||||
|
baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) ? url : $"{proxyUrl}caption/{player.Id}/{subtitle.Language}";
|
||||||
|
|
||||||
|
representation.AppendChild(baseUrl);
|
||||||
|
adaptationSet.AppendChild(representation);
|
||||||
|
period.AppendChild(adaptationSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
mpdRoot.AppendChild(period);
|
||||||
|
return doc.OuterXml.Replace(" schemaLocation=\"", " xsi:schemaLocation=\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> GetHlsManifest(this YoutubePlayer player, string proxyUrl)
|
||||||
|
{
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.AppendLine("#EXTM3U");
|
||||||
|
sb.AppendLine("##Generated by LightTube");
|
||||||
|
sb.AppendLine("##Video ID: " + player.Id);
|
||||||
|
|
||||||
|
sb.AppendLine("#EXT-X-VERSION:7");
|
||||||
|
sb.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
|
||||||
|
|
||||||
|
string hls = await new HttpClient().GetStringAsync(player.HlsManifestUrl);
|
||||||
|
string[] hlsLines = hls.Split("\n");
|
||||||
|
foreach (string line in hlsLines)
|
||||||
|
{
|
||||||
|
if (line.StartsWith("#EXT-X-STREAM-INF:"))
|
||||||
|
sb.AppendLine(line);
|
||||||
|
if (line.StartsWith("http"))
|
||||||
|
{
|
||||||
|
Uri u = new(line);
|
||||||
|
sb.AppendLine($"{proxyUrl}/ytmanifest?path={HttpUtility.UrlEncode(u.PathAndQuery)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ReadRuns(JArray runs)
|
||||||
|
{
|
||||||
|
string str = "";
|
||||||
|
foreach (JToken runToken in runs ?? new JArray())
|
||||||
|
{
|
||||||
|
JObject run = runToken as JObject;
|
||||||
|
if (run is null) continue;
|
||||||
|
|
||||||
|
if (run.ContainsKey("bold"))
|
||||||
|
{
|
||||||
|
str += "<b>" + run["text"] + "</b>";
|
||||||
|
}
|
||||||
|
else if (run.ContainsKey("navigationEndpoint"))
|
||||||
|
{
|
||||||
|
if (run?["navigationEndpoint"]?["urlEndpoint"] is not null)
|
||||||
|
{
|
||||||
|
string url = run["navigationEndpoint"]?["urlEndpoint"]?["url"]?.ToString() ?? "";
|
||||||
|
if (url.StartsWith("https://www.youtube.com/redirect"))
|
||||||
|
{
|
||||||
|
NameValueCollection qsl = HttpUtility.ParseQueryString(url.Split("?")[1]);
|
||||||
|
url = qsl["url"] ?? qsl["q"];
|
||||||
|
}
|
||||||
|
|
||||||
|
str += $"<a href=\"{url}\">{run["text"]}</a>";
|
||||||
|
}
|
||||||
|
else if (run?["navigationEndpoint"]?["commandMetadata"] is not null)
|
||||||
|
{
|
||||||
|
string url = run["navigationEndpoint"]?["commandMetadata"]?["webCommandMetadata"]?["url"]
|
||||||
|
?.ToString() ?? "";
|
||||||
|
if (url.StartsWith("/"))
|
||||||
|
url = "https://youtube.com" + url;
|
||||||
|
str += $"<a href=\"{url}\">{run["text"]}</a>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
str += run["text"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Thumbnail ParseThumbnails(JToken arg) => new()
|
||||||
|
{
|
||||||
|
Height = arg["height"]?.ToObject<long>() ?? -1,
|
||||||
|
Url = arg["url"]?.ToString() ?? string.Empty,
|
||||||
|
Width = arg["width"]?.ToObject<long>() ?? -1
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task<JObject> GetAuthorizedPlayer(string id, HttpClient client)
|
||||||
|
{
|
||||||
|
HttpRequestMessage hrm = new(HttpMethod.Post,
|
||||||
|
"https://www.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8");
|
||||||
|
|
||||||
|
byte[] buffer = Encoding.UTF8.GetBytes(
|
||||||
|
RequestContext.BuildRequestContextJson(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["videoId"] = id
|
||||||
|
}));
|
||||||
|
ByteArrayContent byteContent = new(buffer);
|
||||||
|
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||||
|
hrm.Content = byteContent;
|
||||||
|
|
||||||
|
if (UseAuthorization)
|
||||||
|
{
|
||||||
|
hrm.Headers.Add("Cookie", GenerateAuthCookie());
|
||||||
|
hrm.Headers.Add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:96.0) Gecko/20100101 Firefox/96.0");
|
||||||
|
hrm.Headers.Add("Authorization", GenerateAuthHeader());
|
||||||
|
hrm.Headers.Add("X-Origin", "https://www.youtube.com");
|
||||||
|
hrm.Headers.Add("X-Youtube-Client-Name", "1");
|
||||||
|
hrm.Headers.Add("X-Youtube-Client-Version", "2.20210721.00.00");
|
||||||
|
hrm.Headers.Add("Accept-Language", "en-US;q=0.8,en;q=0.7");
|
||||||
|
hrm.Headers.Add("Origin", "https://www.youtube.com");
|
||||||
|
hrm.Headers.Add("Referer", "https://www.youtube.com/watch?v=" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponseMessage ytPlayerRequest = await client.SendAsync(hrm);
|
||||||
|
return JObject.Parse(await ytPlayerRequest.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GenerateAuthHeader()
|
||||||
|
{
|
||||||
|
if (!UseAuthorization) return "None none";
|
||||||
|
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
string hashInput = timestamp + " " + Sapisid + " https://www.youtube.com";
|
||||||
|
string hashDigest = GenerateSha1Hash(hashInput);
|
||||||
|
return $"SAPISIDHASH {timestamp}_{hashDigest}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GenerateAuthCookie() => UseAuthorization ? $"SAPISID={Sapisid}; __Secure-3PAPISID={Sapisid}; __Secure-3PSID={Psid};" : ";";
|
||||||
|
|
||||||
|
private static string GenerateSha1Hash(string input)
|
||||||
|
{
|
||||||
|
using SHA1Managed sha1 = new();
|
||||||
|
byte[] hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(input));
|
||||||
|
StringBuilder sb = new(hash.Length * 2);
|
||||||
|
foreach (byte b in hash) sb.Append(b.ToString("X2"));
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetExtension(this Format format)
|
||||||
|
{
|
||||||
|
if (format.VideoCodec != "none") return "mp4";
|
||||||
|
else
|
||||||
|
switch (format.FormatId)
|
||||||
|
{
|
||||||
|
case "139":
|
||||||
|
case "140":
|
||||||
|
case "141":
|
||||||
|
case "256":
|
||||||
|
case "258":
|
||||||
|
case "327":
|
||||||
|
return "mp3";
|
||||||
|
case "249":
|
||||||
|
case "250":
|
||||||
|
case "251":
|
||||||
|
case "338":
|
||||||
|
return "opus";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetAuthorization(bool canUseAuthorizedEndpoints, string sapisid, string psid)
|
||||||
|
{
|
||||||
|
UseAuthorization = canUseAuthorizedEndpoints;
|
||||||
|
Sapisid = sapisid;
|
||||||
|
Psid = psid;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GetCodec(string mimetypeString, bool audioCodec)
|
||||||
|
{
|
||||||
|
string acodec = "";
|
||||||
|
string vcodec = "";
|
||||||
|
|
||||||
|
Match match = Regex.Match(mimetypeString, "codecs=\"([\\s\\S]+?)\"");
|
||||||
|
|
||||||
|
string[] g = match.Groups[1].ToString().Split(",");
|
||||||
|
foreach (string codec in g)
|
||||||
|
{
|
||||||
|
switch (codec.Split(".")[0].Trim())
|
||||||
|
{
|
||||||
|
case "avc1":
|
||||||
|
case "av01":
|
||||||
|
case "vp9":
|
||||||
|
case "mp4v":
|
||||||
|
vcodec = codec;
|
||||||
|
break;
|
||||||
|
case "mp4a":
|
||||||
|
case "opus":
|
||||||
|
acodec = codec;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("Unknown codec type: " + codec.Split(".")[0].Trim());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (audioCodec ? acodec : vcodec).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetFormatName(JToken formatToken)
|
||||||
|
{
|
||||||
|
string format = formatToken["itag"]?.ToString() switch
|
||||||
|
{
|
||||||
|
"160" => "144p",
|
||||||
|
"278" => "144p",
|
||||||
|
"330" => "144p",
|
||||||
|
"394" => "144p",
|
||||||
|
"694" => "144p",
|
||||||
|
|
||||||
|
"133" => "240p",
|
||||||
|
"242" => "240p",
|
||||||
|
"331" => "240p",
|
||||||
|
"395" => "240p",
|
||||||
|
"695" => "240p",
|
||||||
|
|
||||||
|
"134" => "360p",
|
||||||
|
"243" => "360p",
|
||||||
|
"332" => "360p",
|
||||||
|
"396" => "360p",
|
||||||
|
"696" => "360p",
|
||||||
|
|
||||||
|
"135" => "480p",
|
||||||
|
"244" => "480p",
|
||||||
|
"333" => "480p",
|
||||||
|
"397" => "480p",
|
||||||
|
"697" => "480p",
|
||||||
|
|
||||||
|
"136" => "720p",
|
||||||
|
"247" => "720p",
|
||||||
|
"298" => "720p",
|
||||||
|
"302" => "720p",
|
||||||
|
"334" => "720p",
|
||||||
|
"398" => "720p",
|
||||||
|
"698" => "720p",
|
||||||
|
|
||||||
|
"137" => "1080p",
|
||||||
|
"299" => "1080p",
|
||||||
|
"248" => "1080p",
|
||||||
|
"303" => "1080p",
|
||||||
|
"335" => "1080p",
|
||||||
|
"399" => "1080p",
|
||||||
|
"699" => "1080p",
|
||||||
|
|
||||||
|
"264" => "1440p",
|
||||||
|
"271" => "1440p",
|
||||||
|
"304" => "1440p",
|
||||||
|
"308" => "1440p",
|
||||||
|
"336" => "1440p",
|
||||||
|
"400" => "1440p",
|
||||||
|
"700" => "1440p",
|
||||||
|
|
||||||
|
"266" => "2160p",
|
||||||
|
"305" => "2160p",
|
||||||
|
"313" => "2160p",
|
||||||
|
"315" => "2160p",
|
||||||
|
"337" => "2160p",
|
||||||
|
"401" => "2160p",
|
||||||
|
"701" => "2160p",
|
||||||
|
|
||||||
|
"138" => "4320p",
|
||||||
|
"272" => "4320p",
|
||||||
|
"402" => "4320p",
|
||||||
|
"571" => "4320p",
|
||||||
|
|
||||||
|
var _ => $"{formatToken["height"]}p",
|
||||||
|
};
|
||||||
|
|
||||||
|
return format == "p"
|
||||||
|
? formatToken["audioQuality"]?.ToString().ToLowerInvariant()
|
||||||
|
: (formatToken["fps"]?.ToObject<int>() ?? 0) > 30
|
||||||
|
? $"{format}{formatToken["fps"]}"
|
||||||
|
: format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
790
core/InnerTube/Youtube.cs
Normal file
|
@ -0,0 +1,790 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Web;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace InnerTube
|
||||||
|
{
|
||||||
|
public class Youtube
|
||||||
|
{
|
||||||
|
internal readonly HttpClient Client = new();
|
||||||
|
|
||||||
|
public readonly Dictionary<string, CacheItem<YoutubePlayer>> PlayerCache = new();
|
||||||
|
|
||||||
|
private readonly Dictionary<ChannelTabs, string> ChannelTabParams = new()
|
||||||
|
{
|
||||||
|
[ChannelTabs.Home] = @"EghmZWF0dXJlZA%3D%3D",
|
||||||
|
[ChannelTabs.Videos] = @"EgZ2aWRlb3M%3D",
|
||||||
|
[ChannelTabs.Playlists] = @"EglwbGF5bGlzdHM%3D",
|
||||||
|
[ChannelTabs.Community] = @"Egljb21tdW5pdHk%3D",
|
||||||
|
[ChannelTabs.Channels] = @"EghjaGFubmVscw%3D%3D",
|
||||||
|
[ChannelTabs.About] = @"EgVhYm91dA%3D%3D"
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<JObject> MakeRequest(string endpoint, Dictionary<string, object> postData, string language,
|
||||||
|
string region, string clientName = "WEB", string clientId = "1", string clientVersion = "2.20220405", bool authorized = false)
|
||||||
|
{
|
||||||
|
HttpRequestMessage hrm = new(HttpMethod.Post,
|
||||||
|
@$"https://www.youtube.com/youtubei/v1/{endpoint}?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8");
|
||||||
|
|
||||||
|
byte[] buffer = Encoding.UTF8.GetBytes(RequestContext.BuildRequestContextJson(postData, language, region, clientName, clientVersion));
|
||||||
|
ByteArrayContent byteContent = new(buffer);
|
||||||
|
if (authorized)
|
||||||
|
{
|
||||||
|
hrm.Headers.Add("Cookie", Utils.GenerateAuthCookie());
|
||||||
|
hrm.Headers.Add("Authorization", Utils.GenerateAuthHeader());
|
||||||
|
hrm.Headers.Add("X-Youtube-Client-Name", clientId);
|
||||||
|
hrm.Headers.Add("X-Youtube-Client-Version", clientVersion);
|
||||||
|
hrm.Headers.Add("Origin", "https://www.youtube.com");
|
||||||
|
}
|
||||||
|
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||||
|
hrm.Content = byteContent;
|
||||||
|
HttpResponseMessage ytPlayerRequest = await Client.SendAsync(hrm);
|
||||||
|
|
||||||
|
return JObject.Parse(await ytPlayerRequest.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<YoutubePlayer> GetPlayerAsync(string videoId, string language = "en", string region = "US", bool iOS = false)
|
||||||
|
{
|
||||||
|
if (PlayerCache.Any(x => x.Key == videoId && x.Value.ExpireTime > DateTimeOffset.Now))
|
||||||
|
{
|
||||||
|
CacheItem<YoutubePlayer> item = PlayerCache[videoId];
|
||||||
|
item.Item.ExpiresInSeconds = ((int)(item.ExpireTime - DateTimeOffset.Now).TotalSeconds).ToString();
|
||||||
|
return item.Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject player = await MakeRequest("player", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["videoId"] = videoId,
|
||||||
|
["contentCheckOk"] = true,
|
||||||
|
["racyCheckOk"] = true
|
||||||
|
}, language, region, iOS ? "IOS" : "ANDROID", iOS ? "5" : "3", "17.13.3", true);
|
||||||
|
|
||||||
|
switch (player["playabilityStatus"]?["status"]?.ToString())
|
||||||
|
{
|
||||||
|
case "OK":
|
||||||
|
YoutubeStoryboardSpec storyboardSpec =
|
||||||
|
new(player["storyboards"]?["playerStoryboardSpecRenderer"]?["spec"]?.ToString(), player["videoDetails"]?["lengthSeconds"]?.ToObject<long>() ?? 0);
|
||||||
|
YoutubePlayer video = new()
|
||||||
|
{
|
||||||
|
Id = player["videoDetails"]?["videoId"]?.ToString(),
|
||||||
|
Title = player["videoDetails"]?["title"]?.ToString(),
|
||||||
|
Description = player["videoDetails"]?["shortDescription"]?.ToString(),
|
||||||
|
Tags = player["videoDetails"]?["keywords"]?.ToObject<string[]>(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = player["videoDetails"]?["author"]?.ToString(),
|
||||||
|
Id = player["videoDetails"]?["channelId"]?.ToString(),
|
||||||
|
Avatars = Array.Empty<Thumbnail>()
|
||||||
|
},
|
||||||
|
Duration = player["videoDetails"]?["lengthSeconds"]?.ToObject<long>(),
|
||||||
|
IsLive = player["videoDetails"]?["isLiveContent"]?.ToObject<bool>() ?? false,
|
||||||
|
Chapters = Array.Empty<Chapter>(),
|
||||||
|
Thumbnails = player["videoDetails"]?["thumbnail"]?["thumbnails"]?.Select(x => new Thumbnail
|
||||||
|
{
|
||||||
|
Height = x["height"]?.ToObject<int>() ?? -1,
|
||||||
|
Url = x["url"]?.ToString(),
|
||||||
|
Width = x["width"]?.ToObject<int>() ?? -1
|
||||||
|
}).ToArray(),
|
||||||
|
Formats = player["streamingData"]?["formats"]?.Select(x => new Format
|
||||||
|
{
|
||||||
|
FormatName = Utils.GetFormatName(x),
|
||||||
|
FormatId = x["itag"]?.ToString(),
|
||||||
|
FormatNote = x["quality"]?.ToString(),
|
||||||
|
Filesize = x["contentLength"]?.ToObject<long>(),
|
||||||
|
Bitrate = x["bitrate"]?.ToObject<long>() ?? 0,
|
||||||
|
AudioCodec = Utils.GetCodec(x["mimeType"]?.ToString(), true),
|
||||||
|
VideoCodec = Utils.GetCodec(x["mimeType"]?.ToString(), false),
|
||||||
|
AudioSampleRate = x["audioSampleRate"]?.ToObject<long>(),
|
||||||
|
Resolution = $"{x["width"] ?? "0"}x{x["height"] ?? "0"}",
|
||||||
|
Url = x["url"]?.ToString()
|
||||||
|
}).ToArray() ?? Array.Empty<Format>(),
|
||||||
|
AdaptiveFormats = player["streamingData"]?["adaptiveFormats"]?.Select(x => new Format
|
||||||
|
{
|
||||||
|
FormatName = Utils.GetFormatName(x),
|
||||||
|
FormatId = x["itag"]?.ToString(),
|
||||||
|
FormatNote = x["quality"]?.ToString(),
|
||||||
|
Filesize = x["contentLength"]?.ToObject<long>(),
|
||||||
|
Bitrate = x["bitrate"]?.ToObject<long>() ?? 0,
|
||||||
|
AudioCodec = Utils.GetCodec(x["mimeType"].ToString(), true),
|
||||||
|
VideoCodec = Utils.GetCodec(x["mimeType"].ToString(), false),
|
||||||
|
AudioSampleRate = x["audioSampleRate"]?.ToObject<long>(),
|
||||||
|
Resolution = $"{x["width"] ?? "0"}x{x["height"] ?? "0"}",
|
||||||
|
Url = x["url"]?.ToString(),
|
||||||
|
InitRange = x["initRange"]?.ToObject<Models.Range>(),
|
||||||
|
IndexRange = x["indexRange"]?.ToObject<Models.Range>()
|
||||||
|
}).ToArray() ?? Array.Empty<Format>(),
|
||||||
|
HlsManifestUrl = player["streamingData"]?["hlsManifestUrl"]?.ToString(),
|
||||||
|
Subtitles = player["captions"]?["playerCaptionsTracklistRenderer"]?["captionTracks"]?.Select(
|
||||||
|
x => new Subtitle
|
||||||
|
{
|
||||||
|
Ext = HttpUtility.ParseQueryString(x["baseUrl"].ToString()).Get("fmt"),
|
||||||
|
Language = Utils.ReadRuns(x["name"]?["runs"]?.ToObject<JArray>()),
|
||||||
|
Url = x["baseUrl"].ToString()
|
||||||
|
}).ToArray(),
|
||||||
|
Storyboards = storyboardSpec.Urls.TryGetValue("L0", out string sb) ? new[] { sb } : Array.Empty<string>(),
|
||||||
|
ExpiresInSeconds = player["streamingData"]?["expiresInSeconds"]?.ToString(),
|
||||||
|
ErrorMessage = null
|
||||||
|
};
|
||||||
|
PlayerCache.Remove(videoId);
|
||||||
|
PlayerCache.Add(videoId,
|
||||||
|
new CacheItem<YoutubePlayer>(video,
|
||||||
|
TimeSpan.FromSeconds(int.Parse(video.ExpiresInSeconds ?? "21600"))
|
||||||
|
.Subtract(TimeSpan.FromHours(1))));
|
||||||
|
return video;
|
||||||
|
case "LOGIN_REQUIRED":
|
||||||
|
return new YoutubePlayer
|
||||||
|
{
|
||||||
|
Id = "",
|
||||||
|
Title = "",
|
||||||
|
Description = "",
|
||||||
|
Tags = Array.Empty<string>(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = "",
|
||||||
|
Id = "",
|
||||||
|
SubscriberCount = "",
|
||||||
|
Avatars = Array.Empty<Thumbnail>()
|
||||||
|
},
|
||||||
|
Duration = 0,
|
||||||
|
IsLive = false,
|
||||||
|
Chapters = Array.Empty<Chapter>(),
|
||||||
|
Thumbnails = Array.Empty<Thumbnail>(),
|
||||||
|
Formats = Array.Empty<Format>(),
|
||||||
|
AdaptiveFormats = Array.Empty<Format>(),
|
||||||
|
Subtitles = Array.Empty<Subtitle>(),
|
||||||
|
Storyboards = Array.Empty<string>(),
|
||||||
|
ExpiresInSeconds = "0",
|
||||||
|
ErrorMessage =
|
||||||
|
"This video is age-restricted. Please contact this instances authors to update their configuration"
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return new YoutubePlayer
|
||||||
|
{
|
||||||
|
Id = "",
|
||||||
|
Title = "",
|
||||||
|
Description = "",
|
||||||
|
Tags = Array.Empty<string>(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = "",
|
||||||
|
Id = "",
|
||||||
|
SubscriberCount = "",
|
||||||
|
Avatars = Array.Empty<Thumbnail>()
|
||||||
|
},
|
||||||
|
Duration = 0,
|
||||||
|
IsLive = false,
|
||||||
|
Chapters = Array.Empty<Chapter>(),
|
||||||
|
Thumbnails = Array.Empty<Thumbnail>(),
|
||||||
|
Formats = Array.Empty<Format>(),
|
||||||
|
AdaptiveFormats = Array.Empty<Format>(),
|
||||||
|
Subtitles = Array.Empty<Subtitle>(),
|
||||||
|
Storyboards = Array.Empty<string>(),
|
||||||
|
ExpiresInSeconds = "0",
|
||||||
|
ErrorMessage = player["playabilityStatus"]?["reason"]?.ToString() ?? "Something has gone *really* wrong"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<YoutubeVideo> GetVideoAsync(string videoId, string language = "en", string region = "US")
|
||||||
|
{
|
||||||
|
JObject player = await MakeRequest("next", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["videoId"] = videoId
|
||||||
|
}, language, region);
|
||||||
|
|
||||||
|
JToken[] contents =
|
||||||
|
(player?["contents"]?["twoColumnWatchNextResults"]?["results"]?["results"]?["contents"]
|
||||||
|
?.ToObject<JArray>() ?? new JArray())
|
||||||
|
.SkipWhile(x => !x.First.Path.EndsWith("videoPrimaryInfoRenderer")).ToArray();
|
||||||
|
|
||||||
|
YoutubeVideo video = new();
|
||||||
|
video.Id = player["currentVideoEndpoint"]?["watchEndpoint"]?["videoId"]?.ToString();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
|
||||||
|
video.Title = Utils.ReadRuns(
|
||||||
|
contents[0]
|
||||||
|
["videoPrimaryInfoRenderer"]?["title"]?["runs"]?.ToObject<JArray>());
|
||||||
|
video.Description = Utils.ReadRuns(
|
||||||
|
contents[1]
|
||||||
|
["videoSecondaryInfoRenderer"]?["description"]?["runs"]?.ToObject<JArray>());
|
||||||
|
video.Views = contents[0]
|
||||||
|
["videoPrimaryInfoRenderer"]?["viewCount"]?["videoViewCountRenderer"]?["viewCount"]?["simpleText"]?.ToString();
|
||||||
|
video.Channel = new Channel
|
||||||
|
{
|
||||||
|
Name =
|
||||||
|
contents[1]
|
||||||
|
["videoSecondaryInfoRenderer"]?["owner"]?["videoOwnerRenderer"]?["title"]?["runs"]?[0]?[
|
||||||
|
"text"]?.ToString(),
|
||||||
|
Id = contents[1]
|
||||||
|
["videoSecondaryInfoRenderer"]?["owner"]?["videoOwnerRenderer"]?["title"]?["runs"]?[0]?
|
||||||
|
["navigationEndpoint"]?["browseEndpoint"]?["browseId"]?.ToString(),
|
||||||
|
SubscriberCount =
|
||||||
|
contents[1]
|
||||||
|
["videoSecondaryInfoRenderer"]?["owner"]?["videoOwnerRenderer"]?["subscriberCountText"]?[
|
||||||
|
"simpleText"]?.ToString(),
|
||||||
|
Avatars =
|
||||||
|
(contents[1][
|
||||||
|
"videoSecondaryInfoRenderer"]?["owner"]?["videoOwnerRenderer"]?["thumbnail"]?[
|
||||||
|
"thumbnails"]
|
||||||
|
?.ToObject<JArray>() ?? new JArray()).Select(Utils.ParseThumbnails).ToArray()
|
||||||
|
};
|
||||||
|
video.UploadDate = contents[0][
|
||||||
|
"videoPrimaryInfoRenderer"]?["dateText"]?["simpleText"]?.ToString();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
video.Title ??= "";
|
||||||
|
video.Description ??= "";
|
||||||
|
video.Channel ??= new Channel
|
||||||
|
{
|
||||||
|
Name = "",
|
||||||
|
Id = "",
|
||||||
|
SubscriberCount = "",
|
||||||
|
Avatars = Array.Empty<Thumbnail>()
|
||||||
|
};
|
||||||
|
video.UploadDate ??= "";
|
||||||
|
}
|
||||||
|
video.Recommended = ParseRenderers(
|
||||||
|
player?["contents"]?["twoColumnWatchNextResults"]?["secondaryResults"]?["secondaryResults"]?
|
||||||
|
["results"]?.ToObject<JArray>() ?? new JArray());
|
||||||
|
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<YoutubeSearchResults> SearchAsync(string query, string continuation = null,
|
||||||
|
string language = "en", string region = "US")
|
||||||
|
{
|
||||||
|
Dictionary<string, object> data = new();
|
||||||
|
if (string.IsNullOrWhiteSpace(continuation))
|
||||||
|
data.Add("query", query);
|
||||||
|
else
|
||||||
|
data.Add("continuation", continuation);
|
||||||
|
JObject search = await MakeRequest("search", data, language, region);
|
||||||
|
|
||||||
|
return new YoutubeSearchResults
|
||||||
|
{
|
||||||
|
Refinements = search?["refinements"]?.ToObject<string[]>() ?? Array.Empty<string>(),
|
||||||
|
EstimatedResults = search?["estimatedResults"]?.ToObject<long>() ?? 0,
|
||||||
|
Results = ParseRenderers(
|
||||||
|
search?["contents"]?["twoColumnSearchResultsRenderer"]?["primaryContents"]?["sectionListRenderer"]?
|
||||||
|
["contents"]?[0]?["itemSectionRenderer"]?["contents"]?.ToObject<JArray>() ??
|
||||||
|
search?["onResponseReceivedCommands"]?[0]?["appendContinuationItemsAction"]?["continuationItems"]?
|
||||||
|
[0]?["itemSectionRenderer"]?["contents"]?.ToObject<JArray>() ?? new JArray()),
|
||||||
|
ContinuationKey =
|
||||||
|
search?["contents"]?["twoColumnSearchResultsRenderer"]?["primaryContents"]?["sectionListRenderer"]?
|
||||||
|
["contents"]?[1]?["continuationItemRenderer"]?["continuationEndpoint"]?["continuationCommand"]?
|
||||||
|
["token"]?.ToString() ??
|
||||||
|
search?["onResponseReceivedCommands"]?[0]?["appendContinuationItemsAction"]?["continuationItems"]?
|
||||||
|
[1]?["continuationItemRenderer"]?["continuationEndpoint"]?["continuationCommand"]?["token"]
|
||||||
|
?.ToString() ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<YoutubePlaylist> GetPlaylistAsync(string id, string continuation = null,
|
||||||
|
string language = "en", string region = "US")
|
||||||
|
{
|
||||||
|
Dictionary<string, object> data = new();
|
||||||
|
if (string.IsNullOrWhiteSpace(continuation))
|
||||||
|
data.Add("browseId", "VL" + id);
|
||||||
|
else
|
||||||
|
data.Add("continuation", continuation);
|
||||||
|
JObject playlist = await MakeRequest("browse", data, language, region);
|
||||||
|
DynamicItem[] renderers = ParseRenderers(
|
||||||
|
playlist?["contents"]?["twoColumnBrowseResultsRenderer"]?["tabs"]?[0]?["tabRenderer"]?["content"]?
|
||||||
|
["sectionListRenderer"]?["contents"]?[0]?["itemSectionRenderer"]?["contents"]?[0]?
|
||||||
|
["playlistVideoListRenderer"]?["contents"]?.ToObject<JArray>() ??
|
||||||
|
playlist?["onResponseReceivedActions"]?[0]?["appendContinuationItemsAction"]?["continuationItems"]
|
||||||
|
?.ToObject<JArray>() ?? new JArray());
|
||||||
|
|
||||||
|
return new YoutubePlaylist
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Title = playlist?["metadata"]?["playlistMetadataRenderer"]?["title"]?.ToString(),
|
||||||
|
Description = playlist?["metadata"]?["playlistMetadataRenderer"]?["description"]?.ToString(),
|
||||||
|
VideoCount = playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[0]?[
|
||||||
|
"playlistSidebarPrimaryInfoRenderer"]?["stats"]?[0]?["runs"]?[0]?["text"]?.ToString(),
|
||||||
|
ViewCount = playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[0]?[
|
||||||
|
"playlistSidebarPrimaryInfoRenderer"]?["stats"]?[1]?["simpleText"]?.ToString(),
|
||||||
|
LastUpdated = Utils.ReadRuns(playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[0]?[
|
||||||
|
"playlistSidebarPrimaryInfoRenderer"]?["stats"]?[2]?["runs"]?.ToObject<JArray>() ?? new JArray()),
|
||||||
|
Thumbnail = (playlist?["microformat"]?["microformatDataRenderer"]?["thumbnail"]?["thumbnails"] ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name =
|
||||||
|
playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[1]?
|
||||||
|
["playlistSidebarSecondaryInfoRenderer"]?["videoOwner"]?["videoOwnerRenderer"]?["title"]?
|
||||||
|
["runs"]?[0]?["text"]?.ToString(),
|
||||||
|
Id = playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[1]?
|
||||||
|
["playlistSidebarSecondaryInfoRenderer"]?["videoOwner"]?["videoOwnerRenderer"]?
|
||||||
|
["navigationEndpoint"]?["browseEndpoint"]?["browseId"]?.ToString(),
|
||||||
|
SubscriberCount = "",
|
||||||
|
Avatars =
|
||||||
|
(playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[1]?
|
||||||
|
["playlistSidebarSecondaryInfoRenderer"]?["videoOwner"]?["videoOwnerRenderer"]?["thumbnail"]
|
||||||
|
?["thumbnails"] ?? new JArray()).Select(Utils.ParseThumbnails).ToArray()
|
||||||
|
},
|
||||||
|
Videos = renderers.Where(x => x is not ContinuationItem).ToArray(),
|
||||||
|
ContinuationKey = renderers.FirstOrDefault(x => x is ContinuationItem)?.Id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<YoutubeChannel> GetChannelAsync(string id, ChannelTabs tab = ChannelTabs.Home,
|
||||||
|
string continuation = null, string language = "en", string region = "US")
|
||||||
|
{
|
||||||
|
Dictionary<string, object> data = new();
|
||||||
|
if (string.IsNullOrWhiteSpace(continuation))
|
||||||
|
{
|
||||||
|
data.Add("browseId", id);
|
||||||
|
if (string.IsNullOrWhiteSpace(continuation))
|
||||||
|
data.Add("params", ChannelTabParams[tab]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
data.Add("continuation", continuation);
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject channel = await MakeRequest("browse", data, language, region);
|
||||||
|
JArray mainArray =
|
||||||
|
(channel?["contents"]?["twoColumnBrowseResultsRenderer"]?["tabs"]?.ToObject<JArray>() ?? new JArray())
|
||||||
|
.FirstOrDefault(x => x?["tabRenderer"]?["selected"]?.ToObject<bool>() ?? false)?["tabRenderer"]?[
|
||||||
|
"content"]?
|
||||||
|
["sectionListRenderer"]?["contents"]?.ToObject<JArray>();
|
||||||
|
|
||||||
|
return new YoutubeChannel
|
||||||
|
{
|
||||||
|
Id = channel?["metadata"]?["channelMetadataRenderer"]?["externalId"]?.ToString(),
|
||||||
|
Name = channel?["metadata"]?["channelMetadataRenderer"]?["title"]?.ToString(),
|
||||||
|
Url = channel?["metadata"]?["channelMetadataRenderer"]?["externalId"]?.ToString(),
|
||||||
|
Avatars = (channel?["metadata"]?["channelMetadataRenderer"]?["avatar"]?["thumbnails"] ?? new JArray())
|
||||||
|
.Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
Banners = (channel?["header"]?["c4TabbedHeaderRenderer"]?["banner"]?["thumbnails"] ?? new JArray())
|
||||||
|
.Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
Description = channel?["metadata"]?["channelMetadataRenderer"]?["description"]?.ToString(),
|
||||||
|
Videos = ParseRenderers(mainArray ??
|
||||||
|
channel?["onResponseReceivedActions"]?[0]?["appendContinuationItemsAction"]?
|
||||||
|
["continuationItems"]?.ToObject<JArray>() ?? new JArray()),
|
||||||
|
Subscribers = channel?["header"]?["c4TabbedHeaderRenderer"]?["subscriberCountText"]?["simpleText"]
|
||||||
|
?.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<YoutubeTrends> GetExploreAsync(string browseId = null, string continuation = null, string language = "en", string region = "US")
|
||||||
|
{
|
||||||
|
Dictionary<string, object> data = new();
|
||||||
|
if (string.IsNullOrWhiteSpace(continuation))
|
||||||
|
{
|
||||||
|
data.Add("browseId", browseId ?? "FEexplore");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
data.Add("continuation", continuation);
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject explore = await MakeRequest("browse", data, language, region);
|
||||||
|
JToken[] token =
|
||||||
|
(explore?["contents"]?["twoColumnBrowseResultsRenderer"]?["tabs"]?[0]?["tabRenderer"]?["content"]?
|
||||||
|
["sectionListRenderer"]?["contents"]?.ToObject<JArray>() ?? new JArray()).Skip(1).ToArray();
|
||||||
|
|
||||||
|
JArray mainArray = new(token.Select(x => x is JObject obj ? obj : null).Where(x => x is not null));
|
||||||
|
|
||||||
|
return new YoutubeTrends
|
||||||
|
{
|
||||||
|
Categories = explore?["contents"]?["twoColumnBrowseResultsRenderer"]?["tabs"]?[0]?["tabRenderer"]?["content"]?["sectionListRenderer"]?["contents"]?[0]?["itemSectionRenderer"]?["contents"]?[0]?["destinationShelfRenderer"]?["destinationButtons"]?.Select(
|
||||||
|
x =>
|
||||||
|
{
|
||||||
|
JToken rendererObject = x?["destinationButtonRenderer"];
|
||||||
|
TrendCategory category = new()
|
||||||
|
{
|
||||||
|
Label = rendererObject?["label"]?["simpleText"]?.ToString(),
|
||||||
|
BackgroundImage = (rendererObject?["backgroundImage"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
Icon = (rendererObject?["iconImage"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
Id = $"{rendererObject?["onTap"]?["browseEndpoint"]?["browseId"]}"
|
||||||
|
};
|
||||||
|
return category;
|
||||||
|
}).ToArray(),
|
||||||
|
Videos = ParseRenderers(mainArray)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<YoutubeLocals> GetLocalsAsync(string language = "en", string region = "US")
|
||||||
|
{
|
||||||
|
JObject locals = await MakeRequest("account/account_menu", new Dictionary<string, object>(), language,
|
||||||
|
region);
|
||||||
|
|
||||||
|
return new YoutubeLocals
|
||||||
|
{
|
||||||
|
Languages =
|
||||||
|
locals["actions"]?[0]?["openPopupAction"]?["popup"]?["multiPageMenuRenderer"]?["sections"]?[0]?
|
||||||
|
["multiPageMenuSectionRenderer"]?["items"]?[1]?["compactLinkRenderer"]?["serviceEndpoint"]?
|
||||||
|
["signalServiceEndpoint"]?["actions"]?[0]?["getMultiPageMenuAction"]?["menu"]?
|
||||||
|
["multiPageMenuRenderer"]?["sections"]?[0]?["multiPageMenuSectionRenderer"]?["items"]?
|
||||||
|
.ToObject<JArray>()?.ToDictionary(
|
||||||
|
x => x?["compactLinkRenderer"]?["serviceEndpoint"]?["signalServiceEndpoint"]?
|
||||||
|
["actions"]?[0]?["selectLanguageCommand"]?["hl"]?.ToString(),
|
||||||
|
x => x?["compactLinkRenderer"]?["title"]?["simpleText"]?.ToString()),
|
||||||
|
Regions =
|
||||||
|
locals["actions"]?[0]?["openPopupAction"]?["popup"]?["multiPageMenuRenderer"]?["sections"]?[0]?
|
||||||
|
["multiPageMenuSectionRenderer"]?["items"]?[2]?["compactLinkRenderer"]?["serviceEndpoint"]?
|
||||||
|
["signalServiceEndpoint"]?["actions"]?[0]?["getMultiPageMenuAction"]?["menu"]?
|
||||||
|
["multiPageMenuRenderer"]?["sections"]?[0]?["multiPageMenuSectionRenderer"]?["items"]?
|
||||||
|
.ToObject<JArray>()?.ToDictionary(
|
||||||
|
x => x?["compactLinkRenderer"]?["serviceEndpoint"]?["signalServiceEndpoint"]?
|
||||||
|
["actions"]?[0]?["selectCountryCommand"]?["gl"]?.ToString(),
|
||||||
|
x => x?["compactLinkRenderer"]?["title"]?["simpleText"]?.ToString())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private DynamicItem[] ParseRenderers(JArray renderersArray)
|
||||||
|
{
|
||||||
|
List<DynamicItem> items = new();
|
||||||
|
|
||||||
|
foreach (JToken jToken in renderersArray)
|
||||||
|
{
|
||||||
|
JObject recommendationContainer = jToken as JObject;
|
||||||
|
string rendererName = recommendationContainer?.First?.Path.Split(".").Last() ?? "";
|
||||||
|
JObject rendererItem = recommendationContainer?[rendererName]?.ToObject<JObject>();
|
||||||
|
switch (rendererName)
|
||||||
|
{
|
||||||
|
case "videoRenderer":
|
||||||
|
items.Add(new VideoItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["videoId"]?.ToString(),
|
||||||
|
Title = Utils.ReadRuns(rendererItem?["title"]?["runs"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
UploadedAt = rendererItem?["publishedTimeText"]?["simpleText"]?.ToString(),
|
||||||
|
Views = long.TryParse(
|
||||||
|
rendererItem?["viewCountText"]?["simpleText"]?.ToString().Split(" ")[0]
|
||||||
|
.Replace(",", "").Replace(".", "") ?? "0", out long vV) ? vV : 0,
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = rendererItem?["longBylineText"]?["runs"]?[0]?["text"]?.ToString(),
|
||||||
|
Id = rendererItem?["longBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||||
|
"browseEndpoint"]?["browseId"]?.ToString(),
|
||||||
|
SubscriberCount = null,
|
||||||
|
Avatars =
|
||||||
|
(rendererItem?["channelThumbnailSupportedRenderers"]?[
|
||||||
|
"channelThumbnailWithLinkRenderer"]?["thumbnail"]?["thumbnails"]
|
||||||
|
?.ToObject<JArray>() ?? new JArray()).Select(Utils.ParseThumbnails)
|
||||||
|
.ToArray()
|
||||||
|
},
|
||||||
|
Duration = rendererItem?["thumbnailOverlays"]?[0]?[
|
||||||
|
"thumbnailOverlayTimeStatusRenderer"]?["text"]?["simpleText"]?.ToString(),
|
||||||
|
Description = Utils.ReadRuns(rendererItem?["detailedMetadataSnippets"]?[0]?[
|
||||||
|
"snippetText"]?["runs"]?.ToObject<JArray>() ?? new JArray())
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "gridVideoRenderer":
|
||||||
|
items.Add(new VideoItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["videoId"]?.ToString(),
|
||||||
|
Title = rendererItem?["title"]?["simpleText"]?.ToString() ?? Utils.ReadRuns(
|
||||||
|
rendererItem?["title"]?["runs"]?.ToObject<JArray>() ?? new JArray()),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
UploadedAt = rendererItem?["publishedTimeText"]?["simpleText"]?.ToString(),
|
||||||
|
Views = long.TryParse(
|
||||||
|
rendererItem?["viewCountText"]?["simpleText"]?.ToString().Split(" ")[0]
|
||||||
|
.Replace(",", "").Replace(".", "") ?? "0", out long gVV) ? gVV : 0,
|
||||||
|
Channel = null,
|
||||||
|
Duration = rendererItem?["thumbnailOverlays"]?[0]?[
|
||||||
|
"thumbnailOverlayTimeStatusRenderer"]?["text"]?["simpleText"]?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "playlistRenderer":
|
||||||
|
items.Add(new PlaylistItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["playlistId"]
|
||||||
|
?.ToString(),
|
||||||
|
Title = rendererItem?["title"]?["simpleText"]
|
||||||
|
?.ToString(),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnails"]?[0]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
VideoCount = int.TryParse(
|
||||||
|
rendererItem?["videoCountText"]?["runs"]?[0]?["text"]?.ToString().Replace(",", "")
|
||||||
|
.Replace(".", "") ?? "0", out int pVC) ? pVC : 0,
|
||||||
|
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]
|
||||||
|
?.ToString(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = rendererItem?["longBylineText"]?["runs"]?[0]?["text"]
|
||||||
|
?.ToString(),
|
||||||
|
Id = rendererItem?["longBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||||
|
"browseEndpoint"]?["browseId"]
|
||||||
|
?.ToString(),
|
||||||
|
SubscriberCount = null,
|
||||||
|
Avatars = null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "channelRenderer":
|
||||||
|
items.Add(new ChannelItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["channelId"]?.ToString(),
|
||||||
|
Title = rendererItem?["title"]?["simpleText"]?.ToString(),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnail"]?["thumbnails"]
|
||||||
|
?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails)
|
||||||
|
.ToArray(), //
|
||||||
|
Url = rendererItem?["navigationEndpoint"]?["commandMetadata"]?["webCommandMetadata"]?["url"]
|
||||||
|
?.ToString(),
|
||||||
|
Description =
|
||||||
|
Utils.ReadRuns(rendererItem?["descriptionSnippet"]?["runs"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()),
|
||||||
|
VideoCount = long.TryParse(
|
||||||
|
rendererItem?["videoCountText"]?["runs"]?[0]?["text"]
|
||||||
|
?.ToString()
|
||||||
|
.Replace(",",
|
||||||
|
"")
|
||||||
|
.Replace(".",
|
||||||
|
"") ??
|
||||||
|
"0", out long cVC) ? cVC : 0,
|
||||||
|
Subscribers = rendererItem?["subscriberCountText"]?["simpleText"]?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "radioRenderer":
|
||||||
|
items.Add(new RadioItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["playlistId"]
|
||||||
|
?.ToString(),
|
||||||
|
Title = rendererItem?["title"]?["simpleText"]
|
||||||
|
?.ToString(),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]
|
||||||
|
?.ToString(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = rendererItem?["longBylineText"]?["simpleText"]?.ToString(),
|
||||||
|
Id = "",
|
||||||
|
SubscriberCount = null,
|
||||||
|
Avatars = null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "shelfRenderer":
|
||||||
|
items.Add(new ShelfItem
|
||||||
|
{
|
||||||
|
Title = rendererItem?["title"]?["simpleText"]
|
||||||
|
?.ToString() ??
|
||||||
|
rendererItem?["title"]?["runs"]?[0]?["text"]
|
||||||
|
?.ToString(),
|
||||||
|
Thumbnails = (rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
Items = ParseRenderers(
|
||||||
|
rendererItem?["content"]?["verticalListRenderer"]?["items"]
|
||||||
|
?.ToObject<JArray>() ??
|
||||||
|
rendererItem?["content"]?["horizontalListRenderer"]?["items"]
|
||||||
|
?.ToObject<JArray>() ??
|
||||||
|
rendererItem?["content"]?["expandedShelfContentsRenderer"]?["items"]
|
||||||
|
?.ToObject<JArray>() ??
|
||||||
|
new JArray()),
|
||||||
|
CollapsedItemCount =
|
||||||
|
rendererItem?["content"]?["verticalListRenderer"]?["collapsedItemCount"]
|
||||||
|
?.ToObject<int>() ?? 0,
|
||||||
|
Badges = ParseRenderers(rendererItem?["badges"]?.ToObject<JArray>() ?? new JArray())
|
||||||
|
.Where(x => x is BadgeItem).Cast<BadgeItem>().ToArray(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "horizontalCardListRenderer":
|
||||||
|
items.Add(new HorizontalCardListItem
|
||||||
|
{
|
||||||
|
Title = rendererItem?["header"]?["richListHeaderRenderer"]?["title"]?["simpleText"]
|
||||||
|
?.ToString(),
|
||||||
|
Items = ParseRenderers(rendererItem?["cards"]?.ToObject<JArray>() ?? new JArray())
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "searchRefinementCardRenderer":
|
||||||
|
items.Add(new CardItem
|
||||||
|
{
|
||||||
|
Title = Utils.ReadRuns(rendererItem?["query"]?["runs"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()),
|
||||||
|
Thumbnails = (rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "compactVideoRenderer":
|
||||||
|
items.Add(new VideoItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["videoId"]?.ToString(),
|
||||||
|
Title = rendererItem?["title"]?["simpleText"]?.ToString(),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
UploadedAt = rendererItem?["publishedTimeText"]?["simpleText"]?.ToString(),
|
||||||
|
Views = long.TryParse(
|
||||||
|
rendererItem?["viewCountText"]?["simpleText"]?.ToString().Split(" ")[0]
|
||||||
|
.Replace(",", "").Replace(".", "") ?? "0", out long cVV) ? cVV : 0,
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = rendererItem?["longBylineText"]?["runs"]?[0]?["text"]?.ToString(),
|
||||||
|
Id = rendererItem?["longBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||||
|
"browseEndpoint"]?["browseId"]?.ToString(),
|
||||||
|
SubscriberCount = null,
|
||||||
|
Avatars = null
|
||||||
|
},
|
||||||
|
Duration = rendererItem?["thumbnailOverlays"]?[0]?[
|
||||||
|
"thumbnailOverlayTimeStatusRenderer"]?["text"]?["simpleText"]?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "compactPlaylistRenderer":
|
||||||
|
items.Add(new PlaylistItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["playlistId"]
|
||||||
|
?.ToString(),
|
||||||
|
Title = rendererItem?["title"]?["simpleText"]
|
||||||
|
?.ToString(),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnail"]?["thumbnails"]
|
||||||
|
?.ToObject<JArray>() ?? new JArray()).Select(Utils.ParseThumbnails)
|
||||||
|
.ToArray(),
|
||||||
|
VideoCount = int.TryParse(
|
||||||
|
rendererItem?["videoCountText"]?["runs"]?[0]?["text"]?.ToString().Replace(",", "")
|
||||||
|
.Replace(".", "") ?? "0", out int cPVC) ? cPVC : 0,
|
||||||
|
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]
|
||||||
|
?.ToString(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = rendererItem?["longBylineText"]?["runs"]?[0]?["text"]
|
||||||
|
?.ToString(),
|
||||||
|
Id = rendererItem?["longBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||||
|
"browseEndpoint"]?["browseId"]
|
||||||
|
?.ToString(),
|
||||||
|
SubscriberCount = null,
|
||||||
|
Avatars = null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "compactRadioRenderer":
|
||||||
|
items.Add(new RadioItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["playlistId"]
|
||||||
|
?.ToString(),
|
||||||
|
Title = rendererItem?["title"]?["simpleText"]
|
||||||
|
?.ToString(),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnail"]?["thumbnails"]
|
||||||
|
?.ToObject<JArray>() ?? new JArray()).Select(Utils.ParseThumbnails)
|
||||||
|
.ToArray(),
|
||||||
|
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]
|
||||||
|
?.ToString(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = rendererItem?["longBylineText"]?["simpleText"]?.ToString(),
|
||||||
|
Id = "",
|
||||||
|
SubscriberCount = null,
|
||||||
|
Avatars = null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "continuationItemRenderer":
|
||||||
|
items.Add(new ContinuationItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["continuationEndpoint"]?["continuationCommand"]?["token"]?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "playlistVideoRenderer":
|
||||||
|
items.Add(new PlaylistVideoItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["videoId"]?.ToString(),
|
||||||
|
Index = rendererItem?["index"]?["simpleText"]?.ToObject<long>() ?? 0,
|
||||||
|
Title = Utils.ReadRuns(rendererItem?["title"]?["runs"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = rendererItem?["shortBylineText"]?["runs"]?[0]?["text"]?.ToString(),
|
||||||
|
Id = rendererItem?["shortBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||||
|
"browseEndpoint"]?["browseId"]?.ToString(),
|
||||||
|
SubscriberCount = null,
|
||||||
|
Avatars = null
|
||||||
|
},
|
||||||
|
Duration = rendererItem?["lengthText"]?["simpleText"]?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "itemSectionRenderer":
|
||||||
|
items.Add(new ItemSectionItem
|
||||||
|
{
|
||||||
|
Contents = ParseRenderers(rendererItem?["contents"]?.ToObject<JArray>() ?? new JArray())
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "gridRenderer":
|
||||||
|
items.Add(new ItemSectionItem
|
||||||
|
{
|
||||||
|
Contents = ParseRenderers(rendererItem?["items"]?.ToObject<JArray>() ?? new JArray())
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "messageRenderer":
|
||||||
|
items.Add(new MessageItem
|
||||||
|
{
|
||||||
|
Title = rendererItem?["text"]?["simpleText"]?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "channelAboutFullMetadataRenderer":
|
||||||
|
items.Add(new ChannelAboutItem
|
||||||
|
{
|
||||||
|
Description = rendererItem?["description"]?["simpleText"]?.ToString(),
|
||||||
|
Country = rendererItem?["country"]?["simpleText"]?.ToString(),
|
||||||
|
Joined = Utils.ReadRuns(rendererItem?["joinedDateText"]?["runs"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()),
|
||||||
|
ViewCount = rendererItem?["viewCountText"]?["simpleText"]?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "compactStationRenderer":
|
||||||
|
items.Add(new StationItem
|
||||||
|
{
|
||||||
|
Id = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["playlistId"]?.ToString(),
|
||||||
|
Title = rendererItem?["title"]?["simpleText"]?.ToString(),
|
||||||
|
Thumbnails =
|
||||||
|
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||||
|
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||||
|
VideoCount = rendererItem?["videoCountText"]?["runs"]?[0]?["text"].ToObject<int>() ?? 0,
|
||||||
|
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]?.ToString(),
|
||||||
|
Description = rendererItem?["description"]?["simpleText"]?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "metadataBadgeRenderer":
|
||||||
|
items.Add(new BadgeItem
|
||||||
|
{
|
||||||
|
Title = rendererItem?["label"]?.ToString(),
|
||||||
|
Style = rendererItem?["style"]?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "promotedSparklesWebRenderer":
|
||||||
|
// this is an ad
|
||||||
|
// no one likes ads
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
items.Add(new DynamicItem
|
||||||
|
{
|
||||||
|
Id = rendererName,
|
||||||
|
Title = rendererItem?.ToString()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
7
core/LightTube/Contexts/BaseContext.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace LightTube.Contexts
|
||||||
|
{
|
||||||
|
public class BaseContext
|
||||||
|
{
|
||||||
|
public bool MobileLayout;
|
||||||
|
}
|
||||||
|
}
|
7
core/LightTube/Contexts/ErrorContext.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace LightTube.Contexts
|
||||||
|
{
|
||||||
|
public class ErrorContext : BaseContext
|
||||||
|
{
|
||||||
|
public string Path;
|
||||||
|
}
|
||||||
|
}
|
11
core/LightTube/Contexts/FeedContext.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using LightTube.Database;
|
||||||
|
|
||||||
|
namespace LightTube.Contexts
|
||||||
|
{
|
||||||
|
public class FeedContext : BaseContext
|
||||||
|
{
|
||||||
|
public LTChannel[] Channels;
|
||||||
|
public FeedVideo[] Videos;
|
||||||
|
public string RssToken;
|
||||||
|
}
|
||||||
|
}
|
13
core/LightTube/Contexts/LocalsContext.cs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using InnerTube.Models;
|
||||||
|
|
||||||
|
namespace LightTube.Contexts
|
||||||
|
{
|
||||||
|
public class LocalsContext : BaseContext
|
||||||
|
{
|
||||||
|
public Dictionary<string, string> Languages;
|
||||||
|
public Dictionary<string, string> Regions;
|
||||||
|
public string CurrentLanguage;
|
||||||
|
public string CurrentRegion;
|
||||||
|
}
|
||||||
|
}
|
11
core/LightTube/Contexts/PlaylistsContext.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using LightTube.Database;
|
||||||
|
|
||||||
|
namespace LightTube.Contexts
|
||||||
|
{
|
||||||
|
public class PlaylistsContext : BaseContext
|
||||||
|
{
|
||||||
|
public IEnumerable<LTPlaylist> Playlists;
|
||||||
|
}
|
||||||
|
}
|
351
core/LightTube/Controllers/AccountController.cs
Normal file
|
@ -0,0 +1,351 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Web;
|
||||||
|
using InnerTube;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using LightTube.Contexts;
|
||||||
|
using LightTube.Database;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace LightTube.Controllers
|
||||||
|
{
|
||||||
|
public class AccountController : Controller
|
||||||
|
{
|
||||||
|
private readonly Youtube _youtube;
|
||||||
|
|
||||||
|
public AccountController(Youtube youtube)
|
||||||
|
{
|
||||||
|
_youtube = youtube;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/Account")]
|
||||||
|
public IActionResult Account()
|
||||||
|
{
|
||||||
|
return View(new BaseContext
|
||||||
|
{
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult Login(string err = null)
|
||||||
|
{
|
||||||
|
if (HttpContext.TryGetUser(out LTUser _, "web"))
|
||||||
|
return Redirect("/");
|
||||||
|
|
||||||
|
return View(new MessageContext
|
||||||
|
{
|
||||||
|
Message = err,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Login(string userid, string password)
|
||||||
|
{
|
||||||
|
if (HttpContext.TryGetUser(out LTUser _, "web"))
|
||||||
|
return Redirect("/");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LTLogin login = await DatabaseManager.Logins.CreateToken(userid, password, Request.Headers["user-agent"], new []{"web"});
|
||||||
|
Response.Cookies.Append("token", login.Token, new CookieOptions
|
||||||
|
{
|
||||||
|
Expires = DateTimeOffset.MaxValue
|
||||||
|
});
|
||||||
|
|
||||||
|
return Redirect("/");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException e)
|
||||||
|
{
|
||||||
|
return Redirect("/Account/Login?err=" + HttpUtility.UrlEncode(e.Message));
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException e)
|
||||||
|
{
|
||||||
|
return Redirect("/Account/Login?err=" + HttpUtility.UrlEncode(e.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> Logout()
|
||||||
|
{
|
||||||
|
if (HttpContext.Request.Cookies.TryGetValue("token", out string token))
|
||||||
|
{
|
||||||
|
await DatabaseManager.Logins.RemoveToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpContext.Response.Cookies.Delete("token");
|
||||||
|
HttpContext.Response.Cookies.Delete("account_data");
|
||||||
|
return Redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult Register(string err = null)
|
||||||
|
{
|
||||||
|
if (HttpContext.TryGetUser(out LTUser _, "web"))
|
||||||
|
return Redirect("/");
|
||||||
|
|
||||||
|
return View(new MessageContext
|
||||||
|
{
|
||||||
|
Message = err,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Register(string userid, string password)
|
||||||
|
{
|
||||||
|
if (HttpContext.TryGetUser(out LTUser _, "web"))
|
||||||
|
return Redirect("/");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DatabaseManager.Logins.CreateUser(userid, password);
|
||||||
|
LTLogin login = await DatabaseManager.Logins.CreateToken(userid, password, Request.Headers["user-agent"], new []{"web"});
|
||||||
|
Response.Cookies.Append("token", login.Token, new CookieOptions
|
||||||
|
{
|
||||||
|
Expires = DateTimeOffset.MaxValue
|
||||||
|
});
|
||||||
|
|
||||||
|
return Redirect("/");
|
||||||
|
}
|
||||||
|
catch (DuplicateNameException e)
|
||||||
|
{
|
||||||
|
return Redirect("/Account/Register?err=" + HttpUtility.UrlEncode(e.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IActionResult RegisterLocal()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser _, "web"))
|
||||||
|
HttpContext.CreateLocalAccount();
|
||||||
|
|
||||||
|
return Redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult Delete(string err = null)
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser _, "web"))
|
||||||
|
return Redirect("/");
|
||||||
|
|
||||||
|
return View(new MessageContext
|
||||||
|
{
|
||||||
|
Message = err,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Delete(string userid, string password)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (userid == "Local Account" && password == "local_account")
|
||||||
|
Response.Cookies.Delete("account_data");
|
||||||
|
else
|
||||||
|
await DatabaseManager.Logins.DeleteUser(userid, password);
|
||||||
|
return Redirect("/Account/Register?err=Account+deleted");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException e)
|
||||||
|
{
|
||||||
|
return Redirect("/Account/Delete?err=" + HttpUtility.UrlEncode(e.Message));
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException e)
|
||||||
|
{
|
||||||
|
return Redirect("/Account/Delete?err=" + HttpUtility.UrlEncode(e.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> Logins()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser _, "web") || !HttpContext.Request.Cookies.TryGetValue("token", out string token))
|
||||||
|
return Redirect("/Account/Login");
|
||||||
|
|
||||||
|
return View(new LoginsContext
|
||||||
|
{
|
||||||
|
CurrentLogin = await DatabaseManager.Logins.GetCurrentLoginId(token),
|
||||||
|
Logins = await DatabaseManager.Logins.GetAllUserTokens(token),
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> DisableLogin(string id)
|
||||||
|
{
|
||||||
|
if (!HttpContext.Request.Cookies.TryGetValue("token", out string token))
|
||||||
|
return Redirect("/Account/Login");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DatabaseManager.Logins.RemoveTokenFromId(token, id);
|
||||||
|
} catch { }
|
||||||
|
return Redirect("/Account/Logins");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> Subscribe(string channel)
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
YoutubeChannel youtubeChannel = await _youtube.GetChannelAsync(channel, ChannelTabs.About);
|
||||||
|
|
||||||
|
(LTChannel channel, bool subscribed) result;
|
||||||
|
result.channel = await DatabaseManager.Channels.UpdateChannel(youtubeChannel.Id, youtubeChannel.Name, youtubeChannel.Subscribers,
|
||||||
|
youtubeChannel.Avatars.First().Url);
|
||||||
|
|
||||||
|
if (user.PasswordHash == "local_account")
|
||||||
|
{
|
||||||
|
LTChannel ltChannel = await DatabaseManager.Channels.UpdateChannel(youtubeChannel.Id, youtubeChannel.Name, youtubeChannel.Subscribers,
|
||||||
|
youtubeChannel.Avatars.First().Url);
|
||||||
|
if (user.SubscribedChannels.Contains(ltChannel.ChannelId))
|
||||||
|
user.SubscribedChannels.Remove(ltChannel.ChannelId);
|
||||||
|
else
|
||||||
|
user.SubscribedChannels.Add(ltChannel.ChannelId);
|
||||||
|
|
||||||
|
HttpContext.Response.Cookies.Append("account_data", JsonConvert.SerializeObject(user),
|
||||||
|
new CookieOptions
|
||||||
|
{
|
||||||
|
Expires = DateTimeOffset.MaxValue
|
||||||
|
});
|
||||||
|
|
||||||
|
result.subscribed = user.SubscribedChannels.Contains(ltChannel.ChannelId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result =
|
||||||
|
await DatabaseManager.Logins.SubscribeToChannel(user, youtubeChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(result.subscribed ? "true" : "false");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IActionResult SubscriptionsJson()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||||
|
return Json(Array.Empty<string>());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Json(user.SubscribedChannels);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Json(Array.Empty<string>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> Settings()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||||
|
Redirect("/Account/Login");
|
||||||
|
|
||||||
|
if (Request.Method == "POST")
|
||||||
|
{
|
||||||
|
CookieOptions opts = new()
|
||||||
|
{
|
||||||
|
Expires = DateTimeOffset.MaxValue
|
||||||
|
};
|
||||||
|
foreach ((string key, StringValues value) in Request.Form)
|
||||||
|
{
|
||||||
|
switch (key)
|
||||||
|
{
|
||||||
|
case "theme":
|
||||||
|
Response.Cookies.Append("theme", value, opts);
|
||||||
|
break;
|
||||||
|
case "hl":
|
||||||
|
Response.Cookies.Append("hl", value, opts);
|
||||||
|
break;
|
||||||
|
case "gl":
|
||||||
|
Response.Cookies.Append("gl", value, opts);
|
||||||
|
break;
|
||||||
|
case "compatibility":
|
||||||
|
Response.Cookies.Append("compatibility", value, opts);
|
||||||
|
break;
|
||||||
|
case "api-access":
|
||||||
|
await DatabaseManager.Logins.SetApiAccess(user, bool.Parse(value));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Redirect("/Account");
|
||||||
|
}
|
||||||
|
|
||||||
|
YoutubeLocals locals = await _youtube.GetLocalsAsync();
|
||||||
|
|
||||||
|
Request.Cookies.TryGetValue("theme", out string theme);
|
||||||
|
|
||||||
|
bool compatibility = false;
|
||||||
|
if (Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||||
|
bool.TryParse(compatibilityString, out compatibility);
|
||||||
|
|
||||||
|
return View(new SettingsContext
|
||||||
|
{
|
||||||
|
Languages = locals.Languages,
|
||||||
|
Regions = locals.Regions,
|
||||||
|
CurrentLanguage = HttpContext.GetLanguage(),
|
||||||
|
CurrentRegion = HttpContext.GetRegion(),
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request),
|
||||||
|
Theme = theme ?? "light",
|
||||||
|
CompatibilityMode = compatibility,
|
||||||
|
ApiAccess = user.ApiAccess
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> AddVideoToPlaylist(string v)
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||||
|
Redirect("/Account/Login");
|
||||||
|
|
||||||
|
JObject ytPlayer = await InnerTube.Utils.GetAuthorizedPlayer(v, new HttpClient());
|
||||||
|
return View(new AddToPlaylistContext
|
||||||
|
{
|
||||||
|
Id = v,
|
||||||
|
Video = await _youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||||
|
Playlists = await DatabaseManager.Playlists.GetUserPlaylists(user.UserID),
|
||||||
|
Thumbnail = ytPlayer?["videoDetails"]?["thumbnail"]?["thumbnails"]?[0]?["url"]?.ToString() ?? $"https://i.ytimg.com/vi_webp/{v}/maxresdefault.webp",
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult CreatePlaylist(string returnUrl = null)
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||||
|
Redirect("/Account/Login");
|
||||||
|
|
||||||
|
return View(new BaseContext
|
||||||
|
{
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> CreatePlaylist()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||||
|
Redirect("/Account/Login");
|
||||||
|
|
||||||
|
if (!Request.Form.ContainsKey("name") || string.IsNullOrWhiteSpace(Request.Form["name"])) return BadRequest();
|
||||||
|
|
||||||
|
LTPlaylist pl = await DatabaseManager.Playlists.CreatePlaylist(
|
||||||
|
user,
|
||||||
|
Request.Form["name"],
|
||||||
|
string.IsNullOrWhiteSpace(Request.Form["description"]) ? "" : Request.Form["description"],
|
||||||
|
Enum.Parse<PlaylistVisibility>(string.IsNullOrWhiteSpace(Request.Form["visibility"]) ? "UNLISTED" : Request.Form["visibility"]));
|
||||||
|
|
||||||
|
return Redirect($"/playlist?list={pl.Id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
187
core/LightTube/Controllers/ApiController.cs
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml;
|
||||||
|
using InnerTube;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace LightTube.Controllers
|
||||||
|
{
|
||||||
|
[Route("/api")]
|
||||||
|
public class ApiController : Controller
|
||||||
|
{
|
||||||
|
private const string VideoIdRegex = @"[a-zA-Z0-9_-]{11}";
|
||||||
|
private const string ChannelIdRegex = @"[a-zA-Z0-9_-]{24}";
|
||||||
|
private const string PlaylistIdRegex = @"[a-zA-Z0-9_-]{34}";
|
||||||
|
private readonly Youtube _youtube;
|
||||||
|
|
||||||
|
public ApiController(Youtube youtube)
|
||||||
|
{
|
||||||
|
_youtube = youtube;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult Xml(XmlNode xmlDocument)
|
||||||
|
{
|
||||||
|
MemoryStream ms = new();
|
||||||
|
ms.Write(Encoding.UTF8.GetBytes(xmlDocument.OuterXml));
|
||||||
|
ms.Position = 0;
|
||||||
|
HttpContext.Response.Headers.Add("Access-Control-Allow-Origin", "*");
|
||||||
|
return File(ms, "application/xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("player")]
|
||||||
|
public async Task<IActionResult> GetPlayerInfo(string v)
|
||||||
|
{
|
||||||
|
if (v is null)
|
||||||
|
return GetErrorVideoPlayer("", "Missing YouTube ID (query parameter `v`)");
|
||||||
|
|
||||||
|
Regex regex = new(VideoIdRegex);
|
||||||
|
if (!regex.IsMatch(v) || v.Length != 11)
|
||||||
|
return GetErrorVideoPlayer(v, "Invalid YouTube ID " + v);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
YoutubePlayer player =
|
||||||
|
await _youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||||
|
XmlDocument xml = player.GetXmlDocument();
|
||||||
|
return Xml(xml);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return GetErrorVideoPlayer(v, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult GetErrorVideoPlayer(string videoId, string message)
|
||||||
|
{
|
||||||
|
YoutubePlayer player = new()
|
||||||
|
{
|
||||||
|
Id = videoId,
|
||||||
|
Title = "",
|
||||||
|
Description = "",
|
||||||
|
Tags = Array.Empty<string>(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = "",
|
||||||
|
Id = "",
|
||||||
|
Avatars = Array.Empty<Thumbnail>()
|
||||||
|
},
|
||||||
|
Duration = 0,
|
||||||
|
Chapters = Array.Empty<Chapter>(),
|
||||||
|
Thumbnails = Array.Empty<Thumbnail>(),
|
||||||
|
Formats = Array.Empty<Format>(),
|
||||||
|
AdaptiveFormats = Array.Empty<Format>(),
|
||||||
|
Subtitles = Array.Empty<Subtitle>(),
|
||||||
|
Storyboards = Array.Empty<string>(),
|
||||||
|
ExpiresInSeconds = "0",
|
||||||
|
ErrorMessage = message
|
||||||
|
};
|
||||||
|
return Xml(player.GetXmlDocument());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("video")]
|
||||||
|
public async Task<IActionResult> GetVideoInfo(string v)
|
||||||
|
{
|
||||||
|
if (v is null)
|
||||||
|
return GetErrorVideoPlayer("", "Missing YouTube ID (query parameter `v`)");
|
||||||
|
|
||||||
|
Regex regex = new(VideoIdRegex);
|
||||||
|
if (!regex.IsMatch(v) || v.Length != 11)
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement item = doc.CreateElement("Error");
|
||||||
|
|
||||||
|
item.InnerText = "Invalid YouTube ID " + v;
|
||||||
|
|
||||||
|
doc.AppendChild(item);
|
||||||
|
return Xml(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
YoutubeVideo player = await _youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||||
|
XmlDocument xml = player.GetXmlDocument();
|
||||||
|
return Xml(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("search")]
|
||||||
|
public async Task<IActionResult> Search(string query, string continuation = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(continuation))
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement item = doc.CreateElement("Error");
|
||||||
|
|
||||||
|
item.InnerText = "Invalid query " + query;
|
||||||
|
|
||||||
|
doc.AppendChild(item);
|
||||||
|
return Xml(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
YoutubeSearchResults player = await _youtube.SearchAsync(query, continuation, HttpContext.GetLanguage(),
|
||||||
|
HttpContext.GetRegion());
|
||||||
|
XmlDocument xml = player.GetXmlDocument();
|
||||||
|
return Xml(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("playlist")]
|
||||||
|
public async Task<IActionResult> Playlist(string id, string continuation = null)
|
||||||
|
{
|
||||||
|
Regex regex = new(PlaylistIdRegex);
|
||||||
|
if (!regex.IsMatch(id) || id.Length != 34) return GetErrorVideoPlayer(id, "Invalid playlist ID " + id);
|
||||||
|
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(continuation))
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement item = doc.CreateElement("Error");
|
||||||
|
|
||||||
|
item.InnerText = "Invalid ID " + id;
|
||||||
|
|
||||||
|
doc.AppendChild(item);
|
||||||
|
return Xml(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
YoutubePlaylist player = await _youtube.GetPlaylistAsync(id, continuation, HttpContext.GetLanguage(),
|
||||||
|
HttpContext.GetRegion());
|
||||||
|
XmlDocument xml = player.GetXmlDocument();
|
||||||
|
return Xml(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("channel")]
|
||||||
|
public async Task<IActionResult> Channel(string id, ChannelTabs tab = ChannelTabs.Home,
|
||||||
|
string continuation = null)
|
||||||
|
{
|
||||||
|
Regex regex = new(ChannelIdRegex);
|
||||||
|
if (!regex.IsMatch(id) || id.Length != 24) return GetErrorVideoPlayer(id, "Invalid channel ID " + id);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(continuation))
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement item = doc.CreateElement("Error");
|
||||||
|
|
||||||
|
item.InnerText = "Invalid ID " + id;
|
||||||
|
|
||||||
|
doc.AppendChild(item);
|
||||||
|
return Xml(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
YoutubeChannel player = await _youtube.GetChannelAsync(id, tab, continuation, HttpContext.GetLanguage(),
|
||||||
|
HttpContext.GetRegion());
|
||||||
|
XmlDocument xml = player.GetXmlDocument();
|
||||||
|
return Xml(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("trending")]
|
||||||
|
public async Task<IActionResult> Trending(string id, string continuation = null)
|
||||||
|
{
|
||||||
|
YoutubeTrends player = await _youtube.GetExploreAsync(id, continuation,
|
||||||
|
HttpContext.GetLanguage(),
|
||||||
|
HttpContext.GetRegion());
|
||||||
|
XmlDocument xml = player.GetXmlDocument();
|
||||||
|
return Xml(xml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
189
core/LightTube/Controllers/AuthorizedApiController.cs
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml;
|
||||||
|
using InnerTube;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using LightTube.Database;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace LightTube.Controllers
|
||||||
|
{
|
||||||
|
[Route("/api/auth")]
|
||||||
|
public class AuthorizedApiController : Controller
|
||||||
|
{
|
||||||
|
private readonly Youtube _youtube;
|
||||||
|
|
||||||
|
private IReadOnlyList<string> _scopes = new[]
|
||||||
|
{
|
||||||
|
"api.subscriptions.read",
|
||||||
|
"api.subscriptions.write"
|
||||||
|
};
|
||||||
|
|
||||||
|
public AuthorizedApiController(Youtube youtube)
|
||||||
|
{
|
||||||
|
_youtube = youtube;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult Xml(XmlNode xmlDocument, HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
MemoryStream ms = new();
|
||||||
|
ms.Write(Encoding.UTF8.GetBytes(xmlDocument.OuterXml));
|
||||||
|
ms.Position = 0;
|
||||||
|
HttpContext.Response.Headers.Add("Access-Control-Allow-Origin", "*");
|
||||||
|
Response.StatusCode = (int)statusCode;
|
||||||
|
return File(ms, "application/xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
private XmlNode BuildErrorXml(string message)
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement error = doc.CreateElement("Error");
|
||||||
|
error.InnerText = message;
|
||||||
|
doc.AppendChild(error);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Route("getToken")]
|
||||||
|
public async Task<IActionResult> GetToken()
|
||||||
|
{
|
||||||
|
if (!Request.Headers.TryGetValue("User-Agent", out StringValues userAgent))
|
||||||
|
return Xml(BuildErrorXml("Missing User-Agent header"), HttpStatusCode.BadRequest);
|
||||||
|
|
||||||
|
Match match = Regex.Match(userAgent.ToString(), DatabaseManager.ApiUaRegex);
|
||||||
|
if (!match.Success)
|
||||||
|
return Xml(BuildErrorXml("Bad User-Agent header. Please see 'Documentation/API requests'"), HttpStatusCode.BadRequest);
|
||||||
|
if (match.Groups[1].ToString() != "1.0")
|
||||||
|
return Xml(BuildErrorXml($"Unknown API version {match.Groups[1]}"), HttpStatusCode.BadRequest);
|
||||||
|
|
||||||
|
if (!Request.Form.TryGetValue("user", out StringValues user))
|
||||||
|
return Xml(BuildErrorXml("Missing request value: 'user'"), HttpStatusCode.BadRequest);
|
||||||
|
if (!Request.Form.TryGetValue("password", out StringValues password))
|
||||||
|
return Xml(BuildErrorXml("Missing request value: 'password'"), HttpStatusCode.BadRequest);
|
||||||
|
if (!Request.Form.TryGetValue("scopes", out StringValues scopes))
|
||||||
|
return Xml(BuildErrorXml("Missing request value: 'scopes'"), HttpStatusCode.BadRequest);
|
||||||
|
|
||||||
|
string[] newScopes = scopes.First().Split(",");
|
||||||
|
foreach (string s in newScopes)
|
||||||
|
if (!_scopes.Contains(s))
|
||||||
|
return Xml(BuildErrorXml($"Unknown scope '{s}'"), HttpStatusCode.BadRequest);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LTLogin ltLogin =
|
||||||
|
await DatabaseManager.Logins.CreateToken(user, password, userAgent.ToString(),
|
||||||
|
scopes.First().Split(","));
|
||||||
|
return Xml(ltLogin.GetXmlElement(), HttpStatusCode.Created);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Xml(BuildErrorXml("Invalid credentials"), HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return Xml(BuildErrorXml("User has API access disabled"), HttpStatusCode.Forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("subscriptions/feed")]
|
||||||
|
public async Task<IActionResult> SubscriptionsFeed()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "api.subscriptions.read"))
|
||||||
|
return Xml(BuildErrorXml("Unauthorized"), HttpStatusCode.Unauthorized);
|
||||||
|
|
||||||
|
SubscriptionFeed feed = new()
|
||||||
|
{
|
||||||
|
videos = await YoutubeRSS.GetMultipleFeeds(user.SubscribedChannels)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Xml(feed.GetXmlDocument(), HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Route("subscriptions/channels")]
|
||||||
|
public IActionResult SubscriptionsChannels()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "api.subscriptions.read"))
|
||||||
|
return Xml(BuildErrorXml("Unauthorized"), HttpStatusCode.Unauthorized);
|
||||||
|
|
||||||
|
SubscriptionChannels feed = new()
|
||||||
|
{
|
||||||
|
Channels = user.SubscribedChannels.Select(DatabaseManager.Channels.GetChannel).ToArray()
|
||||||
|
};
|
||||||
|
Array.Sort(feed.Channels, (p, q) => string.Compare(p.Name, q.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return Xml(feed.GetXmlDocument(), HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[Route("subscriptions/channels")]
|
||||||
|
public async Task<IActionResult> Subscribe()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "api.subscriptions.write"))
|
||||||
|
return Xml(BuildErrorXml("Unauthorized"), HttpStatusCode.Unauthorized);
|
||||||
|
|
||||||
|
Request.Form.TryGetValue("id", out StringValues ids);
|
||||||
|
string id = ids.ToString();
|
||||||
|
|
||||||
|
if (user.SubscribedChannels.Contains(id))
|
||||||
|
return StatusCode((int)HttpStatusCode.NotModified);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
YoutubeChannel channel = await _youtube.GetChannelAsync(id);
|
||||||
|
|
||||||
|
if (channel.Id is null)
|
||||||
|
return StatusCode((int)HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
(LTChannel ltChannel, bool _) = await DatabaseManager.Logins.SubscribeToChannel(user, channel);
|
||||||
|
|
||||||
|
XmlDocument doc = new();
|
||||||
|
doc.AppendChild(ltChannel.GetXmlElement(doc));
|
||||||
|
return Xml(doc, HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return Xml(BuildErrorXml(e.Message), HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete]
|
||||||
|
[Route("subscriptions/channels")]
|
||||||
|
public async Task<IActionResult> Unsubscribe()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "api.subscriptions.write"))
|
||||||
|
return Xml(BuildErrorXml("Unauthorized"), HttpStatusCode.Unauthorized);
|
||||||
|
|
||||||
|
Request.Form.TryGetValue("id", out StringValues ids);
|
||||||
|
string id = ids.ToString();
|
||||||
|
|
||||||
|
if (!user.SubscribedChannels.Contains(id))
|
||||||
|
return StatusCode((int)HttpStatusCode.NotModified);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
YoutubeChannel channel = await _youtube.GetChannelAsync(id);
|
||||||
|
|
||||||
|
if (channel.Id is null)
|
||||||
|
return StatusCode((int)HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
(LTChannel ltChannel, bool _) = await DatabaseManager.Logins.SubscribeToChannel(user, channel);
|
||||||
|
|
||||||
|
XmlDocument doc = new();
|
||||||
|
doc.AppendChild(ltChannel.GetXmlElement(doc));
|
||||||
|
return Xml(doc, HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return Xml(BuildErrorXml(e.Message), HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
104
core/LightTube/Controllers/FeedController.cs
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LightTube.Contexts;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using InnerTube;
|
||||||
|
using LightTube.Database;
|
||||||
|
|
||||||
|
namespace LightTube.Controllers
|
||||||
|
{
|
||||||
|
[Route("/feed")]
|
||||||
|
public class FeedController : Controller
|
||||||
|
{
|
||||||
|
private readonly ILogger<FeedController> _logger;
|
||||||
|
private readonly Youtube _youtube;
|
||||||
|
|
||||||
|
public FeedController(ILogger<FeedController> logger, Youtube youtube)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_youtube = youtube;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("subscriptions")]
|
||||||
|
public async Task<IActionResult> Subscriptions()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||||
|
return Redirect("/Account/Login");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FeedContext context = new()
|
||||||
|
{
|
||||||
|
Channels = user.SubscribedChannels.Select(DatabaseManager.Channels.GetChannel).ToArray(),
|
||||||
|
Videos = await YoutubeRSS.GetMultipleFeeds(user.SubscribedChannels),
|
||||||
|
RssToken = user.RssToken,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
};
|
||||||
|
Array.Sort(context.Channels, (p, q) => string.Compare(p.Name, q.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return View(context);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
HttpContext.Response.Cookies.Delete("token");
|
||||||
|
return Redirect("/Account/Login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("channels")]
|
||||||
|
public IActionResult Channels()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||||
|
return Redirect("/Account/Login");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FeedContext context = new()
|
||||||
|
{
|
||||||
|
Channels = user.SubscribedChannels.Select(DatabaseManager.Channels.GetChannel).ToArray(),
|
||||||
|
Videos = null,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
};
|
||||||
|
Array.Sort(context.Channels, (p, q) => string.Compare(p.Name, q.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return View(context);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
HttpContext.Response.Cookies.Delete("token");
|
||||||
|
return Redirect("/Account/Login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("explore")]
|
||||||
|
public IActionResult Explore()
|
||||||
|
{
|
||||||
|
return View(new BaseContext
|
||||||
|
{
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/feed/library")]
|
||||||
|
public async Task<IActionResult> Playlists()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||||
|
Redirect("/Account/Login");
|
||||||
|
|
||||||
|
return View(new PlaylistsContext
|
||||||
|
{
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request),
|
||||||
|
Playlists = await DatabaseManager.Playlists.GetUserPlaylists(user.UserID)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/rss")]
|
||||||
|
public async Task<IActionResult> Playlists(string token, int limit = 15)
|
||||||
|
{
|
||||||
|
if (!DatabaseManager.TryGetRssUser(token, out LTUser user))
|
||||||
|
return Unauthorized();
|
||||||
|
return File(Encoding.UTF8.GetBytes(await user.GenerateRssFeed(Request.Host.ToString(), Math.Clamp(limit, 0, 50))), "application/xml");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
core/LightTube/Controllers/HomeController.cs
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LightTube.Contexts;
|
||||||
|
using LightTube.Models;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using InnerTube;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using ErrorContext = LightTube.Contexts.ErrorContext;
|
||||||
|
|
||||||
|
namespace LightTube.Controllers
|
||||||
|
{
|
||||||
|
public class HomeController : Controller
|
||||||
|
{
|
||||||
|
private readonly ILogger<HomeController> _logger;
|
||||||
|
private readonly Youtube _youtube;
|
||||||
|
|
||||||
|
public HomeController(ILogger<HomeController> logger, Youtube youtube)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_youtube = youtube;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IActionResult Index()
|
||||||
|
{
|
||||||
|
return View(new BaseContext
|
||||||
|
{
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||||
|
public IActionResult Error()
|
||||||
|
{
|
||||||
|
return View(new ErrorContext
|
||||||
|
{
|
||||||
|
Path = HttpContext.Features.Get<IExceptionHandlerPathFeature>().Path,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
62
core/LightTube/Controllers/ManifestController.cs
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using InnerTube;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace LightTube.Controllers
|
||||||
|
{
|
||||||
|
[Route("/manifest")]
|
||||||
|
public class ManifestController : Controller
|
||||||
|
{
|
||||||
|
private readonly Youtube _youtube;
|
||||||
|
private readonly HttpClient _client = new();
|
||||||
|
|
||||||
|
public ManifestController(Youtube youtube)
|
||||||
|
{
|
||||||
|
_youtube = youtube;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("{v}")]
|
||||||
|
public async Task<IActionResult> DefaultManifest(string v)
|
||||||
|
{
|
||||||
|
YoutubePlayer player = await _youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||||
|
return StatusCode(500, player.ErrorMessage);
|
||||||
|
return Redirect(player.IsLive ? $"/manifest/{v}.m3u8" : $"/manifest/{v}.mpd" + Request.QueryString);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("{v}.mpd")]
|
||||||
|
public async Task<IActionResult> DashManifest(string v, string videoCodec = null, string audioCodec = null, bool useProxy = true)
|
||||||
|
{
|
||||||
|
YoutubePlayer player = await _youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||||
|
string manifest = player.GetMpdManifest(useProxy ? $"https://{Request.Host}/proxy/" : null, videoCodec, audioCodec);
|
||||||
|
return File(Encoding.UTF8.GetBytes(manifest), "application/dash+xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("{v}.m3u8")]
|
||||||
|
public async Task<IActionResult> HlsManifest(string v, bool useProxy = true)
|
||||||
|
{
|
||||||
|
YoutubePlayer player = await _youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion(), true);
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||||
|
return StatusCode(403, player.ErrorMessage);
|
||||||
|
|
||||||
|
if (player.IsLive)
|
||||||
|
{
|
||||||
|
string manifest = await player.GetHlsManifest(useProxy ? $"https://{Request.Host}/proxy" : null);
|
||||||
|
return File(Encoding.UTF8.GetBytes(manifest), "application/vnd.apple.mpegurl");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useProxy)
|
||||||
|
return StatusCode(400, "HLS proxy for non-live videos are not supported at the moment.");
|
||||||
|
return Redirect(player.HlsManifestUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
517
core/LightTube/Controllers/ProxyController.cs
Normal file
|
@ -0,0 +1,517 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Web;
|
||||||
|
using InnerTube;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace LightTube.Controllers
|
||||||
|
{
|
||||||
|
[Route("/proxy")]
|
||||||
|
public class ProxyController : Controller
|
||||||
|
{
|
||||||
|
private readonly ILogger<YoutubeController> _logger;
|
||||||
|
private readonly Youtube _youtube;
|
||||||
|
private string[] BlockedHeaders =
|
||||||
|
{
|
||||||
|
"host",
|
||||||
|
"cookies"
|
||||||
|
};
|
||||||
|
|
||||||
|
public ProxyController(ILogger<YoutubeController> logger, Youtube youtube)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_youtube = youtube;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("media/{videoId}/{formatId}")]
|
||||||
|
public async Task Media(string videoId, string formatId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(player.ErrorMessage));
|
||||||
|
await Response.StartAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Format> formats = new();
|
||||||
|
|
||||||
|
formats.AddRange(player.Formats);
|
||||||
|
formats.AddRange(player.AdaptiveFormats);
|
||||||
|
|
||||||
|
if (!formats.Any(x => x.FormatId == formatId))
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(
|
||||||
|
$"Format with ID {formatId} not found.\nAvailable IDs are: {string.Join(", ", formats.Select(x => x.FormatId.ToString()))}"));
|
||||||
|
await Response.StartAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string url = formats.First(x => x.FormatId == formatId).Url;
|
||||||
|
|
||||||
|
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||||
|
url = "https://" + url;
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest) WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
request.Method = Request.Method;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
HttpWebResponse response;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = (HttpWebResponse) request.GetResponse();
|
||||||
|
}
|
||||||
|
catch (WebException e)
|
||||||
|
{
|
||||||
|
response = e.Response as HttpWebResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response == null)
|
||||||
|
await Response.StartAsync();
|
||||||
|
|
||||||
|
foreach (string header in response.Headers.AllKeys)
|
||||||
|
if (Response.Headers.ContainsKey(header))
|
||||||
|
Response.Headers[header] = response.Headers.Get(header);
|
||||||
|
else
|
||||||
|
Response.Headers.Add(header, response.Headers.Get(header));
|
||||||
|
Response.StatusCode = (int) response.StatusCode;
|
||||||
|
|
||||||
|
await using Stream stream = response.GetResponseStream();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stream.CopyToAsync(Response.Body, HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// an exception is thrown if the client suddenly stops streaming
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response.StartAsync();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(e.ToString()));
|
||||||
|
await Response.StartAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("download/{videoId}/{formatId}/{filename}")]
|
||||||
|
public async Task Download(string videoId, string formatId, string filename)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(player.ErrorMessage));
|
||||||
|
await Response.StartAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Format> formats = new();
|
||||||
|
|
||||||
|
formats.AddRange(player.Formats);
|
||||||
|
formats.AddRange(player.AdaptiveFormats);
|
||||||
|
|
||||||
|
if (!formats.Any(x => x.FormatId == formatId))
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(
|
||||||
|
$"Format with ID {formatId} not found.\nAvailable IDs are: {string.Join(", ", formats.Select(x => x.FormatId.ToString()))}"));
|
||||||
|
await Response.StartAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string url = formats.First(x => x.FormatId == formatId).Url;
|
||||||
|
|
||||||
|
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||||
|
url = "https://" + url;
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest) WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
request.Method = Request.Method;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
HttpWebResponse response;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = (HttpWebResponse) request.GetResponse();
|
||||||
|
}
|
||||||
|
catch (WebException e)
|
||||||
|
{
|
||||||
|
response = e.Response as HttpWebResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response == null)
|
||||||
|
await Response.StartAsync();
|
||||||
|
|
||||||
|
foreach (string header in response.Headers.AllKeys)
|
||||||
|
if (Response.Headers.ContainsKey(header))
|
||||||
|
Response.Headers[header] = response.Headers.Get(header);
|
||||||
|
else
|
||||||
|
Response.Headers.Add(header, response.Headers.Get(header));
|
||||||
|
Response.Headers.Add("Content-Disposition", $"attachment; filename=\"{Regex.Replace(filename, @"[^\u0000-\u007F]+", string.Empty)}\"");
|
||||||
|
Response.StatusCode = (int) response.StatusCode;
|
||||||
|
|
||||||
|
await using Stream stream = response.GetResponseStream();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stream.CopyToAsync(Response.Body, HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// an exception is thrown if the client suddenly stops streaming
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response.StartAsync();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(e.ToString()));
|
||||||
|
await Response.StartAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("caption/{videoId}/{language}")]
|
||||||
|
public async Task<FileStreamResult> SubtitleProxy(string videoId, string language)
|
||||||
|
{
|
||||||
|
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||||
|
return File(new MemoryStream(Encoding.UTF8.GetBytes(player.ErrorMessage)),
|
||||||
|
"text/plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
string url = null;
|
||||||
|
Subtitle? subtitle = player.Subtitles.FirstOrDefault(x => string.Equals(x.Language, language, StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
if (subtitle is null)
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||||
|
return File(
|
||||||
|
new MemoryStream(Encoding.UTF8.GetBytes(
|
||||||
|
$"There are no available subtitles for {language}. Available language codes are: {string.Join(", ", player.Subtitles.Select(x => $"\"{x.Language}\""))}")),
|
||||||
|
"text/plain");
|
||||||
|
}
|
||||||
|
url = subtitle.Url.Replace("fmt=srv3", "fmt=vtt");
|
||||||
|
|
||||||
|
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||||
|
url = "https://" + url;
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||||
|
|
||||||
|
await using Stream stream = response.GetResponseStream();
|
||||||
|
using StreamReader reader = new(stream);
|
||||||
|
|
||||||
|
return File(new MemoryStream(Encoding.UTF8.GetBytes(await reader.ReadToEndAsync())),
|
||||||
|
"text/vtt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("image")]
|
||||||
|
[Obsolete("Use /proxy/thumbnail instead")]
|
||||||
|
public async Task ImageProxy(string url)
|
||||||
|
{
|
||||||
|
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||||
|
url = "https://" + url;
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||||
|
|
||||||
|
foreach (string header in response.Headers.AllKeys)
|
||||||
|
if (Response.Headers.ContainsKey(header))
|
||||||
|
Response.Headers[header] = response.Headers.Get(header);
|
||||||
|
else
|
||||||
|
Response.Headers.Add(header, response.Headers.Get(header));
|
||||||
|
Response.StatusCode = (int)response.StatusCode;
|
||||||
|
|
||||||
|
await using Stream stream = response.GetResponseStream();
|
||||||
|
await stream.CopyToAsync(Response.Body);
|
||||||
|
await Response.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("thumbnail/{videoId}/{index:int}")]
|
||||||
|
public async Task ThumbnailProxy(string videoId, int index = 0)
|
||||||
|
{
|
||||||
|
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||||
|
if (index == -1) index = player.Thumbnails.Length - 1;
|
||||||
|
if (index >= player.Thumbnails.Length)
|
||||||
|
{
|
||||||
|
Response.StatusCode = 404;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(
|
||||||
|
$"Cannot find thumbnail #{index} for {videoId}. The maximum quality is {player.Thumbnails.Length - 1}"));
|
||||||
|
await Response.StartAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string url = player.Thumbnails.FirstOrDefault()?.Url;
|
||||||
|
|
||||||
|
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||||
|
url = "https://" + url;
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||||
|
|
||||||
|
foreach (string header in response.Headers.AllKeys)
|
||||||
|
if (Response.Headers.ContainsKey(header))
|
||||||
|
Response.Headers[header] = response.Headers.Get(header);
|
||||||
|
else
|
||||||
|
Response.Headers.Add(header, response.Headers.Get(header));
|
||||||
|
Response.StatusCode = (int)response.StatusCode;
|
||||||
|
|
||||||
|
await using Stream stream = response.GetResponseStream();
|
||||||
|
await stream.CopyToAsync(Response.Body);
|
||||||
|
await Response.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("storyboard/{videoId}")]
|
||||||
|
public async Task StoryboardProxy(string videoId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(player.ErrorMessage));
|
||||||
|
await Response.StartAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player.Storyboards.Any())
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes("No usable storyboard found."));
|
||||||
|
await Response.StartAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string url = player.Storyboards.First();
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||||
|
|
||||||
|
foreach (string header in response.Headers.AllKeys)
|
||||||
|
if (Response.Headers.ContainsKey(header))
|
||||||
|
Response.Headers[header] = response.Headers.Get(header);
|
||||||
|
else
|
||||||
|
Response.Headers.Add(header, response.Headers.Get(header));
|
||||||
|
Response.StatusCode = (int)response.StatusCode;
|
||||||
|
|
||||||
|
await using Stream stream = response.GetResponseStream();
|
||||||
|
await stream.CopyToAsync(Response.Body);
|
||||||
|
await Response.StartAsync();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||||
|
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(e.ToString()));
|
||||||
|
await Response.StartAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("hls")]
|
||||||
|
public async Task<IActionResult> HlsProxy(string url)
|
||||||
|
{
|
||||||
|
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||||
|
url = "https://" + url;
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
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 ();
|
||||||
|
|
||||||
|
foreach (string s in manifest.Split("\n"))
|
||||||
|
{
|
||||||
|
// also check if proxy enabled
|
||||||
|
proxyManifest.AppendLine(!s.StartsWith("http")
|
||||||
|
? s
|
||||||
|
: $"https://{Request.Host}/proxy/video?url={HttpUtility.UrlEncode(s)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(new MemoryStream(Encoding.UTF8.GetBytes(proxyManifest.ToString())),
|
||||||
|
"application/vnd.apple.mpegurl");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("manifest/{videoId}")]
|
||||||
|
public async Task<IActionResult> ManifestProxy(string videoId, string formatId, bool useProxy = true)
|
||||||
|
{
|
||||||
|
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId, iOS: true);
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||||
|
return File(new MemoryStream(Encoding.UTF8.GetBytes(player.ErrorMessage)),
|
||||||
|
"text/plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.HlsManifestUrl == null)
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||||
|
return File(new MemoryStream(Encoding.UTF8.GetBytes("This video does not have an HLS manifest URL")),
|
||||||
|
"text/plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
string url = player.HlsManifestUrl;
|
||||||
|
|
||||||
|
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||||
|
url = "https://" + url;
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
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 ();
|
||||||
|
|
||||||
|
if (useProxy)
|
||||||
|
foreach (string s in manifest.Split("\n"))
|
||||||
|
{
|
||||||
|
// also check if proxy enabled
|
||||||
|
proxyManifest.AppendLine(!s.StartsWith("http")
|
||||||
|
? s
|
||||||
|
: $"https://{Request.Host}/proxy/ytmanifest?path=" + HttpUtility.UrlEncode(s[46..]));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
proxyManifest.Append(manifest);
|
||||||
|
|
||||||
|
return File(new MemoryStream(Encoding.UTF8.GetBytes(proxyManifest.ToString())),
|
||||||
|
"application/vnd.apple.mpegurl");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("ytmanifest")]
|
||||||
|
public async Task<IActionResult> YoutubeManifestProxy(string path)
|
||||||
|
{
|
||||||
|
string url = "https://manifest.googlevideo.com" + path;
|
||||||
|
StringBuilder sb = new();
|
||||||
|
|
||||||
|
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||||
|
url = "https://" + url;
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||||
|
|
||||||
|
await using Stream stream = response.GetResponseStream();
|
||||||
|
using StreamReader reader = new(stream);
|
||||||
|
string manifest = await reader.ReadToEndAsync();
|
||||||
|
|
||||||
|
foreach (string line in manifest.Split("\n"))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
sb.AppendLine();
|
||||||
|
else if (line.StartsWith("#"))
|
||||||
|
sb.AppendLine(line);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Uri u = new(line);
|
||||||
|
sb.AppendLine($"https://{Request.Host}/proxy/videoplayback?host={u.Host}&path={HttpUtility.UrlEncode(u.PathAndQuery)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())),
|
||||||
|
"application/vnd.apple.mpegurl");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("videoplayback")]
|
||||||
|
public async Task VideoPlaybackProxy(string path, string host)
|
||||||
|
{
|
||||||
|
// make sure this is only used in livestreams
|
||||||
|
|
||||||
|
string url = $"https://{host}{path}";
|
||||||
|
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||||
|
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||||
|
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||||
|
foreach (string value in values)
|
||||||
|
request.Headers.Add(header, value);
|
||||||
|
|
||||||
|
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||||
|
|
||||||
|
await using Stream stream = response.GetResponseStream();
|
||||||
|
|
||||||
|
Response.ContentType = "application/octet-stream";
|
||||||
|
await Response.StartAsync();
|
||||||
|
await stream.CopyToAsync(Response.Body, HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
67
core/LightTube/Controllers/TogglesController.cs
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace LightTube.Controllers
|
||||||
|
{
|
||||||
|
[Route("/toggles")]
|
||||||
|
public class TogglesController : Controller
|
||||||
|
{
|
||||||
|
[Route("theme")]
|
||||||
|
public IActionResult ToggleTheme(string redirectUrl)
|
||||||
|
{
|
||||||
|
if (Request.Cookies.TryGetValue("theme", out string theme))
|
||||||
|
Response.Cookies.Append("theme", theme switch
|
||||||
|
{
|
||||||
|
"light" => "dark",
|
||||||
|
"dark" => "light",
|
||||||
|
var _ => "dark"
|
||||||
|
}, new CookieOptions
|
||||||
|
{
|
||||||
|
Expires = DateTimeOffset.MaxValue
|
||||||
|
});
|
||||||
|
else
|
||||||
|
Response.Cookies.Append("theme", "light");
|
||||||
|
|
||||||
|
return Redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("compatibility")]
|
||||||
|
public IActionResult ToggleCompatibility(string redirectUrl)
|
||||||
|
{
|
||||||
|
if (Request.Cookies.TryGetValue("compatibility", out string compatibility))
|
||||||
|
Response.Cookies.Append("compatibility", compatibility switch
|
||||||
|
{
|
||||||
|
"true" => "false",
|
||||||
|
"false" => "true",
|
||||||
|
var _ => "true"
|
||||||
|
}, new CookieOptions
|
||||||
|
{
|
||||||
|
Expires = DateTimeOffset.MaxValue
|
||||||
|
});
|
||||||
|
else
|
||||||
|
Response.Cookies.Append("compatibility", "true");
|
||||||
|
|
||||||
|
return Redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("collapse_guide")]
|
||||||
|
public IActionResult ToggleCollapseGuide(string redirectUrl)
|
||||||
|
{
|
||||||
|
if (Request.Cookies.TryGetValue("minmode", out string minmode))
|
||||||
|
Response.Cookies.Append("minmode", minmode switch
|
||||||
|
{
|
||||||
|
"true" => "false",
|
||||||
|
"false" => "true",
|
||||||
|
var _ => "true"
|
||||||
|
}, new CookieOptions
|
||||||
|
{
|
||||||
|
Expires = DateTimeOffset.MaxValue
|
||||||
|
});
|
||||||
|
else
|
||||||
|
Response.Cookies.Append("minmode", "true");
|
||||||
|
|
||||||
|
return Redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
226
core/LightTube/Controllers/YoutubeController.cs
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LightTube.Contexts;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using InnerTube;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using LightTube.Database;
|
||||||
|
|
||||||
|
namespace LightTube.Controllers
|
||||||
|
{
|
||||||
|
public class YoutubeController : Controller
|
||||||
|
{
|
||||||
|
private readonly ILogger<YoutubeController> _logger;
|
||||||
|
private readonly Youtube _youtube;
|
||||||
|
|
||||||
|
public YoutubeController(ILogger<YoutubeController> logger, Youtube youtube)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_youtube = youtube;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/watch")]
|
||||||
|
public async Task<IActionResult> Watch(string v, string quality = null)
|
||||||
|
{
|
||||||
|
Task[] tasks = {
|
||||||
|
_youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||||
|
_youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||||
|
ReturnYouTubeDislike.GetDislikes(v)
|
||||||
|
};
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
bool cookieCompatibility = false;
|
||||||
|
if (Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||||
|
bool.TryParse(compatibilityString, out cookieCompatibility);
|
||||||
|
|
||||||
|
PlayerContext context = new()
|
||||||
|
{
|
||||||
|
Player = (tasks[0] as Task<YoutubePlayer>)?.Result,
|
||||||
|
Video = (tasks[1] as Task<YoutubeVideo>)?.Result,
|
||||||
|
Engagement = (tasks[2] as Task<YoutubeDislikes>)?.Result,
|
||||||
|
Resolution = quality ?? (tasks[0] as Task<YoutubePlayer>)?.Result.Formats.FirstOrDefault(x => x.FormatId != "17")?.FormatNote,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request),
|
||||||
|
CompatibilityMode = cookieCompatibility
|
||||||
|
};
|
||||||
|
return View(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/download")]
|
||||||
|
public async Task<IActionResult> Download(string v)
|
||||||
|
{
|
||||||
|
Task[] tasks = {
|
||||||
|
_youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||||
|
_youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||||
|
ReturnYouTubeDislike.GetDislikes(v)
|
||||||
|
};
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
bool cookieCompatibility = false;
|
||||||
|
if (Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||||
|
bool.TryParse(compatibilityString, out cookieCompatibility);
|
||||||
|
|
||||||
|
PlayerContext context = new()
|
||||||
|
{
|
||||||
|
Player = (tasks[0] as Task<YoutubePlayer>)?.Result,
|
||||||
|
Video = (tasks[1] as Task<YoutubeVideo>)?.Result,
|
||||||
|
Engagement = null,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request),
|
||||||
|
CompatibilityMode = cookieCompatibility
|
||||||
|
};
|
||||||
|
return View(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/embed/{v}")]
|
||||||
|
public async Task<IActionResult> Embed(string v, string quality = null, bool compatibility = false)
|
||||||
|
{
|
||||||
|
Task[] tasks = {
|
||||||
|
_youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||||
|
_youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||||
|
ReturnYouTubeDislike.GetDislikes(v)
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
|
||||||
|
bool cookieCompatibility = false;
|
||||||
|
if (Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||||
|
bool.TryParse(compatibilityString, out cookieCompatibility);
|
||||||
|
|
||||||
|
PlayerContext context = new()
|
||||||
|
{
|
||||||
|
Player = (tasks[0] as Task<YoutubePlayer>)?.Result,
|
||||||
|
Video = (tasks[1] as Task<YoutubeVideo>)?.Result,
|
||||||
|
Engagement = (tasks[2] as Task<YoutubeDislikes>)?.Result,
|
||||||
|
Resolution = quality ?? (tasks[0] as Task<YoutubePlayer>)?.Result.Formats.FirstOrDefault(x => x.FormatId != "17")?.FormatNote,
|
||||||
|
CompatibilityMode = compatibility || cookieCompatibility,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
};
|
||||||
|
return View(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/results")]
|
||||||
|
public async Task<IActionResult> Search(string search_query, string continuation = null)
|
||||||
|
{
|
||||||
|
SearchContext context = new()
|
||||||
|
{
|
||||||
|
Query = search_query,
|
||||||
|
ContinuationKey = continuation,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(search_query))
|
||||||
|
{
|
||||||
|
context.Results = await _youtube.SearchAsync(search_query, continuation, HttpContext.GetLanguage(),
|
||||||
|
HttpContext.GetRegion());
|
||||||
|
Response.Cookies.Append("search_query", search_query);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Results =
|
||||||
|
new YoutubeSearchResults
|
||||||
|
{
|
||||||
|
Refinements = Array.Empty<string>(),
|
||||||
|
EstimatedResults = 0,
|
||||||
|
Results = Array.Empty<DynamicItem>(),
|
||||||
|
ContinuationKey = null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return View(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/playlist")]
|
||||||
|
public async Task<IActionResult> Playlist(string list, string continuation = null, int? delete = null, string add = null, string remove = null)
|
||||||
|
{
|
||||||
|
HttpContext.TryGetUser(out LTUser user, "web");
|
||||||
|
|
||||||
|
YoutubePlaylist pl = list.StartsWith("LT-PL")
|
||||||
|
? await (await DatabaseManager.Playlists.GetPlaylist(list)).ToYoutubePlaylist()
|
||||||
|
: await _youtube.GetPlaylistAsync(list, continuation, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||||
|
|
||||||
|
string message = "";
|
||||||
|
|
||||||
|
if (list.StartsWith("LT-PL") && (await DatabaseManager.Playlists.GetPlaylist(list)).Visibility == PlaylistVisibility.PRIVATE && pl.Channel.Name != user?.UserID)
|
||||||
|
pl = new YoutubePlaylist
|
||||||
|
{
|
||||||
|
Id = null,
|
||||||
|
Title = "",
|
||||||
|
Description = "",
|
||||||
|
VideoCount = "",
|
||||||
|
ViewCount = "",
|
||||||
|
LastUpdated = "",
|
||||||
|
Thumbnail = Array.Empty<Thumbnail>(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = "",
|
||||||
|
Id = "",
|
||||||
|
SubscriberCount = "",
|
||||||
|
Avatars = Array.Empty<Thumbnail>()
|
||||||
|
},
|
||||||
|
Videos = Array.Empty<DynamicItem>(),
|
||||||
|
ContinuationKey = null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(pl.Title)) message = "Playlist unavailable";
|
||||||
|
|
||||||
|
if (list.StartsWith("LT-PL") && pl.Channel.Name == user?.UserID)
|
||||||
|
{
|
||||||
|
if (delete != null)
|
||||||
|
{
|
||||||
|
LTVideo removed = await DatabaseManager.Playlists.RemoveVideoFromPlaylist(list, delete.Value);
|
||||||
|
message += $"Removed video '{removed.Title}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (add != null)
|
||||||
|
{
|
||||||
|
LTVideo added = await DatabaseManager.Playlists.AddVideoToPlaylist(list, add);
|
||||||
|
message += $"Added video '{added.Title}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(remove))
|
||||||
|
{
|
||||||
|
await DatabaseManager.Playlists.DeletePlaylist(list);
|
||||||
|
message = "Playlist deleted";
|
||||||
|
}
|
||||||
|
|
||||||
|
pl = await (await DatabaseManager.Playlists.GetPlaylist(list)).ToYoutubePlaylist();
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistContext context = new()
|
||||||
|
{
|
||||||
|
Playlist = pl,
|
||||||
|
Id = list,
|
||||||
|
ContinuationToken = continuation,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request),
|
||||||
|
Message = message,
|
||||||
|
Editable = list.StartsWith("LT-PL") && pl.Channel.Name == user?.UserID
|
||||||
|
};
|
||||||
|
return View(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/channel/{id}")]
|
||||||
|
public async Task<IActionResult> Channel(string id, string continuation = null)
|
||||||
|
{
|
||||||
|
ChannelContext context = new()
|
||||||
|
{
|
||||||
|
Channel = await _youtube.GetChannelAsync(id, ChannelTabs.Videos, continuation, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||||
|
Id = id,
|
||||||
|
ContinuationToken = continuation,
|
||||||
|
MobileLayout = Utils.IsClientMobile(Request)
|
||||||
|
};
|
||||||
|
await DatabaseManager.Channels.UpdateChannel(context.Channel.Id, context.Channel.Name, context.Channel.Subscribers,
|
||||||
|
context.Channel.Avatars.First().Url.ToString());
|
||||||
|
return View(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("/shorts/{id}")]
|
||||||
|
public IActionResult Shorts(string id)
|
||||||
|
{
|
||||||
|
// yea no fuck shorts
|
||||||
|
return Redirect("/watch?v=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
46
core/LightTube/Database/ChannelManager.cs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
public class ChannelManager
|
||||||
|
{
|
||||||
|
private static IMongoCollection<LTChannel> _channelCacheCollection;
|
||||||
|
|
||||||
|
public ChannelManager(IMongoCollection<LTChannel> channelCacheCollection)
|
||||||
|
{
|
||||||
|
_channelCacheCollection = channelCacheCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LTChannel GetChannel(string id)
|
||||||
|
{
|
||||||
|
LTChannel res = _channelCacheCollection.FindSync(x => x.ChannelId == id).FirstOrDefault();
|
||||||
|
return res ?? new LTChannel
|
||||||
|
{
|
||||||
|
Name = "Unknown Channel",
|
||||||
|
ChannelId = id,
|
||||||
|
IconUrl = "",
|
||||||
|
Subscribers = ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LTChannel> UpdateChannel(string id, string name, string subscribers, string iconUrl)
|
||||||
|
{
|
||||||
|
LTChannel channel = new()
|
||||||
|
{
|
||||||
|
ChannelId = id,
|
||||||
|
Name = name,
|
||||||
|
Subscribers = subscribers,
|
||||||
|
IconUrl = iconUrl
|
||||||
|
};
|
||||||
|
if (channel.IconUrl is null && !string.IsNullOrWhiteSpace(GetChannel(id).IconUrl))
|
||||||
|
channel.IconUrl = GetChannel(id).IconUrl;
|
||||||
|
if (await _channelCacheCollection.CountDocumentsAsync(x => x.ChannelId == id) > 0)
|
||||||
|
await _channelCacheCollection.ReplaceOneAsync(x => x.ChannelId == id, channel);
|
||||||
|
else
|
||||||
|
await _channelCacheCollection.InsertOneAsync(channel);
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
154
core/LightTube/Database/DatabaseManager.cs
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using InnerTube;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
public static class DatabaseManager
|
||||||
|
{
|
||||||
|
public static readonly string ApiUaRegex = "LightTubeApiClient\\/([0-9.]*) ([\\S]+?)\\/([0-9.]*) \\(([\\s\\S]+?)\\)";
|
||||||
|
|
||||||
|
private static IMongoCollection<LTUser> _userCollection;
|
||||||
|
private static IMongoCollection<LTLogin> _tokenCollection;
|
||||||
|
private static IMongoCollection<LTChannel> _channelCacheCollection;
|
||||||
|
private static IMongoCollection<LTPlaylist> _playlistCollection;
|
||||||
|
private static IMongoCollection<LTVideo> _videoCacheCollection;
|
||||||
|
public static LoginManager Logins { get; private set; }
|
||||||
|
public static ChannelManager Channels { get; private set; }
|
||||||
|
public static PlaylistManager Playlists { get; private set; }
|
||||||
|
|
||||||
|
public static void Init(string connstr, Youtube youtube)
|
||||||
|
{
|
||||||
|
MongoClient client = new(connstr);
|
||||||
|
IMongoDatabase database = client.GetDatabase("lighttube");
|
||||||
|
_userCollection = database.GetCollection<LTUser>("users");
|
||||||
|
_tokenCollection = database.GetCollection<LTLogin>("tokens");
|
||||||
|
_playlistCollection = database.GetCollection<LTPlaylist>("playlists");
|
||||||
|
_channelCacheCollection = database.GetCollection<LTChannel>("channelCache");
|
||||||
|
_videoCacheCollection = database.GetCollection<LTVideo>("videoCache");
|
||||||
|
Logins = new LoginManager(_userCollection, _tokenCollection);
|
||||||
|
Channels = new ChannelManager(_channelCacheCollection);
|
||||||
|
Playlists = new PlaylistManager(_userCollection, _playlistCollection, _videoCacheCollection, youtube);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CreateLocalAccount(this HttpContext context)
|
||||||
|
{
|
||||||
|
bool accountExists = false;
|
||||||
|
|
||||||
|
// Check local account
|
||||||
|
if (context.Request.Cookies.TryGetValue("account_data", out string accountJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (accountJson != null)
|
||||||
|
{
|
||||||
|
LTUser tempUser = JsonConvert.DeserializeObject<LTUser>(accountJson) ?? new LTUser();
|
||||||
|
if (tempUser.UserID == "Local Account" && tempUser.PasswordHash == "local_account")
|
||||||
|
accountExists = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account already exists, just leave it there
|
||||||
|
if (accountExists) return;
|
||||||
|
|
||||||
|
LTUser user = new()
|
||||||
|
{
|
||||||
|
UserID = "Local Account",
|
||||||
|
PasswordHash = "local_account",
|
||||||
|
SubscribedChannels = new List<string>()
|
||||||
|
};
|
||||||
|
|
||||||
|
context.Response.Cookies.Append("account_data", JsonConvert.SerializeObject(user), new CookieOptions
|
||||||
|
{
|
||||||
|
Expires = DateTimeOffset.MaxValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetUser(this HttpContext context, out LTUser user, string requiredScope)
|
||||||
|
{
|
||||||
|
// Check local account
|
||||||
|
if (context.Request.Cookies.TryGetValue("account_data", out string accountJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (accountJson != null)
|
||||||
|
{
|
||||||
|
LTUser tempUser = JsonConvert.DeserializeObject<LTUser>(accountJson) ?? new LTUser();
|
||||||
|
if (tempUser.UserID == "Local Account" && tempUser.PasswordHash == "local_account")
|
||||||
|
{
|
||||||
|
user = tempUser;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
user = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cloud account
|
||||||
|
if (!context.Request.Cookies.TryGetValue("token", out string token))
|
||||||
|
if (context.Request.Headers.TryGetValue("Authorization", out StringValues tokens))
|
||||||
|
token = tokens.ToString();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
user = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (token != null)
|
||||||
|
{
|
||||||
|
user = Logins.GetUserFromToken(token).Result;
|
||||||
|
LTLogin login = Logins.GetLoginFromToken(token).Result;
|
||||||
|
if (login.Scopes.Contains(requiredScope))
|
||||||
|
{
|
||||||
|
#pragma warning disable 4014
|
||||||
|
login.UpdateLastAccess(DateTimeOffset.Now);
|
||||||
|
#pragma warning restore 4014
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
user = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
user = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetRssUser(string token, out LTUser user)
|
||||||
|
{
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
user = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
user = Logins.GetUserFromRssToken(token).Result;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
user = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
31
core/LightTube/Database/LTChannel.cs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
using System.Xml;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
[BsonIgnoreExtraElements]
|
||||||
|
public class LTChannel
|
||||||
|
{
|
||||||
|
public string ChannelId;
|
||||||
|
public string Name;
|
||||||
|
public string Subscribers;
|
||||||
|
public string IconUrl;
|
||||||
|
|
||||||
|
public XmlNode GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Channel");
|
||||||
|
item.SetAttribute("id", ChannelId);
|
||||||
|
item.SetAttribute("subscribers", Subscribers);
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Name");
|
||||||
|
title.InnerText = Name;
|
||||||
|
item.AppendChild(title);
|
||||||
|
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Avatar");
|
||||||
|
thumbnail.InnerText = IconUrl;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
84
core/LightTube/Database/LTLogin.cs
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Web;
|
||||||
|
using System.Xml;
|
||||||
|
using Humanizer;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
using MyCSharp.HttpUserAgentParser;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
[BsonIgnoreExtraElements]
|
||||||
|
public class LTLogin
|
||||||
|
{
|
||||||
|
public string Identifier;
|
||||||
|
public string Email;
|
||||||
|
public string Token;
|
||||||
|
public string UserAgent;
|
||||||
|
public string[] Scopes;
|
||||||
|
public DateTimeOffset Created = DateTimeOffset.MinValue;
|
||||||
|
public DateTimeOffset LastSeen = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
public XmlDocument GetXmlElement()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement login = doc.CreateElement("Login");
|
||||||
|
login.SetAttribute("id", Identifier);
|
||||||
|
login.SetAttribute("user", Email);
|
||||||
|
|
||||||
|
XmlElement token = doc.CreateElement("Token");
|
||||||
|
token.InnerText = Token;
|
||||||
|
login.AppendChild(token);
|
||||||
|
|
||||||
|
XmlElement scopes = doc.CreateElement("Scopes");
|
||||||
|
foreach (string scope in Scopes)
|
||||||
|
{
|
||||||
|
XmlElement scopeElement = doc.CreateElement("Scope");
|
||||||
|
scopeElement.InnerText = scope;
|
||||||
|
login.AppendChild(scopeElement);
|
||||||
|
}
|
||||||
|
login.AppendChild(scopes);
|
||||||
|
|
||||||
|
doc.AppendChild(login);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetTitle()
|
||||||
|
{
|
||||||
|
Match match = Regex.Match(UserAgent, DatabaseManager.ApiUaRegex);
|
||||||
|
if (match.Success)
|
||||||
|
return $"API App: {match.Groups[2]} {match.Groups[3]}";
|
||||||
|
|
||||||
|
HttpUserAgentInformation client = HttpUserAgentParser.Parse(UserAgent);
|
||||||
|
StringBuilder sb = new($"{client.Name} {client.Version}");
|
||||||
|
if (client.Platform.HasValue)
|
||||||
|
sb.Append($" on {client.Platform.Value.PlatformType.ToString()}");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetDescription()
|
||||||
|
{
|
||||||
|
StringBuilder sb = new();
|
||||||
|
sb.AppendLine($"Created: {Created.Humanize(DateTimeOffset.Now)}");
|
||||||
|
sb.AppendLine($"Last seen: {LastSeen.Humanize(DateTimeOffset.Now)}");
|
||||||
|
|
||||||
|
Match match = Regex.Match(UserAgent, DatabaseManager.ApiUaRegex);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"API version: {HttpUtility.HtmlEncode(match.Groups[1])}");
|
||||||
|
sb.AppendLine($"App info: {HttpUtility.HtmlEncode(match.Groups[4])}");
|
||||||
|
sb.AppendLine("Allowed scopes:");
|
||||||
|
foreach (string scope in Scopes) sb.AppendLine($"- {scope}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateLastAccess(DateTimeOffset newTime)
|
||||||
|
{
|
||||||
|
await DatabaseManager.Logins.UpdateLastAccess(Identifier, newTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
68
core/LightTube/Database/LTPlaylist.cs
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using InnerTube.Models;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
public class LTPlaylist
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public string Name;
|
||||||
|
public string Description;
|
||||||
|
public PlaylistVisibility Visibility;
|
||||||
|
public List<string> VideoIds;
|
||||||
|
public string Author;
|
||||||
|
public DateTimeOffset LastUpdated;
|
||||||
|
|
||||||
|
public async Task<YoutubePlaylist> ToYoutubePlaylist()
|
||||||
|
{
|
||||||
|
List<Thumbnail> t = new();
|
||||||
|
if (VideoIds.Count > 0)
|
||||||
|
t.Add(new Thumbnail { Url = $"https://i.ytimg.com/vi_webp/{VideoIds.First()}/maxresdefault.webp" });
|
||||||
|
YoutubePlaylist playlist = new()
|
||||||
|
{
|
||||||
|
Id = Id,
|
||||||
|
Title = Name,
|
||||||
|
Description = Description,
|
||||||
|
VideoCount = VideoIds.Count.ToString(),
|
||||||
|
ViewCount = "0",
|
||||||
|
LastUpdated = "Last updated " + LastUpdated.ToString("MMMM dd, yyyy"),
|
||||||
|
Thumbnail = t.ToArray(),
|
||||||
|
Channel = new Channel
|
||||||
|
{
|
||||||
|
Name = Author,
|
||||||
|
Id = GenerateChannelId(),
|
||||||
|
SubscriberCount = "0 subscribers",
|
||||||
|
Avatars = Array.Empty<Thumbnail>()
|
||||||
|
},
|
||||||
|
Videos = (await DatabaseManager.Playlists.GetPlaylistVideos(Id)).Select(x =>
|
||||||
|
{
|
||||||
|
x.Index = VideoIds.IndexOf(x.Id) + 1;
|
||||||
|
return x;
|
||||||
|
}).Cast<DynamicItem>().ToArray(),
|
||||||
|
ContinuationKey = null
|
||||||
|
};
|
||||||
|
return playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateChannelId()
|
||||||
|
{
|
||||||
|
StringBuilder sb = new("LTU-" + Author.Trim() + "_");
|
||||||
|
|
||||||
|
string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||||
|
Random rng = new(Author.GetHashCode());
|
||||||
|
while (sb.Length < 32) sb.Append(alphabet[rng.Next(0, alphabet.Length)]);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PlaylistVisibility
|
||||||
|
{
|
||||||
|
PRIVATE,
|
||||||
|
UNLISTED,
|
||||||
|
VISIBLE
|
||||||
|
}
|
||||||
|
}
|
102
core/LightTube/Database/LTUser.cs
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
[BsonIgnoreExtraElements]
|
||||||
|
public class LTUser
|
||||||
|
{
|
||||||
|
public string UserID;
|
||||||
|
public string PasswordHash;
|
||||||
|
public List<string> SubscribedChannels;
|
||||||
|
public bool ApiAccess;
|
||||||
|
public string RssToken;
|
||||||
|
|
||||||
|
public async Task<string> GenerateRssFeed(string hostUrl, int limit)
|
||||||
|
{
|
||||||
|
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 subscriptions RSS feed for " + UserID;
|
||||||
|
channel.AppendChild(title);
|
||||||
|
|
||||||
|
XmlElement description = document.CreateElement("description");
|
||||||
|
description.InnerText = $"LightTube subscriptions RSS feed for {UserID} with {SubscribedChannels.Count} channels";
|
||||||
|
channel.AppendChild(description);
|
||||||
|
|
||||||
|
FeedVideo[] feeds = await YoutubeRSS.GetMultipleFeeds(SubscribedChannels);
|
||||||
|
IEnumerable<FeedVideo> feedVideos = feeds.Take(limit);
|
||||||
|
|
||||||
|
foreach (FeedVideo video in feedVideos)
|
||||||
|
{
|
||||||
|
XmlElement item = document.CreateElement("item");
|
||||||
|
|
||||||
|
XmlElement id = document.CreateElement("id");
|
||||||
|
id.InnerText = $"id:video:{video.Id}";
|
||||||
|
item.AppendChild(id);
|
||||||
|
|
||||||
|
XmlElement vtitle = document.CreateElement("title");
|
||||||
|
vtitle.InnerText = video.Title;
|
||||||
|
item.AppendChild(vtitle);
|
||||||
|
|
||||||
|
XmlElement vdescription = document.CreateElement("description");
|
||||||
|
vdescription.InnerText = video.Description;
|
||||||
|
item.AppendChild(vdescription);
|
||||||
|
|
||||||
|
XmlElement link = document.CreateElement("link");
|
||||||
|
link.InnerText = $"https://{hostUrl}/watch?v={video.Id}";
|
||||||
|
item.AppendChild(link);
|
||||||
|
|
||||||
|
XmlElement published = document.CreateElement("pubDate");
|
||||||
|
published.InnerText = video.PublishedDate.ToString("R");
|
||||||
|
item.AppendChild(published);
|
||||||
|
|
||||||
|
XmlElement author = document.CreateElement("author");
|
||||||
|
|
||||||
|
XmlElement name = document.CreateElement("name");
|
||||||
|
name.InnerText = video.ChannelName;
|
||||||
|
author.AppendChild(name);
|
||||||
|
|
||||||
|
XmlElement uri = document.CreateElement("uri");
|
||||||
|
uri.InnerText = $"https://{hostUrl}/channel/{video.ChannelId}";
|
||||||
|
author.AppendChild(uri);
|
||||||
|
|
||||||
|
item.AppendChild(author);
|
||||||
|
/*
|
||||||
|
XmlElement mediaGroup = document.CreateElement("media_group");
|
||||||
|
|
||||||
|
XmlElement mediaTitle = document.CreateElement("media_title");
|
||||||
|
mediaTitle.InnerText = video.Title;
|
||||||
|
mediaGroup.AppendChild(mediaTitle);
|
||||||
|
|
||||||
|
XmlElement mediaThumbnail = document.CreateElement("media_thumbnail");
|
||||||
|
mediaThumbnail.SetAttribute("url", video.Thumbnail);
|
||||||
|
mediaGroup.AppendChild(mediaThumbnail);
|
||||||
|
|
||||||
|
XmlElement mediaContent = document.CreateElement("media_content");
|
||||||
|
mediaContent.SetAttribute("url", $"https://{hostUrl}/embed/{video.Id}");
|
||||||
|
mediaContent.SetAttribute("type", "text/html");
|
||||||
|
mediaGroup.AppendChild(mediaContent);
|
||||||
|
|
||||||
|
item.AppendChild(mediaGroup);
|
||||||
|
*/
|
||||||
|
channel.AppendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
rss.AppendChild(channel);
|
||||||
|
|
||||||
|
document.AppendChild(rss);
|
||||||
|
return document.OuterXml;//.Replace("<media_", "<media:").Replace("</media_", "</media:");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
39
core/LightTube/Database/LTVideo.cs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
using System;
|
||||||
|
using System.Xml;
|
||||||
|
using InnerTube.Models;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
public class LTVideo : PlaylistVideoItem
|
||||||
|
{
|
||||||
|
public string UploadedAt;
|
||||||
|
public long Views;
|
||||||
|
|
||||||
|
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Video");
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
item.SetAttribute("duration", Duration);
|
||||||
|
item.SetAttribute("views", Views.ToString());
|
||||||
|
item.SetAttribute("uploadedAt", UploadedAt);
|
||||||
|
item.SetAttribute("index", Index.ToString());
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
if (Channel is not null)
|
||||||
|
item.AppendChild(Channel.GetXmlElement(doc));
|
||||||
|
|
||||||
|
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||||
|
{
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||||
|
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||||
|
thumbnail.InnerText = t.Url;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
172
core/LightTube/Database/LoginManager.cs
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Data;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
public class LoginManager
|
||||||
|
{
|
||||||
|
private IMongoCollection<LTUser> _userCollection;
|
||||||
|
private IMongoCollection<LTLogin> _tokenCollection;
|
||||||
|
|
||||||
|
public LoginManager(IMongoCollection<LTUser> userCollection, IMongoCollection<LTLogin> tokenCollection)
|
||||||
|
{
|
||||||
|
_userCollection = userCollection;
|
||||||
|
_tokenCollection = tokenCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LTLogin> CreateToken(string email, string password, string userAgent, string[] scopes)
|
||||||
|
{
|
||||||
|
IAsyncCursor<LTUser> users = await _userCollection.FindAsync(x => x.UserID == email);
|
||||||
|
if (!await users.AnyAsync())
|
||||||
|
throw new UnauthorizedAccessException("Invalid credentials");
|
||||||
|
LTUser user = (await _userCollection.FindAsync(x => x.UserID == email)).First();
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||||
|
throw new UnauthorizedAccessException("Invalid credentials");
|
||||||
|
if (!scopes.Contains("web") && !user.ApiAccess)
|
||||||
|
throw new InvalidOperationException("This user has API access disabled");
|
||||||
|
|
||||||
|
LTLogin login = new()
|
||||||
|
{
|
||||||
|
Identifier = Guid.NewGuid().ToString(),
|
||||||
|
Email = email,
|
||||||
|
Token = GenerateToken(256),
|
||||||
|
UserAgent = userAgent,
|
||||||
|
Scopes = scopes.ToArray(),
|
||||||
|
Created = DateTimeOffset.Now,
|
||||||
|
LastSeen = DateTimeOffset.Now
|
||||||
|
};
|
||||||
|
await _tokenCollection.InsertOneAsync(login);
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateLastAccess(string id, DateTimeOffset offset)
|
||||||
|
{
|
||||||
|
LTLogin login = (await _tokenCollection.FindAsync(x => x.Identifier == id)).First();
|
||||||
|
login.LastSeen = offset;
|
||||||
|
await _tokenCollection.ReplaceOneAsync(x => x.Identifier == id, login);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveToken(string token)
|
||||||
|
{
|
||||||
|
await _tokenCollection.FindOneAndDeleteAsync(t => t.Token == token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveToken(string email, string password, string identifier)
|
||||||
|
{
|
||||||
|
IAsyncCursor<LTUser> users = await _userCollection.FindAsync(x => x.UserID == email);
|
||||||
|
if (!await users.AnyAsync())
|
||||||
|
throw new KeyNotFoundException("Invalid credentials");
|
||||||
|
LTUser user = (await _userCollection.FindAsync(x => x.UserID == email)).First();
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||||
|
throw new UnauthorizedAccessException("Invalid credentials");
|
||||||
|
|
||||||
|
await _tokenCollection.FindOneAndDeleteAsync(t => t.Identifier == identifier && t.Email == user.UserID);
|
||||||
|
}
|
||||||
|
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
public async Task RemoveTokenFromId(string sourceToken, string identifier)
|
||||||
|
{
|
||||||
|
LTLogin login = (await _tokenCollection.FindAsync(x => x.Token == sourceToken)).First();
|
||||||
|
LTLogin deletedLogin = (await _tokenCollection.FindAsync(x => x.Identifier == identifier)).First();
|
||||||
|
|
||||||
|
if (login.Email == deletedLogin.Email)
|
||||||
|
await _tokenCollection.FindOneAndDeleteAsync(t => t.Identifier == identifier);
|
||||||
|
else
|
||||||
|
throw new UnauthorizedAccessException(
|
||||||
|
"Logged in user does not match the token that is supposed to be deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LTUser> GetUserFromToken(string token)
|
||||||
|
{
|
||||||
|
string email = (await _tokenCollection.FindAsync(x => x.Token == token)).First().Email;
|
||||||
|
return (await _userCollection.FindAsync(u => u.UserID == email)).First();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LTUser> GetUserFromRssToken(string token) => (await _userCollection.FindAsync(u => u.RssToken == token)).First();
|
||||||
|
|
||||||
|
public async Task<LTLogin> GetLoginFromToken(string token)
|
||||||
|
{
|
||||||
|
var res = await _tokenCollection.FindAsync(x => x.Token == token);
|
||||||
|
return res.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<LTLogin>> GetAllUserTokens(string token)
|
||||||
|
{
|
||||||
|
string email = (await _tokenCollection.FindAsync(x => x.Token == token)).First().Email;
|
||||||
|
return await (await _tokenCollection.FindAsync(u => u.Email == email)).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetCurrentLoginId(string token)
|
||||||
|
{
|
||||||
|
return (await _tokenCollection.FindAsync(t => t.Token == token)).First().Identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(LTChannel channel, bool subscribed)> SubscribeToChannel(LTUser user, YoutubeChannel channel)
|
||||||
|
{
|
||||||
|
LTChannel ltChannel = await DatabaseManager.Channels.UpdateChannel(channel.Id, channel.Name, channel.Subscribers,
|
||||||
|
channel.Avatars.FirstOrDefault()?.Url);
|
||||||
|
|
||||||
|
if (user.SubscribedChannels.Contains(ltChannel.ChannelId))
|
||||||
|
user.SubscribedChannels.Remove(ltChannel.ChannelId);
|
||||||
|
else
|
||||||
|
user.SubscribedChannels.Add(ltChannel.ChannelId);
|
||||||
|
|
||||||
|
await _userCollection.ReplaceOneAsync(x => x.UserID == user.UserID, user);
|
||||||
|
return (ltChannel, user.SubscribedChannels.Contains(ltChannel.ChannelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetApiAccess(LTUser user, bool access)
|
||||||
|
{
|
||||||
|
user.ApiAccess = access;
|
||||||
|
await _userCollection.ReplaceOneAsync(x => x.UserID == user.UserID, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteUser(string email, string password)
|
||||||
|
{
|
||||||
|
IAsyncCursor<LTUser> users = await _userCollection.FindAsync(x => x.UserID == email);
|
||||||
|
if (!await users.AnyAsync())
|
||||||
|
throw new KeyNotFoundException("Invalid credentials");
|
||||||
|
LTUser user = (await _userCollection.FindAsync(x => x.UserID == email)).First();
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||||
|
throw new UnauthorizedAccessException("Invalid credentials");
|
||||||
|
|
||||||
|
await _userCollection.DeleteOneAsync(x => x.UserID == email);
|
||||||
|
await _tokenCollection.DeleteManyAsync(x => x.Email == email);
|
||||||
|
foreach (LTPlaylist pl in await DatabaseManager.Playlists.GetUserPlaylists(email))
|
||||||
|
await DatabaseManager.Playlists.DeletePlaylist(pl.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CreateUser(string email, string password)
|
||||||
|
{
|
||||||
|
IAsyncCursor<LTUser> users = await _userCollection.FindAsync(x => x.UserID == email);
|
||||||
|
if (await users.AnyAsync())
|
||||||
|
throw new DuplicateNameException("A user with that email already exists");
|
||||||
|
|
||||||
|
LTUser user = new()
|
||||||
|
{
|
||||||
|
UserID = email,
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||||
|
SubscribedChannels = new List<string>(),
|
||||||
|
RssToken = GenerateToken(32)
|
||||||
|
};
|
||||||
|
await _userCollection.InsertOneAsync(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
161
core/LightTube/Database/PlaylistManager.cs
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using InnerTube;
|
||||||
|
using InnerTube.Models;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
public class PlaylistManager
|
||||||
|
{
|
||||||
|
private IMongoCollection<LTUser> _userCollection;
|
||||||
|
private IMongoCollection<LTPlaylist> _playlistCollection;
|
||||||
|
private IMongoCollection<LTVideo> _videoCacheCollection;
|
||||||
|
private Youtube _youtube;
|
||||||
|
|
||||||
|
public PlaylistManager(IMongoCollection<LTUser> userCollection, IMongoCollection<LTPlaylist> playlistCollection,
|
||||||
|
IMongoCollection<LTVideo> videoCacheCollection, Youtube youtube)
|
||||||
|
{
|
||||||
|
_userCollection = userCollection;
|
||||||
|
_playlistCollection = playlistCollection;
|
||||||
|
_videoCacheCollection = videoCacheCollection;
|
||||||
|
_youtube = youtube;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LTPlaylist> CreatePlaylist(LTUser user, string name, string description,
|
||||||
|
PlaylistVisibility visibility, string idPrefix = null)
|
||||||
|
{
|
||||||
|
if (await _userCollection.CountDocumentsAsync(x => x.UserID == user.UserID) == 0)
|
||||||
|
throw new UnauthorizedAccessException("Local accounts cannot create playlists");
|
||||||
|
|
||||||
|
LTPlaylist pl = new()
|
||||||
|
{
|
||||||
|
Id = GenerateAuthorId(idPrefix),
|
||||||
|
Name = name,
|
||||||
|
Description = description,
|
||||||
|
Visibility = visibility,
|
||||||
|
VideoIds = new List<string>(),
|
||||||
|
Author = user.UserID,
|
||||||
|
LastUpdated = DateTimeOffset.Now
|
||||||
|
};
|
||||||
|
|
||||||
|
await _playlistCollection.InsertOneAsync(pl).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return pl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LTPlaylist> GetPlaylist(string id)
|
||||||
|
{
|
||||||
|
IAsyncCursor<LTPlaylist> cursor = await _playlistCollection.FindAsync(x => x.Id == id);
|
||||||
|
return await cursor.FirstOrDefaultAsync() ?? new LTPlaylist
|
||||||
|
{
|
||||||
|
Id = null,
|
||||||
|
Name = "",
|
||||||
|
Description = "",
|
||||||
|
Visibility = PlaylistVisibility.VISIBLE,
|
||||||
|
VideoIds = new List<string>(),
|
||||||
|
Author = "",
|
||||||
|
LastUpdated = DateTimeOffset.MinValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<LTVideo>> GetPlaylistVideos(string id)
|
||||||
|
{
|
||||||
|
LTPlaylist pl = await GetPlaylist(id);
|
||||||
|
List<LTVideo> videos = new();
|
||||||
|
|
||||||
|
foreach (string videoId in pl.VideoIds)
|
||||||
|
{
|
||||||
|
IAsyncCursor<LTVideo> cursor = await _videoCacheCollection.FindAsync(x => x.Id == videoId);
|
||||||
|
videos.Add(await cursor.FirstAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LTVideo> AddVideoToPlaylist(string playlistId, string videoId)
|
||||||
|
{
|
||||||
|
LTPlaylist pl = await GetPlaylist(playlistId);
|
||||||
|
YoutubeVideo vid = await _youtube.GetVideoAsync(videoId);
|
||||||
|
JObject ytPlayer = await InnerTube.Utils.GetAuthorizedPlayer(videoId, new HttpClient());
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(vid.Id))
|
||||||
|
throw new KeyNotFoundException($"Couldn't find a video with ID '{videoId}'");
|
||||||
|
|
||||||
|
LTVideo v = new()
|
||||||
|
{
|
||||||
|
Id = vid.Id,
|
||||||
|
Title = vid.Title,
|
||||||
|
Thumbnails = ytPlayer?["videoDetails"]?["thumbnail"]?["thumbnails"]?.ToObject<Thumbnail[]>() ?? new []
|
||||||
|
{
|
||||||
|
new Thumbnail { Url = $"https://i.ytimg.com/vi_webp/{vid.Id}/maxresdefault.webp" }
|
||||||
|
},
|
||||||
|
UploadedAt = vid.UploadDate,
|
||||||
|
Views = long.Parse(vid.Views.Split(" ")[0].Replace(",", "").Replace(".", "")),
|
||||||
|
Channel = vid.Channel,
|
||||||
|
Duration = GetDurationString(ytPlayer?["videoDetails"]?["lengthSeconds"]?.ToObject<long>() ?? 0),
|
||||||
|
Index = pl.VideoIds.Count
|
||||||
|
};
|
||||||
|
pl.VideoIds.Add(vid.Id);
|
||||||
|
|
||||||
|
if (await _videoCacheCollection.CountDocumentsAsync(x => x.Id == vid.Id) == 0)
|
||||||
|
await _videoCacheCollection.InsertOneAsync(v);
|
||||||
|
else
|
||||||
|
await _videoCacheCollection.FindOneAndReplaceAsync(x => x.Id == vid.Id, v);
|
||||||
|
|
||||||
|
UpdateDefinition<LTPlaylist> update = Builders<LTPlaylist>.Update
|
||||||
|
.Push(x => x.VideoIds, vid.Id);
|
||||||
|
_playlistCollection.FindOneAndUpdate(x => x.Id == playlistId, update);
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LTVideo> RemoveVideoFromPlaylist(string playlistId, int videoIndex)
|
||||||
|
{
|
||||||
|
LTPlaylist pl = await GetPlaylist(playlistId);
|
||||||
|
|
||||||
|
IAsyncCursor<LTVideo> cursor = await _videoCacheCollection.FindAsync(x => x.Id == pl.VideoIds[videoIndex]);
|
||||||
|
LTVideo v = await cursor.FirstAsync();
|
||||||
|
pl.VideoIds.RemoveAt(videoIndex);
|
||||||
|
|
||||||
|
await _playlistCollection.FindOneAndReplaceAsync(x => x.Id == playlistId, pl);
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<LTPlaylist>> GetUserPlaylists(string userId)
|
||||||
|
{
|
||||||
|
IAsyncCursor<LTPlaylist> cursor = await _playlistCollection.FindAsync(x => x.Author == userId);
|
||||||
|
|
||||||
|
return cursor.ToEnumerable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetDurationString(long length)
|
||||||
|
{
|
||||||
|
string s = TimeSpan.FromSeconds(length).ToString();
|
||||||
|
while (s.StartsWith("00:") && s.Length > 5) s = s[3..];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GenerateAuthorId(string prefix)
|
||||||
|
{
|
||||||
|
StringBuilder sb = new(string.IsNullOrWhiteSpace(prefix) || prefix.Trim().Length > 20
|
||||||
|
? "LT-PL"
|
||||||
|
: "LT-PL-" + prefix.Trim() + "_");
|
||||||
|
|
||||||
|
string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||||
|
Random rng = new();
|
||||||
|
while (sb.Length < 32) sb.Append(alphabet[rng.Next(0, alphabet.Length)]);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeletePlaylist(string playlistId)
|
||||||
|
{
|
||||||
|
await _playlistCollection.DeleteOneAsync(x => x.Id == playlistId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
core/LightTube/Database/SubscriptionChannels.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Xml;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
public class SubscriptionChannels
|
||||||
|
{
|
||||||
|
public LTChannel[] Channels { get; set; }
|
||||||
|
|
||||||
|
public XmlNode GetXmlDocument()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement feed = doc.CreateElement("Subscriptions");
|
||||||
|
foreach (LTChannel channel in Channels) feed.AppendChild(channel.GetXmlElement(doc));
|
||||||
|
doc.AppendChild(feed);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
core/LightTube/Database/SubscriptionFeed.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Xml;
|
||||||
|
|
||||||
|
namespace LightTube.Database
|
||||||
|
{
|
||||||
|
public class SubscriptionFeed
|
||||||
|
{
|
||||||
|
public FeedVideo[] videos;
|
||||||
|
|
||||||
|
public XmlDocument GetXmlDocument()
|
||||||
|
{
|
||||||
|
XmlDocument doc = new();
|
||||||
|
XmlElement feed = doc.CreateElement("Feed");
|
||||||
|
foreach (FeedVideo feedVideo in videos) feed.AppendChild(feedVideo.GetXmlElement(doc));
|
||||||
|
doc.AppendChild(feed);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
core/LightTube/LightTube.csproj
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net5.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\InnerTube\InnerTube.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.2" />
|
||||||
|
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||||
|
<PackageReference Include="MongoDB.Driver" Version="2.14.1" />
|
||||||
|
<PackageReference Include="MyCSharp.HttpUserAgentParser" Version="1.1.5" />
|
||||||
|
<PackageReference Include="YamlDotNet" Version="11.2.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
11
core/LightTube/Models/ErrorViewModel.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LightTube.Models
|
||||||
|
{
|
||||||
|
public class ErrorViewModel
|
||||||
|
{
|
||||||
|
public string RequestId { get; set; }
|
||||||
|
|
||||||
|
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||||
|
}
|
||||||
|
}
|
26
core/LightTube/Program.cs
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LightTube
|
||||||
|
{
|
||||||
|
public class Program
|
||||||
|
{
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
Configuration.LoadConfiguration();
|
||||||
|
InnerTube.Utils.SetAuthorization(Configuration.Instance.Credentials.CanUseAuthorizedEndpoints(),
|
||||||
|
Configuration.Instance.Credentials.Sapisid, Configuration.Instance.Credentials.Psid);
|
||||||
|
CreateHostBuilder(args).Build().Run();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IHostBuilder CreateHostBuilder(string[] args) =>
|
||||||
|
Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
|
||||||
|
}
|
||||||
|
}
|
60
core/LightTube/Views/Account/Account.cshtml
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
@using LightTube.Database
|
||||||
|
@using System.Web
|
||||||
|
@model LightTube.Contexts.BaseContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Title = "Account";
|
||||||
|
Layout = "_Layout";
|
||||||
|
|
||||||
|
Context.Request.Cookies.TryGetValue("theme", out string theme);
|
||||||
|
if (!new[] { "light", "dark" }.Contains(theme)) theme = "light";
|
||||||
|
|
||||||
|
string newTheme = theme switch {
|
||||||
|
"light" => "dark",
|
||||||
|
"dark" => "light",
|
||||||
|
var _ => "dark"
|
||||||
|
};
|
||||||
|
|
||||||
|
bool compatibility = false;
|
||||||
|
if (Context.Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||||
|
bool.TryParse(compatibilityString, out compatibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div>
|
||||||
|
<div class="fullscreen-account-menu">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<br>
|
||||||
|
<div class="guide-item">
|
||||||
|
<a href="/toggles/theme?redirectUrl=@(HttpUtility.UrlEncode($"{Context.Request.Path}{Context.Request.QueryString}"))">Switch to @(newTheme) theme</a>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
@if (Context.TryGetUser(out LTUser user, "web"))
|
||||||
|
{
|
||||||
|
<div class="guide-item">
|
||||||
|
<a href="/Account/Settings">Settings</a>
|
||||||
|
</div>
|
||||||
|
@if (user.PasswordHash != "local_account")
|
||||||
|
{
|
||||||
|
<div class="guide-item">
|
||||||
|
<a href="/Account/Logins">Active logins</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="guide-item">
|
||||||
|
<a href="/Account/Logout">Log out</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="guide-item">
|
||||||
|
<a href="/Account/Login">Log in</a>
|
||||||
|
</div>
|
||||||
|
<div class="guide-item">
|
||||||
|
<a href="/Account/Register">Register</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
</div>
|
||||||
|
</div>
|
52
core/LightTube/Views/Account/AddVideoToPlaylist.cshtml
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
@using System.Web
|
||||||
|
@using LightTube.Database
|
||||||
|
@model LightTube.Contexts.AddToPlaylistContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Metadata = new Dictionary<string, string>();
|
||||||
|
ViewBag.Metadata["author"] = Model.Video.Channel.Name;
|
||||||
|
ViewBag.Metadata["og:title"] = Model.Video.Title;
|
||||||
|
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||||
|
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Thumbnail)}";
|
||||||
|
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Thumbnail)}";
|
||||||
|
ViewBag.Title = Model.Video.Title;
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="playlist-page">
|
||||||
|
<div class="playlist-info">
|
||||||
|
<div class="thumbnail" style="background-image: url('@Model.Thumbnail')">
|
||||||
|
<a href="/watch?v=@Model.Video.Id">Watch</a>
|
||||||
|
</div>
|
||||||
|
<p class="title">@Model.Video.Title</p>
|
||||||
|
<span class="info">@Model.Video.Views • @Model.Video.UploadDate</span>
|
||||||
|
<div class="channel-info">
|
||||||
|
<a href="/channel/@Model.Video.Channel.Id" class="avatar">
|
||||||
|
<img src="@Model.Video.Channel.Avatars.LastOrDefault()?.Url">
|
||||||
|
</a>
|
||||||
|
<div class="name">
|
||||||
|
<a class="name" href="/channel/@Model.Video.Channel.Id">@Model.Video.Channel.Name</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-list playlist-list playlist-video-list">
|
||||||
|
<h3>Add to one of these playlists:</h3>
|
||||||
|
<a class="login-button" href="/Account/CreatePlaylist" style="margin:unset;">Create playlist</a>
|
||||||
|
@foreach (LTPlaylist playlist in Model.Playlists)
|
||||||
|
{
|
||||||
|
<div class="playlist-video">
|
||||||
|
<a href="/playlist?list=@playlist.Id&add=@Model.Id" class="thumbnail"
|
||||||
|
style="background-image: url('https://i.ytimg.com/vi_webp/@playlist.VideoIds.FirstOrDefault()/maxresdefault.webp')">
|
||||||
|
</a>
|
||||||
|
<div class="info">
|
||||||
|
<a href="/playlist?list=@playlist.Id&add=@Model.Id" class="title max-lines-2">
|
||||||
|
@playlist.Name
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<span>@playlist.VideoIds.Count videos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
25
core/LightTube/Views/Account/CreatePlaylist.cshtml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
@model LightTube.Contexts.BaseContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Title = "Create Playlist";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<form asp-action="CreatePlaylist" method="POST" class="playlist-form">
|
||||||
|
<h1>Create Playlist</h1>
|
||||||
|
<input name="name" type="text" placeholder="Playlist Name">
|
||||||
|
<input name="description" type="text" placeholder="Description">
|
||||||
|
<select name="visibility">
|
||||||
|
<option value="UNLISTED">Anyone with the link can view</option>
|
||||||
|
<option value="PRIVATE">Only you can view</option>
|
||||||
|
</select>
|
||||||
|
<input type="submit" value="Create">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
</div>
|
||||||
|
</div>
|
46
core/LightTube/Views/Account/Delete.cshtml
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
@using LightTube.Database
|
||||||
|
@model LightTube.Contexts.MessageContext
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Delete Account";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||||
|
{
|
||||||
|
<div class="login-message">
|
||||||
|
@Model.Message
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
@if (Context.Request.Cookies.TryGetValue("account_data", out string _))
|
||||||
|
{
|
||||||
|
Context.TryGetUser(out LTUser user, "web");
|
||||||
|
<form asp-action="Delete" method="POST" class="login-form">
|
||||||
|
<h1>Delete Account</h1>
|
||||||
|
<p>Deleting a local account</p>
|
||||||
|
<input name="email" type="hidden" value="@user.UserID">
|
||||||
|
<input name="password" type="hidden" value="@user.PasswordHash">
|
||||||
|
<input type="submit" value="Delete Account" class="login-button danger">
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<form asp-action="Delete" method="POST" class="login-form">
|
||||||
|
<h1>Delete Account</h1>
|
||||||
|
<input name="userid" type="text" placeholder="UserID">
|
||||||
|
<input name="password" type="password" placeholder="Password">
|
||||||
|
<input type="submit" value="Delete Account" class="login-button danger">
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h1>Warning!</h1>
|
||||||
|
<p>You cannot undo this operation! After you enter your username and password, your account will get deleted forever.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
29
core/LightTube/Views/Account/Login.cshtml
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
@model LightTube.Contexts.MessageContext
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Login";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||||
|
{
|
||||||
|
<div class="login-message">
|
||||||
|
@Model.Message
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<form asp-action="Login" method="POST" class="login-form">
|
||||||
|
<h1>Log in</h1>
|
||||||
|
<input name="userid" type="text" placeholder="UserID">
|
||||||
|
<input name="password" type="password" placeholder="Password">
|
||||||
|
<input type="submit" value="Login">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Don't have an account?</h2>
|
||||||
|
<a href="/Account/Register" class="login-button">Create an account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
18
core/LightTube/Views/Account/Logins.cshtml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
@using LightTube.Database
|
||||||
|
@model LightTube.Contexts.LoginsContext
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Active Logins";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<h1 style="text-align:center;">Active Logins</h1>
|
||||||
|
<div class="logins-container">
|
||||||
|
@foreach (LTLogin login in Model.Logins)
|
||||||
|
{
|
||||||
|
<div class="login">
|
||||||
|
<h2 class="max-lines-1">@(login.Identifier == Model.CurrentLogin ? "(This window) " : "")@login.GetTitle()</h2>
|
||||||
|
<p>@Html.Raw(login.GetDescription().Replace("\n", "<br>"))</p>
|
||||||
|
<a href="/Account/DisableLogin?id=@login.Identifier" class="login-button" style="color:red;">Disable</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
42
core/LightTube/Views/Account/Register.cshtml
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
@model LightTube.Contexts.MessageContext
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Register";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||||
|
{
|
||||||
|
<div class="login-message">
|
||||||
|
@Model.Message
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<form asp-action="Register" method="POST" class="login-form">
|
||||||
|
<h1>Register</h1>
|
||||||
|
<input name="userid" type="text" placeholder="UserID">
|
||||||
|
<input name="password" type="password" placeholder="Password">
|
||||||
|
<input type="submit" value="Register">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h1>...or register with a local account</h1>
|
||||||
|
<h2>What is the difference?</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Remote account data is saved in this lighttube instance, while local account data is stored in
|
||||||
|
your browser's cookies
|
||||||
|
<ul>
|
||||||
|
<li>This means that the author of this lighttube instance cannot see your account data</li>
|
||||||
|
<li>It also means that, if you clear your cookies a lot, your account data will also get
|
||||||
|
lost with the cookies</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/Account/RegisterLocal" class="login-button">Create local account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
63
core/LightTube/Views/Account/Settings.cshtml
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
@model LightTube.Contexts.SettingsContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Title = "Settings";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="settings-content">
|
||||||
|
<h1 style="text-align:center">Settings</h1>
|
||||||
|
<div>
|
||||||
|
<label for="settings-theme">Theme</label>
|
||||||
|
<select id="settings-theme" name="theme">
|
||||||
|
@Html.Raw($"<option value='light' {(Model.Theme == "light" ? "selected" : "")}>Light</option>")
|
||||||
|
@Html.Raw($"<option value='dark' {(Model.Theme == "dark" ? "selected" : "")}>Dark</option>")
|
||||||
|
</select>
|
||||||
|
<p>This is the visual theme the website will use.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="settings-yhl">Content Language</label>
|
||||||
|
<select id="settings-yhl" name="hl">
|
||||||
|
@foreach (KeyValuePair<string, string> o in Model.Languages)
|
||||||
|
{
|
||||||
|
@Html.Raw($"<option value='{o.Key}' {(o.Key == Model.CurrentLanguage ? "selected" : "")}>{o.Value}</option>")
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<p>The language YouTube will deliver the content in. This will not affect LightTube's UI language.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="settings-ygl">Content Region</label>
|
||||||
|
<select id="settings-ygl" name="gl">
|
||||||
|
@foreach (KeyValuePair<string, string> o in Model.Regions)
|
||||||
|
{
|
||||||
|
@Html.Raw($"<option value='{o.Key}' {(o.Key == Model.CurrentRegion ? "selected" : "")}>{o.Value}</option>")
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<p>The language YouTube will deliver the content for. It is used for the explore page and the recommendations.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="settings-player">Player</label>
|
||||||
|
<select id="settings-player" name="compatibility">
|
||||||
|
@Html.Raw($"<option value=\"false\" {(Model.CompatibilityMode ? "" : "selected")}>DASH playback with muxed fallback (recommended)</option>")
|
||||||
|
@Html.Raw($"<option value=\"true\" {(Model.CompatibilityMode ? "selected" : "")}>Muxed formats only (only supports 360p & 720p)</option>")
|
||||||
|
</select>
|
||||||
|
<p>Player behaviour. DASH playback allows for resolutions over 720p, but it is not compatible in all browsers. (e.g: Firefox Mobile)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="settings-api">API Access</label>
|
||||||
|
<select id="settings-api" name="api-access">
|
||||||
|
@Html.Raw($"<option value=\"true\" {(Model.ApiAccess ? "selected" : "")}>Enabled</option>")
|
||||||
|
@Html.Raw($"<option value=\"false\" {(Model.ApiAccess ? "" : "selected")}>Disabled</option>")
|
||||||
|
</select>
|
||||||
|
<p>This will allow apps to log in using your username and password</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:row">
|
||||||
|
<a href="/Account/Logins" class="login-button">Active Logins</a>
|
||||||
|
<a href="/Account/Delete" class="login-button" style="color:red">Delete Account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<input type="submit" class="login-button" value="Save"/>
|
||||||
|
</form>
|
26
core/LightTube/Views/Feed/Channels.cshtml
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
@using LightTube.Database
|
||||||
|
@model LightTube.Contexts.FeedContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Title = "Channel list";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="video-list">
|
||||||
|
@foreach (LTChannel channel in Model.Channels)
|
||||||
|
{
|
||||||
|
<div class="channel">
|
||||||
|
<a href="/channel/@channel.ChannelId" class="avatar">
|
||||||
|
<img src="@channel.IconUrl" alt="Channel Avatar">
|
||||||
|
</a>
|
||||||
|
<a href="/channel/@channel.ChannelId" class="info">
|
||||||
|
<span class="name max-lines-2">@channel.Name</span>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<span>@channel.Subscribers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<button class="subscribe-button" data-cid="@channel.ChannelId">Subscribe</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
8
core/LightTube/Views/Feed/Explore.cshtml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Explore";
|
||||||
|
ViewData["SelectedGuideItem"] = "explore";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div style="text-align: center">
|
||||||
|
<h1>Coming soon!</h1>
|
||||||
|
</div>
|
35
core/LightTube/Views/Feed/Playlists.cshtml
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
@using LightTube.Database
|
||||||
|
@model LightTube.Contexts.PlaylistsContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Playlists";
|
||||||
|
ViewData["SelectedGuideItem"] = "library";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="video-list">
|
||||||
|
<h2>Playlists</h2>
|
||||||
|
@foreach (LTPlaylist playlist in Model.Playlists)
|
||||||
|
{
|
||||||
|
<div class="playlist">
|
||||||
|
<a href="/watch?v=@playlist.VideoIds.FirstOrDefault()&list=@playlist.Id" class="thumbnail" style="background-image: url('https://i.ytimg.com/vi_webp/@playlist.VideoIds.FirstOrDefault()/maxresdefault.webp')">
|
||||||
|
<div>
|
||||||
|
<span>@playlist.VideoIds.Count</span><span>VIDEOS</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="info">
|
||||||
|
<a href="/watch?v=@playlist.VideoIds.FirstOrDefault()&list=@playlist.Id" class="title max-lines-2">@playlist.Name</a>
|
||||||
|
<div>
|
||||||
|
<a href="/channel/@PlaylistManager.GenerateAuthorId(playlist.Author)">@playlist.Author</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/playlist?list=@playlist.Id">
|
||||||
|
<b>View Full Playlist</b>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
55
core/LightTube/Views/Feed/Subscriptions.cshtml
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
@using Humanizer
|
||||||
|
@using LightTube.Database
|
||||||
|
@using System.Web
|
||||||
|
@model LightTube.Contexts.FeedContext
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Subscriptions";
|
||||||
|
ViewData["SelectedGuideItem"] = "subs";
|
||||||
|
|
||||||
|
bool minMode = false;
|
||||||
|
if (Context.Request.Cookies.TryGetValue("minMode", out string minModeString))
|
||||||
|
bool.TryParse(minModeString, out minMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="horizontal-channel-list" style="max-width: @(!Model.MobileLayout ? $"calc(100vw - {(minMode ? 80 : 312)}px);" : "")">
|
||||||
|
<a href="/feed/channels" class="channel">
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
<div class="name max-lines-2">Manage Channels</div>
|
||||||
|
</a>
|
||||||
|
<a href="/rss?token=@HttpUtility.UrlEncode(Model.RssToken)" class="channel">
|
||||||
|
<i class="bi bi-rss"></i>
|
||||||
|
<div class="name max-lines-2">RSS Feed</div>
|
||||||
|
</a>
|
||||||
|
@foreach (LTChannel channel in Model.Channels)
|
||||||
|
{
|
||||||
|
<a href="/channel/@channel.ChannelId" class="channel">
|
||||||
|
<img src="@channel.IconUrl" loading="lazy">
|
||||||
|
<div class="name max-lines-2">@channel.Name</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rich-video-grid">
|
||||||
|
@foreach (FeedVideo video in Model.Videos)
|
||||||
|
{
|
||||||
|
<div class="video">
|
||||||
|
<a href="/watch?v=@video.Id" class="thumbnail img-thumbnail">
|
||||||
|
<img src="@video.Thumbnail" loading="lazy">
|
||||||
|
</a>
|
||||||
|
<a href="/channel/@video.ChannelId" class="avatar">
|
||||||
|
<img src="@Model.Channels.First(x => x.ChannelId == video.ChannelId).IconUrl">
|
||||||
|
</a>
|
||||||
|
<div class="info">
|
||||||
|
<a href="/watch?v=@video.Id" class="title max-lines-2">@video.Title</a>
|
||||||
|
<div>
|
||||||
|
<a href="/channel/@video.ChannelId">@video.ChannelName</a>
|
||||||
|
<div>
|
||||||
|
<span>@video.ViewCount views</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>@video.PublishedDate.Humanize(DateTimeOffset.Now)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
15
core/LightTube/Views/Home/Index.cshtml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
@model LightTube.Contexts.BaseContext
|
||||||
|
@{
|
||||||
|
ViewBag.Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["og:title"] = "LightTube",
|
||||||
|
["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}",
|
||||||
|
["og:description"] = "An alternative, privacy respecting front end for YouTube",
|
||||||
|
};
|
||||||
|
ViewData["Title"] = "Home Page";
|
||||||
|
ViewData["SelectedGuideItem"] = "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div style="text-align: center">
|
||||||
|
<h1>@Configuration.Instance.Interface.MessageOfTheDay</h1>
|
||||||
|
</div>
|
17
core/LightTube/Views/Shared/Error.cshtml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
@model LightTube.Contexts.ErrorContext
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Error";
|
||||||
|
}
|
||||||
|
|
||||||
|
<h1 class="text-danger">Error.</h1>
|
||||||
|
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||||
|
<p>
|
||||||
|
You can try other alternatives to access this resource such as:
|
||||||
|
<b>
|
||||||
|
<a href="https://invidio.us@($"{Model.Path}{Context.Request.QueryString}")">Invidious</a>
|
||||||
|
</b>
|
||||||
|
or
|
||||||
|
<b>
|
||||||
|
<a href="https://youtube.com@($"{Model.Path}{Context.Request.QueryString}")">YouTube</a>
|
||||||
|
</b>
|
||||||
|
</p>
|
139
core/LightTube/Views/Shared/_Layout.cshtml
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
@using System.Web
|
||||||
|
@using LightTube.Contexts
|
||||||
|
@model LightTube.Contexts.BaseContext
|
||||||
|
@{
|
||||||
|
bool compatibility = false;
|
||||||
|
if (Context.Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||||
|
bool.TryParse(compatibilityString, out compatibility);
|
||||||
|
|
||||||
|
bool minMode = false;
|
||||||
|
if (Context.Request.Cookies.TryGetValue("minMode", out string minModeString))
|
||||||
|
bool.TryParse(minModeString, out minMode);
|
||||||
|
}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<meta property="og:site_name" content="lighttube" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
@if (ViewBag.Metadata is not null)
|
||||||
|
{
|
||||||
|
@foreach (KeyValuePair<string, string> metaTag in ViewBag.Metadata)
|
||||||
|
{
|
||||||
|
if (metaTag.Key.StartsWith("og:"))
|
||||||
|
{
|
||||||
|
<meta property="@metaTag.Key" content="@metaTag.Value"/>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<meta name="@metaTag.Key" content="@metaTag.Value"/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<meta property="theme-color" content="#AA0000" />
|
||||||
|
<title>@ViewData["Title"] - lighttube</title>
|
||||||
|
@if ((ViewData["HideGuide"] ?? false).Equals(true))
|
||||||
|
{
|
||||||
|
<style> .guide { display: none !important; } </style>
|
||||||
|
}
|
||||||
|
@{
|
||||||
|
Context.Request.Cookies.TryGetValue("theme", out string theme);
|
||||||
|
if (!new[] { "light", "dark" }.Contains(theme)) theme = "light";
|
||||||
|
}
|
||||||
|
<link rel="stylesheet" href="@($"~/css/colors-{theme}.css")" asp-append-version="true"/>
|
||||||
|
@if (Model.MobileLayout)
|
||||||
|
{
|
||||||
|
<link rel="stylesheet" href="~/css/mobile.css" asp-append-version="true"/>
|
||||||
|
<link rel="stylesheet" href="~/css/lt-video/player-mobile.css" asp-append-version="true"/>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<link rel="stylesheet" href="~/css/desktop.css" asp-append-version="true"/>
|
||||||
|
<link rel="stylesheet" href="~/css/lt-video/player-desktop.css" asp-append-version="true"/>
|
||||||
|
}
|
||||||
|
<link rel="stylesheet" href="~/css/bootstrap-icons/bootstrap-icons.css" asp-append-version="true"/>
|
||||||
|
<link rel="icon" href="~/favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="top-bar @(ViewData["UseFullSizeSearchBar"]?.Equals(true) ?? false ? "full-size-search" : "")">
|
||||||
|
<a class="logo" href="/">light<b>tube</b></a>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<form action="/results">
|
||||||
|
<input type="text" placeholder="Search" name="search_query" value="@(Model is SearchContext ctx ? ctx.Query : Context.Request.Cookies.TryGetValue("search_query", out string s) ? s : "")">
|
||||||
|
<input type="submit" value="Search">
|
||||||
|
</form>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="search-button">
|
||||||
|
<a class="icon-link" href="/results">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="account" tabindex="-1">
|
||||||
|
<a class="icon-link" href="/Account">
|
||||||
|
<i class="bi bi-person-circle"></i>
|
||||||
|
</a>
|
||||||
|
<div class="account-menu">
|
||||||
|
@Html.Partial("_LoginLogoutPartial")
|
||||||
|
<div class="guide-item"><a href="/toggles/theme?redirectUrl=@(HttpUtility.UrlEncode($"{Context.Request.Path}{Context.Request.QueryString}"))">Toggle Theme</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="guide @(minMode ? "minmode" : "")">
|
||||||
|
<div class="guide-item @(ViewData["SelectedGuideItem"] as string == "home" ? "active" : "")">
|
||||||
|
<a href="/">
|
||||||
|
<i class="icon bi bi-house-door"></i>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="guide-item @(ViewData["SelectedGuideItem"] as string == "explore" ? "active" : "")">
|
||||||
|
<a href="/feed/explore">
|
||||||
|
<i class="icon bi bi-compass"></i>
|
||||||
|
Explore
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="guide-item @(ViewData["SelectedGuideItem"] as string == "subs" ? "active" : "")">
|
||||||
|
<a href="/feed/subscriptions">
|
||||||
|
<i class="icon bi bi-inboxes"></i>
|
||||||
|
Subscriptions
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="guide-item @(ViewData["SelectedGuideItem"] as string == "library" ? "active" : "")">
|
||||||
|
<a href="/feed/library">
|
||||||
|
<i class="icon bi bi-list-ul"></i>
|
||||||
|
Library
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="hide-on-minmode guide-item">
|
||||||
|
<a href="/toggles/collapse_guide?redirectUrl=@(HttpUtility.UrlEncode($"{Context.Request.Path}{Context.Request.QueryString}"))">
|
||||||
|
<i class="icon"><i class="bi bi-arrow-left-square"></i></i>
|
||||||
|
Collapse Guide
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="show-on-minmode guide-item">
|
||||||
|
<a href="/toggles/collapse_guide?redirectUrl=@(HttpUtility.UrlEncode($"{Context.Request.Path}{Context.Request.QueryString}"))">
|
||||||
|
<i class="icon"><i class="bi bi-arrow-right-square"></i></i>
|
||||||
|
Expand
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<hr class="hide-on-minmode">
|
||||||
|
<p class="hide-on-minmode">
|
||||||
|
<a href="https://gitlab.com/kuylar/lighttube/-/blob/master/README.md">About</a><br>
|
||||||
|
<a href="https://gitlab.com/kuylar/lighttube/-/blob/master/OTHERLIBS.md">How LightTube works</a><br>
|
||||||
|
<a href="https://gitlab.com/kuylar/lighttube">Source code</a>
|
||||||
|
<a href="https://gitlab.com/kuylar/lighttube/-/wikis/XML-API">API</a>
|
||||||
|
<a href="https://gitlab.com/kuylar/lighttube/-/blob/master/LICENSE">License</a><br>
|
||||||
|
<span style="font-weight: normal">Running on LightTube v@(Utils.GetVersion())</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
@RenderBody()
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||||
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
|
</body>
|
||||||
|
</html>
|
16
core/LightTube/Views/Shared/_LoginLogoutPartial.cshtml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
@using LightTube.Database
|
||||||
|
@if (Context.TryGetUser(out LTUser user, "web"))
|
||||||
|
{
|
||||||
|
<div class="guide-item"><a>@user.UserID.Split("@")[0]</a></div>
|
||||||
|
@if (user.PasswordHash != "local_account")
|
||||||
|
{
|
||||||
|
<div class="guide-item"><a href="/Account/Logins">Active logins</a></div>
|
||||||
|
}
|
||||||
|
<div class="guide-item"><a href="/Account/Logout">Log out</a></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="guide-item"><a href="/Account/Login">Log in</a></div>
|
||||||
|
<div class="guide-item"><a href="/Account/Register">Register</a></div>
|
||||||
|
}
|
||||||
|
<div class="guide-item"><a href="/Account/Settings">Settings</a></div>
|
80
core/LightTube/Views/Youtube/Channel.cshtml
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
@using InnerTube.Models
|
||||||
|
@using System.Web
|
||||||
|
@model LightTube.Contexts.ChannelContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Metadata = new Dictionary<string, string>();
|
||||||
|
ViewBag.Metadata["og:title"] = Model.Channel.Name;
|
||||||
|
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||||
|
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Channel.Avatars.FirstOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Channel.Avatars.LastOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Metadata["og:description"] = Model.Channel.Description;
|
||||||
|
ViewBag.Title = Model.Channel.Name;
|
||||||
|
Layout = "_Layout";
|
||||||
|
|
||||||
|
DynamicItem[] contents;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
contents = ((ItemSectionItem)((ItemSectionItem)Model.Channel.Videos[0]).Contents[0]).Contents;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
contents = Model.Channel.Videos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="channel-page">
|
||||||
|
@if (Model.Channel.Banners.Length > 0)
|
||||||
|
{
|
||||||
|
<img class="channel-banner" alt="Channel Banner" src="@Model.Channel.Banners.Last().Url">
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="channel-info-container">
|
||||||
|
<div class="channel-info">
|
||||||
|
<a href="/channel/@Model.Channel.Id" class="avatar">
|
||||||
|
<img src="@Model.Channel.Avatars.LastOrDefault()?.Url" alt="Channel Avatar">
|
||||||
|
</a>
|
||||||
|
<div class="name">
|
||||||
|
<a>@Model.Channel.Name</a>
|
||||||
|
<span>@Model.Channel.Subscribers</span>
|
||||||
|
</div>
|
||||||
|
<button class="subscribe-button" data-cid="@Model.Channel.Id">Subscribe</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>About</h3>
|
||||||
|
<p>@Html.Raw(Model.Channel.GetHtmlDescription())</p>
|
||||||
|
<br><br>
|
||||||
|
<h3>Uploads</h3>
|
||||||
|
<div class="video-grid">
|
||||||
|
@foreach (VideoItem video in contents.Where(x => x is VideoItem).Cast<VideoItem>())
|
||||||
|
{
|
||||||
|
<a href="/watch?v=@video.Id" class="video">
|
||||||
|
<div class="thumbnail" style="background-image: url('@video.Thumbnails.LastOrDefault()?.Url')"><span class="video-length">@video.Duration</span></div>
|
||||||
|
<div class="info">
|
||||||
|
<span class="title max-lines-2">@video.Title</span>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<span>@video.Views views</span>
|
||||||
|
<span>@video.UploadedAt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-buttons">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.ContinuationToken))
|
||||||
|
{
|
||||||
|
<a href="/channel?id=@Model.Id">First Page</a>
|
||||||
|
}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<span>•</span>
|
||||||
|
<div class="divider"></div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(contents.FirstOrDefault(x => x is ContinuationItem)?.Id))
|
||||||
|
{
|
||||||
|
<a href="/channel/@Model.Id?continuation=@(contents.FirstOrDefault(x => x is ContinuationItem)?.Id)">Next Page</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
95
core/LightTube/Views/Youtube/Download.cshtml
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
@using System.Web
|
||||||
|
@using InnerTube
|
||||||
|
@using InnerTube.Models
|
||||||
|
@model LightTube.Contexts.PlayerContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Metadata = new Dictionary<string, string>();
|
||||||
|
ViewBag.Metadata["author"] = Model.Video.Channel.Name;
|
||||||
|
ViewBag.Metadata["og:title"] = Model.Player.Title;
|
||||||
|
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||||
|
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.FirstOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.LastOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Title = Model.Player.Title;
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="playlist-page">
|
||||||
|
<div class="playlist-info">
|
||||||
|
<div class="thumbnail" style="background-image: url('@Model.Player.Thumbnails.Last().Url')">
|
||||||
|
<a href="/watch?v=@Model.Player.Id">Watch</a>
|
||||||
|
</div>
|
||||||
|
<p class="title">@Model.Player.Title</p>
|
||||||
|
<span class="info">@Model.Video.Views • @Model.Video.UploadDate</span>
|
||||||
|
<div class="channel-info">
|
||||||
|
<a href="/channel/@Model.Player.Channel.Id" class="avatar">
|
||||||
|
<img src="@Model.Player.Channel.Avatars.LastOrDefault()?.Url">
|
||||||
|
</a>
|
||||||
|
<div class="name">
|
||||||
|
<a class="name" href="/channel/@Model.Player.Channel.Id">@Model.Player.Channel.Name</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-list download-list playlist-video-list">
|
||||||
|
<div class="format-list">
|
||||||
|
<h2>Muxed formats</h2>
|
||||||
|
<p>These downloads have both video and audio in them</p>
|
||||||
|
@foreach (Format format in Model.Player.Formats)
|
||||||
|
{
|
||||||
|
<div class="download-format">
|
||||||
|
<div>
|
||||||
|
@format.FormatNote
|
||||||
|
</div>
|
||||||
|
<a href="/proxy/download/@Model.Video.Id/@format.FormatId/@(HttpUtility.UrlEncode(Model.Video.Title)).@format.GetExtension()">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
Download through LightTube
|
||||||
|
</a>
|
||||||
|
<a href="@format.Url">
|
||||||
|
<i class="bi bi-cloud-download"></i>
|
||||||
|
Download through YouTube
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="format-list">
|
||||||
|
<h2>Audio only formats</h2>
|
||||||
|
<p>These downloads have only have audio in them</p>
|
||||||
|
@foreach (Format format in Model.Player.AdaptiveFormats.Where(x => x.VideoCodec == "none"))
|
||||||
|
{
|
||||||
|
<div class="download-format">
|
||||||
|
<div>
|
||||||
|
@format.FormatNote (Codec: @format.AudioCodec, Sample Rate: @format.AudioSampleRate)
|
||||||
|
</div>
|
||||||
|
<a href="/proxy/download/@Model.Video.Id/@format.FormatId/@(HttpUtility.UrlEncode(Model.Video.Title)).@format.GetExtension()">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
Download through LightTube
|
||||||
|
</a>
|
||||||
|
<a href="@format.Url">
|
||||||
|
<i class="bi bi-cloud-download"></i>
|
||||||
|
Download through YouTube
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="format-list">
|
||||||
|
<h2>Video only formats</h2>
|
||||||
|
<p>These downloads have only have video in them</p>
|
||||||
|
@foreach (Format format in Model.Player.AdaptiveFormats.Where(x => x.AudioCodec == "none"))
|
||||||
|
{
|
||||||
|
<div class="download-format">
|
||||||
|
<div>
|
||||||
|
@format.FormatNote (Codec: @format.VideoCodec)
|
||||||
|
</div>
|
||||||
|
<a href="/proxy/download/@Model.Video.Id/@format.FormatId/@(HttpUtility.UrlEncode(Model.Video.Title)).@format.GetExtension()">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
Download through LightTube
|
||||||
|
</a>
|
||||||
|
<a href="@format.Url">
|
||||||
|
<i class="bi bi-cloud-download"></i>
|
||||||
|
Download through YouTube
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
146
core/LightTube/Views/Youtube/Embed.cshtml
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
@using System.Collections.Specialized
|
||||||
|
@using System.Web
|
||||||
|
@using InnerTube.Models
|
||||||
|
@model LightTube.Contexts.PlayerContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Metadata = new Dictionary<string, string>();
|
||||||
|
ViewBag.Metadata["author"] = Model.Video.Channel.Name;
|
||||||
|
ViewBag.Metadata["og:title"] = Model.Player.Title;
|
||||||
|
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||||
|
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.FirstOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.LastOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Metadata["og:description"] = Model.Player.Description;
|
||||||
|
ViewBag.Title = Model.Player.Title;
|
||||||
|
|
||||||
|
Layout = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ViewBag.Metadata["og:video"] = $"/proxy/video?url={HttpUtility.UrlEncode(Model.Player.Formats.First().Url.ToString())}";
|
||||||
|
Model.Resolution ??= Model.Player.Formats.First().FormatNote;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
bool live = Model.Player.Formats.Length == 0 && Model.Player.AdaptiveFormats.Length > 0;
|
||||||
|
bool canPlay = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<meta property="og:site_name" content="lighttube"/>
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
|
@if (ViewBag.Metadata is not null)
|
||||||
|
{
|
||||||
|
@foreach (KeyValuePair<string, string> metaTag in ViewBag.Metadata)
|
||||||
|
{
|
||||||
|
if (metaTag.Key.StartsWith("og:"))
|
||||||
|
{
|
||||||
|
<meta property="@metaTag.Key" content="@metaTag.Value"/>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<meta name="@metaTag.Key" content="@metaTag.Value"/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<meta property="theme-color" content="#AA0000"/>
|
||||||
|
<title>@ViewData["Title"] - lighttube</title>
|
||||||
|
<link rel="stylesheet" href="~/css/bootstrap-icons/bootstrap-icons.css"/>
|
||||||
|
<link rel="stylesheet" href="~/css/desktop.css" asp-append-version="true"/>
|
||||||
|
<link rel="stylesheet" href="~/css/lt-video/player-desktop.css" asp-append-version="true"/>
|
||||||
|
<link rel="icon" href="~/favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
@if (live)
|
||||||
|
{
|
||||||
|
<video class="player" poster="@Model.Player.Thumbnails.LastOrDefault()?.Url">
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
else if (Model.Player.Formats.Length > 0)
|
||||||
|
{
|
||||||
|
<video class="player" controls src="/proxy/media/@Model.Player.Id/@HttpUtility.UrlEncode(Model.Player.Formats.First(x => x.FormatNote == Model.Resolution && x.FormatId != "17").FormatId)" poster="@Model.Player.Thumbnails.LastOrDefault()?.Url">
|
||||||
|
@foreach (Subtitle subtitle in Model.Player.Subtitles ?? Array.Empty<Subtitle>())
|
||||||
|
{
|
||||||
|
@:<track src="/proxy/caption/@Model.Player.Id/@HttpUtility.UrlEncode(subtitle.Language).Replace("+", "%20")" label="@subtitle.Language" kind="subtitles">
|
||||||
|
}
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
canPlay = false;
|
||||||
|
<div id="player" class="player error" style="background-image: url('@Model.Player.Thumbnails.LastOrDefault()?.Url')">
|
||||||
|
@if (string.IsNullOrWhiteSpace(Model.Player.ErrorMessage))
|
||||||
|
{
|
||||||
|
<span>
|
||||||
|
No playable streams returned from the API (@Model.Player.Formats.Length/@Model.Player.AdaptiveFormats.Length)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>
|
||||||
|
@Model.Player.ErrorMessage
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (canPlay)
|
||||||
|
{
|
||||||
|
<script src="/js/lt-video/player-desktop.js"></script>
|
||||||
|
@if (!Model.CompatibilityMode && !live)
|
||||||
|
{
|
||||||
|
<script src="/js/shaka-player/shaka-player.compiled.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let player = undefined;
|
||||||
|
loadPlayerWithShaka("video", {
|
||||||
|
"id": "@Model.Video.Id",
|
||||||
|
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||||
|
"embed": true,
|
||||||
|
"live": false,
|
||||||
|
"storyboard": "/proxy/image?url=@HttpUtility.UrlEncode(Model.Player.Storyboards.FirstOrDefault())"
|
||||||
|
}, [
|
||||||
|
@foreach(Format f in Model.Player.Formats.Reverse())
|
||||||
|
{
|
||||||
|
@:{"height": @f.Resolution.Split("x")[1],"label":"@f.FormatName","src": "/proxy/video?url=@HttpUtility.UrlEncode(f.Url)"},
|
||||||
|
}
|
||||||
|
], "https://@(Context.Request.Host)/manifest/@(Model.Video.Id).mpd").then(x => player = x).catch(alert);;
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
else if (live)
|
||||||
|
{
|
||||||
|
<script src="/js/hls.js/hls.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let player = undefined;
|
||||||
|
loadPlayerWithHls("video", {
|
||||||
|
"id": "@(Model.Video.Id)",
|
||||||
|
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||||
|
"embed": true,
|
||||||
|
"live": true
|
||||||
|
}, "https://@(Context.Request.Host)/manifest/@(Model.Video.Id).m3u8").then(x => player = x).catch(alert);
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<script>
|
||||||
|
const player = new Player("video", {
|
||||||
|
"id": "@Model.Video.Id",
|
||||||
|
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||||
|
"embed": true,
|
||||||
|
"live": false,
|
||||||
|
"storyboard": "/proxy/image?url=@HttpUtility.UrlEncode(Model.Player.Storyboards.FirstOrDefault())"
|
||||||
|
}, [
|
||||||
|
@foreach(Format f in Model.Player.Formats.Reverse())
|
||||||
|
{
|
||||||
|
@:{"height": @f.Resolution.Split("x")[1],"label":"@f.FormatName","src": "/proxy/video?url=@HttpUtility.UrlEncode(f.Url)"},
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</body>
|
||||||
|
</html>
|
85
core/LightTube/Views/Youtube/Playlist.cshtml
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
@using InnerTube.Models
|
||||||
|
@using System.Web
|
||||||
|
@model LightTube.Contexts.PlaylistContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Title = Model.Playlist.Title;
|
||||||
|
ViewBag.Metadata = new Dictionary<string, string>();
|
||||||
|
ViewBag.Metadata["og:title"] = Model.Playlist.Title;
|
||||||
|
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||||
|
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Playlist.Thumbnail.FirstOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Playlist.Thumbnail.LastOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Metadata["og:description"] = Model.Playlist.Description;
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||||
|
{
|
||||||
|
<div class="playlist-message" style="padding: 16px;background-color: var(--border-color); color: var(--text-primary);">
|
||||||
|
@Model.Message
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="playlist-page">
|
||||||
|
<div class="playlist-info">
|
||||||
|
<div class="thumbnail" style="background-image: url('@Model.Playlist.Thumbnail.LastOrDefault()?.Url')">
|
||||||
|
<a href="/watch?v=@Model.Playlist.Videos.FirstOrDefault()?.Id&list=@Model.Id">Play all</a>
|
||||||
|
</div>
|
||||||
|
<p class="title">@Model.Playlist.Title</p>
|
||||||
|
<span class="info">@Model.Playlist.VideoCount videos • @Model.Playlist.ViewCount views • @Model.Playlist.LastUpdated</span>
|
||||||
|
<span class="description">@Html.Raw(Model.Playlist.GetHtmlDescription())</span>
|
||||||
|
<a href="/playlist?list=@Model.Id&remove=true" class="login-button" style="margin:unset;">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
Delete playlist
|
||||||
|
</a>
|
||||||
|
<div class="channel-info">
|
||||||
|
<a href="/channel/@Model.Playlist.Channel.Id" class="avatar">
|
||||||
|
<img src="@Model.Playlist.Channel.Avatars.LastOrDefault()?.Url">
|
||||||
|
</a>
|
||||||
|
<div class="name">
|
||||||
|
<a class="name" href="/channel/@Model.Playlist.Channel.Id">@Model.Playlist.Channel.Name</a>
|
||||||
|
</div>
|
||||||
|
<button class="subscribe-button" data-cid="@Model.Playlist.Channel.Id">Subscribe</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-list playlist-video-list">
|
||||||
|
@foreach (PlaylistVideoItem video in Model.Playlist.Videos.Cast<PlaylistVideoItem>())
|
||||||
|
{
|
||||||
|
<div class="playlist-video">
|
||||||
|
<a href="/watch?v=@video.Id&list=@Model.Id" class="index">
|
||||||
|
@video.Index
|
||||||
|
</a>
|
||||||
|
<a href="/watch?v=@video.Id&list=@Model.Id" class="thumbnail"
|
||||||
|
style="background-image: url('@video.Thumbnails.LastOrDefault()?.Url')">
|
||||||
|
<span class="video-length">@video.Duration</span>
|
||||||
|
</a>
|
||||||
|
<div class="info">
|
||||||
|
<a href="/watch?v=@video.Id&list=@Model.Id" class="title max-lines-2">
|
||||||
|
@video.Title
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<a href="/channel/@video.Channel.Name">@video.Channel.Name</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (Model.Editable)
|
||||||
|
{
|
||||||
|
<a href="/playlist?list=@Model.Id&delete=@(video.Index - 1)" class="edit">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pagination-buttons">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.ContinuationToken))
|
||||||
|
{
|
||||||
|
<a href="/playlist?list=@Model.Id">First Page</a>
|
||||||
|
}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<span>•</span>
|
||||||
|
<div class="divider"></div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Playlist.ContinuationKey))
|
||||||
|
{
|
||||||
|
<a href="/playlist?list=@Model.Id&continuation=@Model.Playlist.ContinuationKey">Next Page</a>
|
||||||
|
}
|
||||||
|
</div>
|
28
core/LightTube/Views/Youtube/Search.cshtml
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
@using InnerTube.Models
|
||||||
|
@model LightTube.Contexts.SearchContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Title = Model.Query;
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["UseFullSizeSearchBar"] = Model.MobileLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="video-list">
|
||||||
|
@foreach (DynamicItem preview in Model.Results.Results)
|
||||||
|
{
|
||||||
|
@preview.GetHtml()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="pagination-buttons">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.ContinuationKey))
|
||||||
|
{
|
||||||
|
<a href="/results?search_query=@Model.Query">First Page</a>
|
||||||
|
}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<span>•</span>
|
||||||
|
<div class="divider"></div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Results.ContinuationKey))
|
||||||
|
{
|
||||||
|
<a href="/results?search_query=@Model.Query&continuation=@Model.Results.ContinuationKey">Next Page</a>
|
||||||
|
}
|
||||||
|
</div>
|
325
core/LightTube/Views/Youtube/Watch.cshtml
Normal file
|
@ -0,0 +1,325 @@
|
||||||
|
@using System.Text.RegularExpressions
|
||||||
|
@using System.Web
|
||||||
|
@using InnerTube.Models
|
||||||
|
@model LightTube.Contexts.PlayerContext
|
||||||
|
|
||||||
|
@{
|
||||||
|
bool compatibility = false;
|
||||||
|
if (Context.Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||||
|
bool.TryParse(compatibilityString, out compatibility);
|
||||||
|
|
||||||
|
ViewBag.Metadata = new Dictionary<string, string>();
|
||||||
|
ViewBag.Metadata["author"] = Model.Video.Channel.Name;
|
||||||
|
ViewBag.Metadata["og:title"] = Model.Player.Title;
|
||||||
|
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||||
|
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.FirstOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.LastOrDefault()?.Url?.ToString())}";
|
||||||
|
ViewBag.Metadata["og:description"] = Model.Player.Description;
|
||||||
|
ViewBag.Title = Model.Player.Title;
|
||||||
|
|
||||||
|
Layout = "_Layout";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ViewBag.Metadata["og:video"] = $"/proxy/video?url={HttpUtility.UrlEncode(Model.Player.Formats.First().Url.ToString())}";
|
||||||
|
Model.Resolution ??= Model.Player.Formats.First().FormatNote;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
ViewData["HideGuide"] = true;
|
||||||
|
|
||||||
|
bool live = Model.Player.Formats.Length == 0 && Model.Player.AdaptiveFormats.Length > 0;
|
||||||
|
string description = Model.Video.GetHtmlDescription();
|
||||||
|
const string youtubePattern = @"[w.]*youtube[-nockie]*\.com";
|
||||||
|
|
||||||
|
// turn URLs into hyperlinks
|
||||||
|
Regex urlRegex = new(youtubePattern, RegexOptions.IgnoreCase);
|
||||||
|
Match m;
|
||||||
|
for (m = urlRegex.Match(description); m.Success; m = m.NextMatch())
|
||||||
|
description = description.Replace(m.Groups[0].ToString(),
|
||||||
|
$"{Url.ActionContext.HttpContext.Request.Host}");
|
||||||
|
|
||||||
|
bool canPlay = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TODO: chapters -->
|
||||||
|
<div class="watch-page">
|
||||||
|
<div class="primary">
|
||||||
|
<div class="video-player-container">
|
||||||
|
|
||||||
|
@if (live)
|
||||||
|
{
|
||||||
|
<video class="player" poster="@Model.Player.Thumbnails.LastOrDefault()?.Url">
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
else if (Model.Player.Formats.Length > 0)
|
||||||
|
{
|
||||||
|
<video class="player" controls src="/proxy/media/@Model.Player.Id/@HttpUtility.UrlEncode(Model.Player.Formats.First(x => x.FormatNote == Model.Resolution && x.FormatId != "17").FormatId)" poster="@Model.Player.Thumbnails.LastOrDefault()?.Url">
|
||||||
|
@foreach (Subtitle subtitle in Model.Player.Subtitles ?? Array.Empty<Subtitle>())
|
||||||
|
{
|
||||||
|
@:<track src="/proxy/caption/@Model.Player.Id/@HttpUtility.UrlEncode(subtitle.Language).Replace("+", "%20")" label="@subtitle.Language" kind="subtitles">
|
||||||
|
}
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
canPlay = false;
|
||||||
|
<div id="player" class="player error" style="background-image: url('@Model.Player.Thumbnails.LastOrDefault()?.Url')">
|
||||||
|
@if (string.IsNullOrWhiteSpace(Model.Player.ErrorMessage))
|
||||||
|
{
|
||||||
|
<span>
|
||||||
|
No playable streams returned from the API (@Model.Player.Formats.Length/@Model.Player.AdaptiveFormats.Length)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>
|
||||||
|
@Model.Player.ErrorMessage
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (Model.MobileLayout)
|
||||||
|
{
|
||||||
|
<div class="video-info">
|
||||||
|
<div class="video-title">@Model.Video.Title</div>
|
||||||
|
<div class="video-info-bar">
|
||||||
|
<span>@Model.Video.Views</span>
|
||||||
|
<span>Published @Model.Video.UploadDate</span>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="video-info-buttons">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-hand-thumbs-up"></i><span>@Model.Engagement.Likes</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-hand-thumbs-down"></i><span>@Model.Engagement.Dislikes</span>
|
||||||
|
</div>
|
||||||
|
<a href="/download?v=@Model.Video.Id">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<a href="/Account/AddVideoToPlaylist?v=@Model.Video.Id">
|
||||||
|
<i class="bi bi-folder-plus"></i>
|
||||||
|
Save
|
||||||
|
</a>
|
||||||
|
<a href="https://www.youtube.com/watch?v=@Model.Video.Id">
|
||||||
|
<i class="bi bi-share"></i>
|
||||||
|
YouTube link
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="channel-info">
|
||||||
|
<a href="/channel/@Model.Video.Channel.Id" class="avatar">
|
||||||
|
<img src="@Model.Video.Channel.Avatars.LastOrDefault()?.Url">
|
||||||
|
</a>
|
||||||
|
<div class="name">
|
||||||
|
<a href="/channel/@Model.Video.Channel.Id">@Model.Video.Channel.Name</a>
|
||||||
|
</div>
|
||||||
|
<button class="subscribe-button" data-cid="@Model.Video.Channel.Id">Subscribe</button>
|
||||||
|
</div>
|
||||||
|
<p class="description">@Html.Raw(description)</p>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="video-info">
|
||||||
|
<div class="video-title">@Model.Video.Title</div>
|
||||||
|
<p class="video-sub-info description">
|
||||||
|
<span>@Model.Video.Views @Model.Video.UploadDate</span> @Html.Raw(description)
|
||||||
|
</p>
|
||||||
|
<div class="video-info-buttons">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-hand-thumbs-up"></i>
|
||||||
|
@Model.Engagement.Likes
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-hand-thumbs-down"></i>
|
||||||
|
@Model.Engagement.Dislikes
|
||||||
|
</div>
|
||||||
|
<a href="/download?v=@Model.Player.Id">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<a href="/Account/AddVideoToPlaylist?v=@Model.Video.Id">
|
||||||
|
<i class="bi bi-folder-plus"></i>
|
||||||
|
Save
|
||||||
|
</a>
|
||||||
|
<a href="https://www.youtube.com/watch?v=@Model.Video.Id">
|
||||||
|
<i class="bi bi-share"></i>
|
||||||
|
YouTube link
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="channel-info__bordered">
|
||||||
|
<a href="/channel/@Model.Video.Channel.Id" class="avatar">
|
||||||
|
<img src="@Model.Video.Channel.Avatars.FirstOrDefault()?.Url">
|
||||||
|
</a>
|
||||||
|
<div class="name">
|
||||||
|
<a href="/channel/@Model.Video.Channel.Id">@Model.Video.Channel.Name</a>
|
||||||
|
</div>
|
||||||
|
<div class="subscriber-count">
|
||||||
|
@Model.Video.Channel.SubscriberCount
|
||||||
|
</div>
|
||||||
|
<button class="subscribe-button" data-cid="@Model.Video.Channel.Id">Subscribe</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="secondary">
|
||||||
|
<noscript>
|
||||||
|
<div class="resolutions-list">
|
||||||
|
<h3>Change Resolution</h3>
|
||||||
|
<div>
|
||||||
|
@foreach (Format format in Model.Player.Formats.Where(x => x.FormatId != "17"))
|
||||||
|
{
|
||||||
|
@if (format.FormatNote == Model.Resolution)
|
||||||
|
{
|
||||||
|
<b>@format.FormatNote (current)</b>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a href="/watch?v=@Model.Player.Id&quality=@format.FormatNote">@format.FormatNote</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<div class="recommended-list">
|
||||||
|
|
||||||
|
@if (Model.Video.Recommended.Length == 0)
|
||||||
|
{
|
||||||
|
<p style="text-align: center">None :(<br>This is most likely an age-restricted video</p>
|
||||||
|
}
|
||||||
|
@foreach (DynamicItem recommendation in Model.Video.Recommended)
|
||||||
|
{
|
||||||
|
switch (recommendation)
|
||||||
|
{
|
||||||
|
case VideoItem video:
|
||||||
|
<div class="video">
|
||||||
|
<a href="/watch?v=@video.Id" class="thumbnail" style="background-image: url('@video.Thumbnails.LastOrDefault()?.Url')">
|
||||||
|
<span class="video-length">@video.Duration</span>
|
||||||
|
</a>
|
||||||
|
<div class="info">
|
||||||
|
<a href="/watch?v=@video.Id" class="title max-lines-2">@video.Title</a>
|
||||||
|
<div>
|
||||||
|
<a href="/channel/@video.Channel.Id" class="max-lines-1">@video.Channel.Name</a>
|
||||||
|
<div>
|
||||||
|
<span>@video.Views views</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>@video.UploadedAt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
case PlaylistItem playlist:
|
||||||
|
<div class="playlist">
|
||||||
|
<a href="/watch?v=@playlist.FirstVideoId&list=@playlist.Id" class="thumbnail" style="background-image: url('@playlist.Thumbnails.LastOrDefault()?.Url')">
|
||||||
|
<div>
|
||||||
|
<span>@playlist.VideoCount</span>
|
||||||
|
<span>VIDEOS</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="info">
|
||||||
|
<a href="/watch?v=@playlist.FirstVideoId&list=@playlist.Id" class="title max-lines-2">@playlist.Title</a>
|
||||||
|
<div>
|
||||||
|
<a href="/channel/@playlist.Channel.Id">@playlist.Channel.Name</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
case RadioItem radio:
|
||||||
|
<div class="playlist">
|
||||||
|
<a href="/watch?v=@radio.FirstVideoId&list=@radio.Id" class="thumbnail" style="background-image: url('@radio.Thumbnails.LastOrDefault()?.Url')">
|
||||||
|
<div>
|
||||||
|
<span>MIX</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="info">
|
||||||
|
<a href="/watch?v=@radio.FirstVideoId&list=@radio.Id" class="title max-lines-2">@radio.Title</a>
|
||||||
|
<div>
|
||||||
|
<span>@radio.Channel.Name</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
case ContinuationItem continuationItem:
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<div class="video">
|
||||||
|
<div class="thumbnail" style="background-image: url('@recommendation.Thumbnails?.LastOrDefault()?.Url')"></div>
|
||||||
|
<div class="info">
|
||||||
|
<span class="title max-lines-2">@recommendation.GetType().Name</span>
|
||||||
|
<div>
|
||||||
|
<b>WARNING:</b> Unknown recommendation type: @recommendation.Id
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (canPlay)
|
||||||
|
{
|
||||||
|
@if (Model.MobileLayout)
|
||||||
|
{
|
||||||
|
<script src="/js/lt-video/player-mobile.js"></script>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<script src="/js/lt-video/player-desktop.js"></script>
|
||||||
|
}
|
||||||
|
@if (!Model.CompatibilityMode && !live)
|
||||||
|
{
|
||||||
|
<script src="/js/shaka-player/shaka-player.compiled.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let player = undefined;
|
||||||
|
loadPlayerWithShaka("video", {
|
||||||
|
"id": "@Model.Video.Id",
|
||||||
|
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||||
|
"embed": false,
|
||||||
|
"live": false,
|
||||||
|
"storyboard": "/proxy/image?url=@HttpUtility.UrlEncode(Model.Player.Storyboards.FirstOrDefault())"
|
||||||
|
}, [
|
||||||
|
@foreach (Format f in Model.Player.Formats.Reverse())
|
||||||
|
{
|
||||||
|
@:{"height": @f.Resolution.Split("x")[1],"label":"@f.FormatName","src": "/proxy/video?url=@HttpUtility.UrlEncode(f.Url)"},
|
||||||
|
}
|
||||||
|
], "https://@(Context.Request.Host)/manifest/@(Model.Video.Id).mpd").then(x => player = x).catch(alert);
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
else if (live)
|
||||||
|
{
|
||||||
|
<script src="/js/hls.js/hls.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let player = undefined;
|
||||||
|
loadPlayerWithHls("video", {
|
||||||
|
"id": "@(Model.Video.Id)",
|
||||||
|
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||||
|
"embed": false,
|
||||||
|
"live": true
|
||||||
|
}, "https://@(Context.Request.Host)/manifest/@(Model.Video.Id).m3u8").then(x => player = x).catch(alert);
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<script>
|
||||||
|
const player = new Player("video", {
|
||||||
|
"id": "@Model.Video.Id",
|
||||||
|
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||||
|
"embed": false,
|
||||||
|
"live": false,
|
||||||
|
"storyboard": "/proxy/image?url=@HttpUtility.UrlEncode(Model.Player.Storyboards.FirstOrDefault())"
|
||||||
|
}, [
|
||||||
|
@foreach (Format f in Model.Player.Formats.Reverse())
|
||||||
|
{
|
||||||
|
@:{"height": @f.Resolution.Split("x")[1],"label":"@f.FormatName","src": "/proxy/media/@(Model.Player.Id)/@(f.FormatId)"},
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
3
core/LightTube/Views/_ViewImports.cshtml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
@using LightTube
|
||||||
|
@using LightTube.Models
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
3
core/LightTube/Views/_ViewStart.cshtml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
113
core/LightTube/YoutubeRSS.cs
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace LightTube
|
||||||
|
{
|
||||||
|
public static class YoutubeRSS
|
||||||
|
{
|
||||||
|
private static HttpClient _httpClient = new();
|
||||||
|
|
||||||
|
public static async Task<ChannelFeed> GetChannelFeed(string channelId)
|
||||||
|
{
|
||||||
|
HttpResponseMessage response =
|
||||||
|
await _httpClient.GetAsync("https://www.youtube.com/feeds/videos.xml?channel_id=" + channelId);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw response.StatusCode switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.NotFound => new KeyNotFoundException($"Channel '{channelId}' does not exist"),
|
||||||
|
var _ => new Exception("Failed to fetch RSS feed for channel " + channelId)
|
||||||
|
};
|
||||||
|
|
||||||
|
ChannelFeed feed = new();
|
||||||
|
|
||||||
|
string xml = await response.Content.ReadAsStringAsync();
|
||||||
|
XDocument doc = XDocument.Parse(xml);
|
||||||
|
|
||||||
|
feed.Name = doc.Descendants().First(p => p.Name.LocalName == "title").Value;
|
||||||
|
feed.Id = doc.Descendants().First(p => p.Name.LocalName == "channelId").Value;
|
||||||
|
feed.Videos = doc.Descendants().Where(p => p.Name.LocalName == "entry").Select(x => new FeedVideo
|
||||||
|
{
|
||||||
|
Id = x.Descendants().First(p => p.Name.LocalName == "videoId").Value,
|
||||||
|
Title = x.Descendants().First(p => p.Name.LocalName == "title").Value,
|
||||||
|
Description = x.Descendants().First(p => p.Name.LocalName == "description").Value,
|
||||||
|
ViewCount = long.Parse(x.Descendants().First(p => p.Name.LocalName == "statistics").Attribute("views")?.Value ?? "-1"),
|
||||||
|
Thumbnail = x.Descendants().First(p => p.Name.LocalName == "thumbnail").Attribute("url")?.Value,
|
||||||
|
ChannelName = x.Descendants().First(p => p.Name.LocalName == "name").Value,
|
||||||
|
ChannelId = x.Descendants().First(p => p.Name.LocalName == "channelId").Value,
|
||||||
|
PublishedDate = DateTimeOffset.Parse(x.Descendants().First(p => p.Name.LocalName == "published").Value)
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<FeedVideo[]> GetMultipleFeeds(IEnumerable<string> channelIds)
|
||||||
|
{
|
||||||
|
Task<ChannelFeed>[] feeds = channelIds.Select(YoutubeRSS.GetChannelFeed).ToArray();
|
||||||
|
await Task.WhenAll(feeds);
|
||||||
|
|
||||||
|
List<FeedVideo> videos = new();
|
||||||
|
foreach (ChannelFeed feed in feeds.Select(x => x.Result)) videos.AddRange(feed.Videos);
|
||||||
|
|
||||||
|
videos.Sort((a, b) => DateTimeOffset.Compare(b.PublishedDate, a.PublishedDate));
|
||||||
|
return videos.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChannelFeed
|
||||||
|
{
|
||||||
|
public string Name;
|
||||||
|
public string Id;
|
||||||
|
public FeedVideo[] Videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FeedVideo
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public string Title;
|
||||||
|
public string Description;
|
||||||
|
public long ViewCount;
|
||||||
|
public string Thumbnail;
|
||||||
|
public string ChannelName;
|
||||||
|
public string ChannelId;
|
||||||
|
public DateTimeOffset PublishedDate;
|
||||||
|
|
||||||
|
public XmlElement GetXmlElement(XmlDocument doc)
|
||||||
|
{
|
||||||
|
XmlElement item = doc.CreateElement("Video");
|
||||||
|
item.SetAttribute("id", Id);
|
||||||
|
item.SetAttribute("views", ViewCount.ToString());
|
||||||
|
item.SetAttribute("uploadedAt", PublishedDate.ToUnixTimeSeconds().ToString());
|
||||||
|
|
||||||
|
XmlElement title = doc.CreateElement("Title");
|
||||||
|
title.InnerText = Title;
|
||||||
|
item.AppendChild(title);
|
||||||
|
XmlElement channel = doc.CreateElement("Channel");
|
||||||
|
channel.SetAttribute("id", ChannelId);
|
||||||
|
|
||||||
|
XmlElement channelTitle = doc.CreateElement("Name");
|
||||||
|
channelTitle.InnerText = ChannelName;
|
||||||
|
channel.AppendChild(channelTitle);
|
||||||
|
|
||||||
|
item.AppendChild(channel);
|
||||||
|
|
||||||
|
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||||
|
thumbnail.InnerText = Thumbnail;
|
||||||
|
item.AppendChild(thumbnail);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Description))
|
||||||
|
{
|
||||||
|
XmlElement description = doc.CreateElement("Description");
|
||||||
|
description.InnerText = Description;
|
||||||
|
item.AppendChild(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
core/LightTube/appsettings.Development.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
core/LightTube/appsettings.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
1704
core/LightTube/wwwroot/css/bootstrap-icons/bootstrap-icons.css
vendored
Normal file
19
core/LightTube/wwwroot/css/colors-dark.css
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
:root {
|
||||||
|
--text-primary: #fff;
|
||||||
|
--text-secondary: #808080;
|
||||||
|
--text-link: #3ea6ff;
|
||||||
|
|
||||||
|
--app-background: #181818;
|
||||||
|
--context-menu-background: #333;
|
||||||
|
--border-color: #444;
|
||||||
|
--item-hover-background: #373737;
|
||||||
|
--item-active-background: #383838;
|
||||||
|
|
||||||
|
--top-bar-background: #202020;
|
||||||
|
--guide-background: #212121;
|
||||||
|
|
||||||
|
--thumbnail-background: #252525;
|
||||||
|
|
||||||
|
--channel-info-background: #181818;
|
||||||
|
--channel-contents-background: #0f0f0f;
|
||||||
|
}
|
19
core/LightTube/wwwroot/css/colors-light.css
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
:root {
|
||||||
|
--text-primary: #000;
|
||||||
|
--text-secondary: #606060;
|
||||||
|
--text-link: #3ea6ff;
|
||||||
|
|
||||||
|
--app-background: #f9f9f9;
|
||||||
|
--context-menu-background: #f2f2f2;
|
||||||
|
--border-color: #c5c5c5;
|
||||||
|
--item-hover-background: #f2f2f2;
|
||||||
|
--item-active-background: #E5E5E5;;
|
||||||
|
|
||||||
|
--top-bar-background: #FFF;
|
||||||
|
--guide-background: #FFF;
|
||||||
|
|
||||||
|
--thumbnail-background: #CCC;
|
||||||
|
|
||||||
|
--channel-info-background: #fff;
|
||||||
|
--channel-contents-background: #f9f9f9;
|
||||||
|
}
|
1232
core/LightTube/wwwroot/css/desktop.css
Normal file
267
core/LightTube/wwwroot/css/lt-video/player-desktop.css
Normal file
|
@ -0,0 +1,267 @@
|
||||||
|
* {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player {
|
||||||
|
background-color: #000 !important;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr min-content;
|
||||||
|
grid-template-rows: max-content 1fr max-content max-content max-content;
|
||||||
|
gap: 0 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player * {
|
||||||
|
color: #fff;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player.embed, video.embed {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player * {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player > video {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
grid-area: 1 / 1 / 6 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player.hide-controls > .player-title,
|
||||||
|
.player.hide-controls > .player-controls,
|
||||||
|
.player.hide-controls > .player-playback-bar-container,
|
||||||
|
.player.hide-controls > .player-menu {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-title {
|
||||||
|
grid-area: 1 / 1 / 2 / 3;
|
||||||
|
color: white;
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 27px;
|
||||||
|
background-image: linear-gradient(180deg, #0007 0%, #0000 100%);
|
||||||
|
padding: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls {
|
||||||
|
padding-top: 4px;
|
||||||
|
color: #ddd !important;
|
||||||
|
width: 100%;
|
||||||
|
height: min-content;
|
||||||
|
position: relative;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background-image: linear-gradient(0deg, #0007 0%, #0007 80%, #0000 100%);
|
||||||
|
grid-area: 5 / 1 / 6 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls > span {
|
||||||
|
line-height: 48px;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 109%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls-padding {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
transition: width ease-in 250ms;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 36px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button, .player-button * {
|
||||||
|
color: #dddddd !important;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button > i {
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button:hover, .player-button:hover * {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-volume {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-volume:hover {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button-divider {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button-menu {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-menu {
|
||||||
|
grid-area: 3 / 2 / 4 / 3;
|
||||||
|
z-index: 3;
|
||||||
|
position: relative;
|
||||||
|
background-color: #000a !important;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-menu > div {
|
||||||
|
overflow-y: scroll;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-menu-item {
|
||||||
|
padding: 4px 8px;
|
||||||
|
height: 2rem;
|
||||||
|
line-height: 2rem;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-menu-item > .bi {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-menu-item > .bi-::before {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
content: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-menu-item:hover {
|
||||||
|
background-color: #fff3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar {
|
||||||
|
transition: width linear 100ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-container {
|
||||||
|
grid-area: 4 / 1 / 5 / 3;
|
||||||
|
height: 4px;
|
||||||
|
transition: height linear 100ms;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-bg {
|
||||||
|
background-color: #fff3 !important;
|
||||||
|
width: calc(100% - 24px);
|
||||||
|
margin: auto;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-bg > * {
|
||||||
|
grid-area: 1 / 1 / 2 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-container:hover {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-buffer {
|
||||||
|
background-color: #fffa !important;
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-fg {
|
||||||
|
background-color: #f00 !important;
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-hover {
|
||||||
|
width: min-content !important;
|
||||||
|
padding: 4px;
|
||||||
|
position: fixed;
|
||||||
|
color: white;
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-hover > span {
|
||||||
|
background-color: #000 !important;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-storyboard-image-container {
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: inline-block;
|
||||||
|
width: 144px;
|
||||||
|
height: 81px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-storyboard-image {
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: inline-block;
|
||||||
|
width: 48px;
|
||||||
|
height: 27px;
|
||||||
|
background-position-x: 0;
|
||||||
|
background-position-y: 0;
|
||||||
|
transform: scale(3);
|
||||||
|
position: relative;
|
||||||
|
box-sizing: content-box;
|
||||||
|
border: 1px solid white;
|
||||||
|
top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-buffering {
|
||||||
|
grid-area: 1 / 1 / 6 / 3;
|
||||||
|
background-color: #000A;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-buffering-spinner {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
153
core/LightTube/wwwroot/css/lt-video/player-mobile.css
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
body, html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player {
|
||||||
|
background-color: #000 !important;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr min-content;
|
||||||
|
grid-template-rows: max-content 1fr max-content max-content max-content;
|
||||||
|
gap: 0 0;
|
||||||
|
width: 100%;
|
||||||
|
/*
|
||||||
|
height: 100%;
|
||||||
|
*/
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player * {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player.embed, video.embed {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player * {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player > video {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
grid-area: 1 / 1 / 6 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player.hide-controls > .player-title,
|
||||||
|
.player.hide-controls > .player-controls,
|
||||||
|
.player.hide-controls > .player-playback-bar-container,
|
||||||
|
.player.hide-controls > .player-menu {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls {
|
||||||
|
background-color: #0007;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
grid-area: 1 / 1 / 6 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
font-size: 90px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-tiny-button {
|
||||||
|
width: 40px;
|
||||||
|
font-size: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-tiny-button > i {
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button, .player-button * {
|
||||||
|
color: #dddddd !important;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button > i {
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button:hover, .player-button:hover * {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar {
|
||||||
|
transition: width linear 100ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-container {
|
||||||
|
grid-area: 4 / 1 / 5 / 3;
|
||||||
|
display: flex;
|
||||||
|
column-gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 8px;
|
||||||
|
transition: height linear 100ms;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-bg {
|
||||||
|
background-color: #fff3 !important;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-bg > * {
|
||||||
|
grid-area: 1 / 1 / 2 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-buffer {
|
||||||
|
background-color: #fffa !important;
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-playback-bar-fg {
|
||||||
|
background-color: #f00 !important;
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-buffering {
|
||||||
|
grid-area: 1 / 1 / 6 / 3;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-buffering-spinner {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
1201
core/LightTube/wwwroot/css/mobile.css
Normal file
BIN
core/LightTube/wwwroot/favicon.ico
Normal file
After Width: | Height: | Size: 5.3 KiB |
1
core/LightTube/wwwroot/icons/collapse_guide.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 3)"><path d="m.5 12.5v-10c0-1.1045695.8954305-2 2-2h10c1.1045695 0 2 .8954305 2 2v10c0 1.1045695-.8954305 2-2 2h-10c-1.1045695 0-2-.8954305-2-2z"/><path d="m2.5 12.5v-10c0-1.1045695.8954305-2 2-2h-2c-1 0-2 .8954305-2 2v10c0 1.1045695 1 2 2 2h2c-1.1045695 0-2-.8954305-2-2z" fill="currentColor"/><path d="m7.5 10.5-3-3 3-3"/><path d="m12.5 7.5h-8"/></g></svg>
|
After Width: | Height: | Size: 568 B |
1
core/LightTube/wwwroot/icons/compass.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(2 2)"><circle cx="8.5" cy="8.5" r="8"/><path d="m10.5 9.5-4 3v-5l4-3z"/></g></svg>
|
After Width: | Height: | Size: 290 B |
1
core/LightTube/wwwroot/icons/dislike.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="matrix(1 0 0 -1 2 18)"><path d="m11.6427217 13.7567397-3.14377399-1.2567396h-4v-7.00000002h2l2.80105246-5.5c.57989907 0 1.07487363.2050252 1.48492373.61507546.4100508.41005058.6150761.90502516.6150755 1.48492425l-.8999994 2.40000029 4.0310597 1.34368655c.9979872.33266243 1.5591794 1.37584131 1.3086286 2.37964122l-.0684258.21997226-1.5536355 4.14302809c-.3878403 1.0342407-1.5406646 1.5582517-2.5749053 1.1704115z"/><path d="m1.5 4.5h2c.55228475 0 1 .44771525 1 1v8c0 .5522847-.44771525 1-1 1h-2c-.55228475 0-1-.4477153-1-1v-8c0-.55228475.44771525-1 1-1z"/></g></svg>
|
After Width: | Height: | Size: 766 B |
1
core/LightTube/wwwroot/icons/home.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(1 1)"><path d="m.5 9.5 9-9 9 9"/><path d="m2.5 7.5v8c0 .5522847.44771525 1 1 1h3c.55228475 0 1-.4477153 1-1v-4c0-.5522847.44771525-1 1-1h2c.5522847 0 1 .4477153 1 1v4c0 .5522847.4477153 1 1 1h3c.5522847 0 1-.4477153 1-1v-8"/></g></svg>
|
After Width: | Height: | Size: 443 B |
16
core/LightTube/wwwroot/icons/icons.txt
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
https://systemuicons.com/
|
||||||
|
=========================
|
||||||
|
home:
|
||||||
|
https://systemuicons.com/images/icons/home_door.svg
|
||||||
|
browse:
|
||||||
|
https://systemuicons.com/images/icons/compass.svg
|
||||||
|
subscriptions:
|
||||||
|
-
|
||||||
|
profile:
|
||||||
|
https://systemuicons.com/images/icons/user_male_circle.svg
|
||||||
|
search:
|
||||||
|
https://systemuicons.com/images/icons/search.svg
|
||||||
|
like:
|
||||||
|
https://systemuicons.com/images/icons/thumbs_up.svg
|
||||||
|
dislike:
|
||||||
|
https://systemuicons.com/images/icons/thumbs_down.svg
|
1
core/LightTube/wwwroot/icons/like.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(2 3)"><path d="m11.6427217 13.7567397-3.14377399-1.2567396h-4v-7.00000002h2l2.80105246-5.5c.57989907 0 1.07487363.2050252 1.48492373.61507546.4100508.41005058.6150761.90502516.6150755 1.48492425l-.8999994 2.40000029 4.0310597 1.34368655c.9979872.33266243 1.5591794 1.37584131 1.3086286 2.37964122l-.0684258.21997226-1.5536355 4.14302809c-.3878403 1.0342407-1.5406646 1.5582517-2.5749053 1.1704115z"/><path d="m1.5 4.5h2c.55228475 0 1 .44771525 1 1v8c0 .5522847-.44771525 1-1 1h-2c-.55228475 0-1-.4477153-1-1v-8c0-.55228475.44771525-1 1-1z"/></g></svg>
|
After Width: | Height: | Size: 759 B |
1
core/LightTube/wwwroot/icons/profile.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(2 2)"><circle cx="8.5" cy="8.5" r="8"/><path d="m14.5 13.5c-.6615287-2.2735217-3.1995581-3.0251263-6-3.0251263-2.72749327 0-5.27073171.8688092-6 3.0251263"/><path d="m8.5 2.5c1.6568542 0 3 1.34314575 3 3v2c0 1.65685425-1.3431458 3-3 3-1.65685425 0-3-1.34314575-3-3v-2c0-1.65685425 1.34314575-3 3-3z"/></g></svg>
|
After Width: | Height: | Size: 519 B |
1
core/LightTube/wwwroot/icons/search.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="8.5" cy="8.5" r="5"/><path d="m17.571 17.5-5.571-5.5"/></g></svg>
|
After Width: | Height: | Size: 264 B |
1
core/LightTube/wwwroot/icons/settings.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 3)"><path d="m7.5.5c.35132769 0 .69661025.02588228 1.03404495.07584411l.50785434 1.53911115c.44544792.12730646.86820077.30839026 1.26078721.53578009l1.4600028-.70360861c.5166435.39719686.9762801.86487779 1.3645249 1.388658l-.7293289 1.44720284c.2201691.39604534.3936959.82158734.5131582 1.2692035l1.5298263.5338186c.0390082.29913986.0591302.60421522.0591302.91399032 0 .35132769-.0258823.69661025-.0758441 1.03404495l-1.5391112.50785434c-.1273064.44544792-.3083902.86820077-.5357801 1.26078721l.7036087 1.4600028c-.3971969.5166435-.8648778.9762801-1.388658 1.3645249l-1.4472029-.7293289c-.39604532.2201691-.82158732.3936959-1.26920348.5131582l-.5338186 1.5298263c-.29913986.0390082-.60421522.0591302-.91399032.0591302-.35132769 0-.69661025-.0258823-1.03404495-.0758441l-.50785434-1.5391112c-.44544792-.1273064-.86820077-.3083902-1.26078723-.5357801l-1.46000277.7036087c-.51664349-.3971969-.97628006-.8648778-1.36452491-1.388658l.72932886-1.4472029c-.2203328-.39633993-.39395403-.82222042-.51342462-1.27020241l-1.52968981-.53381682c-.03892294-.29882066-.05900023-.60356226-.05900023-.91299317 0-.35132769.02588228-.69661025.07584411-1.03404495l1.53911115-.50785434c.12730646-.44544792.30839026-.86820077.53578009-1.26078723l-.70360861-1.46000277c.39719686-.51664349.86487779-.97628006 1.388658-1.36452491l1.44720284.72932886c.39633995-.2203328.82222044-.39395403 1.27020243-.51342462l.53381682-1.52968981c.29882066-.03892294.60356226-.05900023.91299317-.05900023z" stroke-width=".933"/><circle cx="7.5" cy="7.5" r="3"/></g></svg>
|
After Width: | Height: | Size: 1.7 KiB |
1
core/LightTube/wwwroot/icons/subscriptions.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(2.5 4.5)"><path d="m3.65939616 0h8.68120764c.4000282 0 .7615663.23839685.9191451.6060807l2.7402511 6.3939193v4c0 1.1045695-.8954305 2-2 2h-12c-1.1045695 0-2-.8954305-2-2v-4l2.74025113-6.3939193c.15757879-.36768385.51911692-.6060807.91914503-.6060807z"/><path d="m0 7h4c.55228475 0 1 .44771525 1 1v1c0 .55228475.44771525 1 1 1h4c.5522847 0 1-.44771525 1-1v-1c0-.55228475.4477153-1 1-1h4"/></g></svg>
|
After Width: | Height: | Size: 606 B |
1
core/LightTube/wwwroot/icons/uncollapse_guide.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 3)"><path d="m.5 12.5v-10c0-1.1045695.8954305-2 2-2h10c1.1045695 0 2 .8954305 2 2v10c0 1.1045695-.8954305 2-2 2h-10c-1.1045695 0-2-.8954305-2-2z"/><path d="m12.5 12.5v-10c0-1.1045695-.8954305-2-2-2h2c1 0 2 .8954305 2 2v10c0 1.1045695-1 2-2 2h-2c1.1045695 0 2-.8954305 2-2z" fill="currentColor"/><path d="m7.5 10.5 3-3-3-3"/><path d="m10.5 7.5h-8"/></g></svg>
|
After Width: | Height: | Size: 568 B |
BIN
core/LightTube/wwwroot/img/player-error.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
core/LightTube/wwwroot/img/spinner.gif
Normal file
After Width: | Height: | Size: 32 KiB |
2
core/LightTube/wwwroot/js/hls.js/hls.min.js
vendored
Normal file
735
core/LightTube/wwwroot/js/lt-video/player-desktop.js
Normal file
|
@ -0,0 +1,735 @@
|
||||||
|
class Player {
|
||||||
|
constructor(query, info, sources, externalPlayer, externalPlayerType) {
|
||||||
|
// vars
|
||||||
|
this.externalPlayerType = externalPlayerType ?? "html5";
|
||||||
|
this.muted = false;
|
||||||
|
this.info = info;
|
||||||
|
this.sources = sources;
|
||||||
|
this.__videoElement = document.querySelector(query);
|
||||||
|
this.__videoElement.removeAttribute("controls");
|
||||||
|
this.__externalPlayer = externalPlayer;
|
||||||
|
|
||||||
|
// container
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.classList.add("player");
|
||||||
|
this.__videoElement.parentElement.appendChild(container);
|
||||||
|
container.appendChild(this.__videoElement);
|
||||||
|
this.container = container;
|
||||||
|
if (info.embed) {
|
||||||
|
this.container.classList.add("embed");
|
||||||
|
this.__videoElement.classList.remove("embed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// default source
|
||||||
|
switch (this.externalPlayerType) {
|
||||||
|
case "html5":
|
||||||
|
for (let source of sources) {
|
||||||
|
if (source.height <= 720) {
|
||||||
|
this.__videoElement.src = source.src;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "hls.js":
|
||||||
|
for (let level = this.__externalPlayer.levels.length - 1; level >= 0; level--) {
|
||||||
|
if (this.__externalPlayer.levels[level].height <= 720) {
|
||||||
|
this.__externalPlayer.currentLevel = level;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "shaka":
|
||||||
|
let variants = this.__externalPlayer.getVariantTracks();
|
||||||
|
for (let variant = variants.length - 1; variant >= 0; variant--) {
|
||||||
|
let v = variants[variant];
|
||||||
|
if (v.height <= 720) {
|
||||||
|
this.__externalPlayer.selectVariantTrack(v, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// controls
|
||||||
|
const createButton = (tag, icon) => {
|
||||||
|
const b = document.createElement(tag);
|
||||||
|
b.classList.add("player-button");
|
||||||
|
if (icon !== "")
|
||||||
|
b.innerHTML = `<i class="bi bi-${icon}"></i>`;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controls = {
|
||||||
|
play: createButton("div", "play-fill"),
|
||||||
|
pause: createButton("div", "pause-fill"),
|
||||||
|
volume: createButton("div", "volume-up-fill"),
|
||||||
|
time: document.createElement("span"),
|
||||||
|
skipToLive: createButton("div", "skip-forward-fill"),
|
||||||
|
div: document.createElement("div"),
|
||||||
|
settings: createButton("div", "gear-fill"),
|
||||||
|
embed: createButton("a", ""),
|
||||||
|
pip: createButton("div", "pip"),
|
||||||
|
fullscreen: createButton("div", "fullscreen")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!info.live) this.controls.skipToLive.style.display = "none"
|
||||||
|
|
||||||
|
const controlHolder = document.createElement("div");
|
||||||
|
controlHolder.classList.add("player-controls");
|
||||||
|
|
||||||
|
this.controls.embed.innerHTML = "<span style='text-align: center; width: 100%'>l<b>t</b></span>";
|
||||||
|
this.controls.embed.setAttribute("target", "_blank");
|
||||||
|
this.controls.embed.setAttribute("href", "/watch?v=" + info.id);
|
||||||
|
if (!info.embed) this.controls.embed.style.display = "none";
|
||||||
|
|
||||||
|
const els = [
|
||||||
|
document.createElement("div"),
|
||||||
|
document.createElement("div"),
|
||||||
|
]
|
||||||
|
for (const padding of els)
|
||||||
|
padding.classList.add("player-controls-padding");
|
||||||
|
|
||||||
|
controlHolder.appendChild(els[0]);
|
||||||
|
for (const control of Object.values(this.controls)) {
|
||||||
|
controlHolder.appendChild(control);
|
||||||
|
}
|
||||||
|
controlHolder.appendChild(els[1]);
|
||||||
|
container.appendChild(controlHolder);
|
||||||
|
|
||||||
|
this.controls.play.onclick = () => this.togglePlayPause();
|
||||||
|
this.controls.pause.onclick = () => this.togglePlayPause();
|
||||||
|
this.controls.volume.onclick = e => this.mute(e);
|
||||||
|
this.controls.volume.classList.add("player-volume");
|
||||||
|
this.controls.fullscreen.onclick = () => this.fullscreen();
|
||||||
|
this.controls.skipToLive.onclick = () => this.skipToLive();
|
||||||
|
|
||||||
|
if (document.pictureInPictureEnabled === true)
|
||||||
|
this.controls.pip.onclick = () => this.pip();
|
||||||
|
else
|
||||||
|
this.controls.pip.style.display = "none";
|
||||||
|
|
||||||
|
let vol = null;
|
||||||
|
if (localStorage !== undefined)
|
||||||
|
vol = localStorage?.getItem("ltvideo.volume");
|
||||||
|
let volumeRange = document.createElement("input");
|
||||||
|
volumeRange.oninput = e => this.setVolume(e);
|
||||||
|
volumeRange.setAttribute("min", "0");
|
||||||
|
volumeRange.setAttribute("max", "1");
|
||||||
|
volumeRange.setAttribute("step", "0.01");
|
||||||
|
volumeRange.setAttribute("value", vol ?? "1");
|
||||||
|
volumeRange.setAttribute("type", "range");
|
||||||
|
if (vol != null)
|
||||||
|
this.setVolume({target: {value: Number(vol)}});
|
||||||
|
this.controls.volume.appendChild(volumeRange);
|
||||||
|
|
||||||
|
this.controls.div.classList.add("player-button-divider")
|
||||||
|
|
||||||
|
// playback bar
|
||||||
|
this.playbackBar = {
|
||||||
|
bg: document.createElement("div"),
|
||||||
|
played: document.createElement("div"),
|
||||||
|
buffered: document.createElement("div"),
|
||||||
|
hover: document.createElement("div"),
|
||||||
|
sb: document.createElement("div"),
|
||||||
|
sbC: document.createElement("div"),
|
||||||
|
hoverText: document.createElement("span")
|
||||||
|
}
|
||||||
|
this.playbackBar.bg.classList.add("player-playback-bar");
|
||||||
|
this.playbackBar.bg.classList.add("player-playback-bar-bg");
|
||||||
|
this.playbackBar.played.classList.add("player-playback-bar");
|
||||||
|
this.playbackBar.played.classList.add("player-playback-bar-fg");
|
||||||
|
this.playbackBar.buffered.classList.add("player-playback-bar");
|
||||||
|
this.playbackBar.buffered.classList.add("player-playback-bar-buffer");
|
||||||
|
this.playbackBar.bg.appendChild(this.playbackBar.buffered);
|
||||||
|
this.playbackBar.bg.appendChild(this.playbackBar.played);
|
||||||
|
|
||||||
|
this.playbackBar.hover.classList.add("player-playback-bar-hover");
|
||||||
|
if (!this.info.live) {
|
||||||
|
this.playbackBar.sb.classList.add("player-storyboard-image");
|
||||||
|
this.playbackBar.sbC.classList.add("player-storyboard-image-container");
|
||||||
|
this.playbackBar.sb.style.backgroundImage = `url("/proxy/storyboard/${info.id}")`;
|
||||||
|
this.playbackBar.sbC.appendChild(this.playbackBar.sb);
|
||||||
|
} else {
|
||||||
|
this.playbackBar.sb.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
let playbackBarContainer = document.createElement("div");
|
||||||
|
playbackBarContainer.classList.add("player-playback-bar-container")
|
||||||
|
this.playbackBar.bg.onclick = e => {
|
||||||
|
this.playbackBarSeek(e)
|
||||||
|
}
|
||||||
|
this.playbackBar.bg.ondragover = e => {
|
||||||
|
this.playbackBarSeek(e)
|
||||||
|
}
|
||||||
|
this.playbackBar.bg.onmouseenter = () => {
|
||||||
|
this.playbackBar.hover.style.display = "block";
|
||||||
|
}
|
||||||
|
this.playbackBar.bg.onmouseleave = () => {
|
||||||
|
this.playbackBar.hover.style.display = "none";
|
||||||
|
}
|
||||||
|
this.playbackBar.bg.onmousemove = e => {
|
||||||
|
this.moveHover(e)
|
||||||
|
}
|
||||||
|
playbackBarContainer.appendChild(this.playbackBar.bg);
|
||||||
|
this.playbackBar.hover.appendChild(this.playbackBar.sbC)
|
||||||
|
this.playbackBar.hover.appendChild(this.playbackBar.hoverText)
|
||||||
|
playbackBarContainer.appendChild(this.playbackBar.hover);
|
||||||
|
container.appendChild(playbackBarContainer);
|
||||||
|
|
||||||
|
// title
|
||||||
|
this.titleElement = document.createElement("div");
|
||||||
|
this.titleElement.classList.add("player-title");
|
||||||
|
this.titleElement.innerText = info.title;
|
||||||
|
container.appendChild(this.titleElement);
|
||||||
|
if (!info.embed)
|
||||||
|
this.titleElement.style.display = "none";
|
||||||
|
|
||||||
|
// events
|
||||||
|
container.onfullscreenchange = () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
this.controls.fullscreen.querySelector("i").setAttribute("class", "bi bi-fullscreen");
|
||||||
|
if (!info.embed)
|
||||||
|
this.titleElement.style.display = "none";
|
||||||
|
} else {
|
||||||
|
this.titleElement.style.display = "block";
|
||||||
|
this.controls.fullscreen.querySelector("i").setAttribute("class", "bi bi-fullscreen-exit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updatePlayButtons = () => {
|
||||||
|
if (this.__videoElement.paused) {
|
||||||
|
this.controls.pause.style.display = "none";
|
||||||
|
this.controls.play.style.display = "block";
|
||||||
|
} else {
|
||||||
|
this.controls.pause.style.display = "block";
|
||||||
|
this.controls.play.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.__videoElement.onplay = () => updatePlayButtons();
|
||||||
|
this.__videoElement.onpause = () => updatePlayButtons();
|
||||||
|
updatePlayButtons();
|
||||||
|
this.__videoElement.onclick = () => this.togglePlayPause();
|
||||||
|
this.__videoElement.ondblclick = () => this.fullscreen();
|
||||||
|
this.container.onkeydown = e => this.keyboardHandler(e);
|
||||||
|
|
||||||
|
this.container.onmousemove = () => {
|
||||||
|
let d = new Date();
|
||||||
|
d.setSeconds(d.getSeconds() + 3);
|
||||||
|
this.controlsDisappearTimeout = d.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.externalPlayerType) {
|
||||||
|
case "shaka":
|
||||||
|
externalPlayer.addEventListener("variantchanged", () => {
|
||||||
|
this.updateMenu();
|
||||||
|
});
|
||||||
|
externalPlayer.addEventListener('error', this.fallbackFromShaka);
|
||||||
|
break;
|
||||||
|
case "hls.js":
|
||||||
|
// uhhhhhh...
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// menu
|
||||||
|
this.controls.settings.onclick = e => this.menuButtonClick(e);
|
||||||
|
this.controls.settings.setAttribute("data-action", "toggle");
|
||||||
|
this.controls.settings.querySelector("i").setAttribute("data-action", "toggle");
|
||||||
|
this.updateMenu(sources);
|
||||||
|
|
||||||
|
// buffering
|
||||||
|
this.bufferingScreen = document.createElement("div");
|
||||||
|
this.bufferingScreen.classList.add("player-buffering");
|
||||||
|
this.container.appendChild(this.bufferingScreen);
|
||||||
|
|
||||||
|
let bufferingSpinner = document.createElement("img");
|
||||||
|
bufferingSpinner.classList.add("player-buffering-spinner");
|
||||||
|
bufferingSpinner.src = "/img/spinner.gif";
|
||||||
|
this.bufferingScreen.appendChild(bufferingSpinner);
|
||||||
|
|
||||||
|
setInterval(() => this.update(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlayPause() {
|
||||||
|
if (this.__videoElement.paused)
|
||||||
|
this.__videoElement.play();
|
||||||
|
else
|
||||||
|
this.__videoElement.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMenu() {
|
||||||
|
const makeButton = (label, action, icon) => {
|
||||||
|
const b = document.createElement("div");
|
||||||
|
//todo: yes fix this
|
||||||
|
b.innerHTML = `<i class="bi bi-${icon}"></i>${label}`;
|
||||||
|
b.onclick = e => this.menuButtonClick(e);
|
||||||
|
b.setAttribute("data-action", action)
|
||||||
|
b.classList.add("player-menu-item")
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeMenu = (id, buttons) => {
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.id = id;
|
||||||
|
for (const button of buttons) {
|
||||||
|
menu.appendChild(makeButton(button.label, button.action, button.icon));
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.menuElement) {
|
||||||
|
this.menuElement.remove();
|
||||||
|
this.menuElement = undefined;
|
||||||
|
}
|
||||||
|
this.menuElement = document.createElement("div");
|
||||||
|
this.menuElement.classList.add("player-menu");
|
||||||
|
this.menuElement.appendChild(makeMenu("menu-main", [
|
||||||
|
{
|
||||||
|
icon: "sliders",
|
||||||
|
label: "Quality",
|
||||||
|
action: "menu res"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "badge-cc",
|
||||||
|
label: "Subtitles",
|
||||||
|
action: "menu sub"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "speedometer2",
|
||||||
|
label: "Speed",
|
||||||
|
action: "menu speed"
|
||||||
|
}
|
||||||
|
]))
|
||||||
|
const resButtons = [
|
||||||
|
{
|
||||||
|
icon: "arrow-left",
|
||||||
|
label: "Back",
|
||||||
|
action: "menu main"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
switch (this.externalPlayerType) {
|
||||||
|
case "html5":
|
||||||
|
for (const index in this.sources) {
|
||||||
|
resButtons.push({
|
||||||
|
icon: this.sources[index].src === this.__videoElement.src ? "check2" : "",
|
||||||
|
label: this.sources[index].label,
|
||||||
|
action: "videosrc " + index
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "shaka":
|
||||||
|
resButtons.pop();
|
||||||
|
let tracks = this.__externalPlayer.getVariantTracks();
|
||||||
|
for (const index in tracks) {
|
||||||
|
if (tracks[index].audioId === 2)
|
||||||
|
resButtons.unshift({
|
||||||
|
icon: tracks[index].active ? "check2" : "",
|
||||||
|
label: tracks[index].height + "p",
|
||||||
|
action: "shakavariant " + index
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resButtons.unshift({
|
||||||
|
icon: this.__externalPlayer.getConfiguration().abr.enabled ? "check2" : "",
|
||||||
|
label: "Auto",
|
||||||
|
action: "shakavariant -1"
|
||||||
|
});
|
||||||
|
resButtons.unshift(
|
||||||
|
{
|
||||||
|
icon: "arrow-left",
|
||||||
|
label: "Back",
|
||||||
|
action: "menu main"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "hls.js":
|
||||||
|
resButtons.pop();
|
||||||
|
for (const level in this.__externalPlayer.levels) {
|
||||||
|
resButtons.unshift({
|
||||||
|
icon: level === this.__externalPlayer.currentLevel ? "check2" : "",
|
||||||
|
label: this.__externalPlayer.levels[level].height + "p",
|
||||||
|
action: "hlslevel " + level
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resButtons.unshift(
|
||||||
|
{
|
||||||
|
icon: -1 === this.__externalPlayer.currentLevel ? "check2" : "",
|
||||||
|
label: "Auto",
|
||||||
|
action: "hlslevel -1"
|
||||||
|
});
|
||||||
|
resButtons.unshift(
|
||||||
|
{
|
||||||
|
icon: "arrow-left",
|
||||||
|
label: "Back",
|
||||||
|
action: "menu main"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.menuElement.appendChild(makeMenu("menu-res", resButtons));
|
||||||
|
const subButtons = [
|
||||||
|
{
|
||||||
|
icon: "arrow-left",
|
||||||
|
label: "Back",
|
||||||
|
action: "menu main"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let index = 0; index < this.__videoElement.textTracks.length; index++) {
|
||||||
|
if (this.__videoElement.textTracks[index].label.includes("Shaka Player")) continue;
|
||||||
|
subButtons.push({
|
||||||
|
icon: this.__videoElement.textTracks[index].mode === "showing" ? "check2" : "",
|
||||||
|
label: this.__videoElement.textTracks[index].label,
|
||||||
|
action: "texttrack " + index
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.menuElement.appendChild(makeMenu("menu-sub", subButtons));
|
||||||
|
this.menuElement.appendChild(makeMenu("menu-speed", [
|
||||||
|
{
|
||||||
|
icon: "arrow-left",
|
||||||
|
label: "Back",
|
||||||
|
action: "menu main"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: this.__videoElement.playbackRate === 0.25 ? "check2" : "",
|
||||||
|
label: "0.25",
|
||||||
|
action: "speed 0.25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: this.__videoElement.playbackRate === 0.50 ? "check2" : "",
|
||||||
|
label: "0.50",
|
||||||
|
action: "speed 0.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: this.__videoElement.playbackRate === 0.75 ? "check2" : "",
|
||||||
|
label: "0.75",
|
||||||
|
action: "speed 0.75"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: this.__videoElement.playbackRate === 1 ? "check2" : "",
|
||||||
|
label: "Normal",
|
||||||
|
action: "speed 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: this.__videoElement.playbackRate === 1.25 ? "check2" : "",
|
||||||
|
label: "1.25",
|
||||||
|
action: "speed 1.25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: this.__videoElement.playbackRate === 1.50 ? "check2" : "",
|
||||||
|
label: "1.50",
|
||||||
|
action: "speed 1.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: this.__videoElement.playbackRate === 1.75 ? "check2" : "",
|
||||||
|
label: "1.75",
|
||||||
|
action: "speed 1.75"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: this.__videoElement.playbackRate === 2 ? "check2" : "",
|
||||||
|
label: "2",
|
||||||
|
action: "speed 2"
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
|
||||||
|
this.container.appendChild(this.menuElement);
|
||||||
|
for (const child of this.menuElement.children) {
|
||||||
|
if (child.tagName === "DIV")
|
||||||
|
child.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openMenu(id) {
|
||||||
|
for (const child of this.menuElement.children) {
|
||||||
|
if (child.tagName === "DIV")
|
||||||
|
child.style.display = "none";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.menuElement.querySelector("#menu-" + id).style.display = "block";
|
||||||
|
} catch {
|
||||||
|
// intended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
menuButtonClick(e) {
|
||||||
|
let args = (e.target.getAttribute("data-action") ?? e.target.parentElement.getAttribute("data-action")).split(" ");
|
||||||
|
let command = args.shift();
|
||||||
|
let closeMenu = true;
|
||||||
|
switch (command) {
|
||||||
|
case "toggle":
|
||||||
|
closeMenu = this.menuElement.clientHeight !== 0;
|
||||||
|
if (!closeMenu)
|
||||||
|
this.openMenu("main");
|
||||||
|
break;
|
||||||
|
case "menu":
|
||||||
|
this.openMenu(args[0]);
|
||||||
|
closeMenu = false;
|
||||||
|
break;
|
||||||
|
case "speed":
|
||||||
|
this.__videoElement.playbackRate = Number.parseFloat(args[0]);
|
||||||
|
this.updateMenu();
|
||||||
|
break;
|
||||||
|
case "texttrack":
|
||||||
|
let i = Number.parseFloat(args[0]);
|
||||||
|
for (let index = 0; index < this.__videoElement.textTracks.length; index++) {
|
||||||
|
this.__videoElement.textTracks[index].mode = "hidden";
|
||||||
|
|
||||||
|
}
|
||||||
|
this.__videoElement.textTracks[i].mode = "showing";
|
||||||
|
this.updateMenu();
|
||||||
|
break;
|
||||||
|
case "videosrc":
|
||||||
|
let time = this.__videoElement.currentTime;
|
||||||
|
let shouldPlay = !this.__videoElement.paused;
|
||||||
|
this.__videoElement.src = this.sources[Number.parseFloat(args[0])].src;
|
||||||
|
this.__videoElement.currentTime = time;
|
||||||
|
if (shouldPlay)
|
||||||
|
this.__videoElement.play();
|
||||||
|
this.updateMenu();
|
||||||
|
break;
|
||||||
|
case "shakavariant":
|
||||||
|
if (args[0] !== "-1")
|
||||||
|
this.__externalPlayer.selectVariantTrack(this.__externalPlayer.getVariantTracks()[Number.parseFloat(args[0])], true, 2)
|
||||||
|
this.__externalPlayer.configure({abr: {enabled: args[0] === "-1"}})
|
||||||
|
break;
|
||||||
|
case "hlslevel":
|
||||||
|
this.__externalPlayer.nextLevel = Number.parseInt(args[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (closeMenu)
|
||||||
|
this.openMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
mute(e) {
|
||||||
|
if (e.target.tagName === "INPUT") return;
|
||||||
|
this.muted = !this.muted;
|
||||||
|
if (this.muted) {
|
||||||
|
this.controls.volume.querySelector("i").setAttribute("class", "bi bi-volume-mute-fill");
|
||||||
|
this.__videoElement.volume = 0;
|
||||||
|
} else {
|
||||||
|
this.controls.volume.querySelector("i").setAttribute("class", "bi bi-volume-up-fill");
|
||||||
|
this.__videoElement.volume = this.controls.volume.querySelector("input").value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
this.container.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pip() {
|
||||||
|
this.__videoElement.requestPictureInPicture();
|
||||||
|
}
|
||||||
|
|
||||||
|
timeUpdate() {
|
||||||
|
if (this.info.live) {
|
||||||
|
let timeBack = this.__videoElement.duration - this.__videoElement.currentTime;
|
||||||
|
this.controls.time.innerHTML = timeBack > 10 ? this.getTimeString(timeBack) : "LIVE";
|
||||||
|
} else
|
||||||
|
this.controls.time.innerHTML = this.getTimeString(this.__videoElement.currentTime) + " / " + this.getTimeString(this.__videoElement.duration);
|
||||||
|
this.playbackBar.played.style.width = ((this.__videoElement.currentTime / this.__videoElement.duration) * 100) + "%";
|
||||||
|
this.playbackBar.buffered.style.width = ((this.getLoadEnd() / this.__videoElement.duration) * 100) + "%";
|
||||||
|
|
||||||
|
if (this.controlsDisappearTimeout - Date.now() < 0 && !this.container.classList.contains("hide-controls") && !this.__videoElement.paused)
|
||||||
|
this.container.classList.add("hide-controls");
|
||||||
|
|
||||||
|
|
||||||
|
if (this.controlsDisappearTimeout - Date.now() > 0 && this.container.classList.contains("hide-controls"))
|
||||||
|
this.container.classList.remove("hide-controls");
|
||||||
|
|
||||||
|
if (this.__videoElement.paused && this.container.classList.contains("hide-controls"))
|
||||||
|
this.container.classList.add("hide-controls");
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(e) {
|
||||||
|
this.__videoElement.volume = e.target.value;
|
||||||
|
localStorage.setItem("ltvideo.volume", e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLoadEnd() {
|
||||||
|
let longest = -1;
|
||||||
|
for (let i = 0; i < this.__videoElement.buffered.length; i++) {
|
||||||
|
const end = this.__videoElement.buffered.end(i);
|
||||||
|
if (end > longest) longest = end;
|
||||||
|
}
|
||||||
|
return longest;
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackBarSeek(e) {
|
||||||
|
let percentage = (e.offsetX / (this.playbackBar.bg.clientLeft + this.playbackBar.bg.clientWidth));
|
||||||
|
this.playbackBar.played.style.width = (percentage * 100) + "%";
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * percentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveHover(e) {
|
||||||
|
let percentage = (e.offsetX / (this.playbackBar.bg.clientLeft + this.playbackBar.bg.clientWidth));
|
||||||
|
let rPercent = Math.round(percentage * 100);
|
||||||
|
|
||||||
|
if (!this.info.live) {
|
||||||
|
this.playbackBar.sb.style.backgroundPositionX = `-${rPercent % 10 * 48}px`;
|
||||||
|
this.playbackBar.sb.style.backgroundPositionY = `-${Math.floor(rPercent / 10) * 27}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playbackBar.hover.style.top = (this.playbackBar.bg.getBoundingClientRect().y - 4 - this.playbackBar.hover.clientHeight) + 'px';
|
||||||
|
this.playbackBar.hover.style.left = (e.clientX - this.playbackBar.hover.clientWidth / 2) + 'px';
|
||||||
|
this.playbackBar.hoverText.innerText = this.getTimeString(this.__videoElement.duration * percentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
skipToLive() {
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardHandler(e) {
|
||||||
|
let pd = true;
|
||||||
|
if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
switch (e.code) {
|
||||||
|
case "Space":
|
||||||
|
this.togglePlayPause();
|
||||||
|
break;
|
||||||
|
case "Digit1":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * 0.1;
|
||||||
|
break;
|
||||||
|
case "Digit2":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * 0.2;
|
||||||
|
break;
|
||||||
|
case "Digit3":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * 0.3;
|
||||||
|
break;
|
||||||
|
case "Digit4":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * 0.4;
|
||||||
|
break;
|
||||||
|
case "Digit5":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * 0.5;
|
||||||
|
break;
|
||||||
|
case "Digit6":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * 0.6;
|
||||||
|
break;
|
||||||
|
case "Digit7":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * 0.7;
|
||||||
|
break;
|
||||||
|
case "Digit8":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * 0.8;
|
||||||
|
break;
|
||||||
|
case "Digit9":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * 0.9;
|
||||||
|
break;
|
||||||
|
case "Digit0":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime = 0;
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime -= 5;
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.currentTime += 5;
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.volume += 0.1;
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
if (!this.info.live)
|
||||||
|
this.__videoElement.volume -= 0.1;
|
||||||
|
break;
|
||||||
|
case "KeyF":
|
||||||
|
this.fullscreen();
|
||||||
|
break;
|
||||||
|
case "KeyM":
|
||||||
|
this.mute({target: {tagName: ""}});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
pd = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (pd) e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeString(s) {
|
||||||
|
let res = s < 3600 ? new Date(s * 1000).toISOString().substr(14, 5) : new Date(s * 1000).toISOString().substr(11, 8);
|
||||||
|
if (res.startsWith("0"))
|
||||||
|
res = res.substr(1);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.timeUpdate();
|
||||||
|
|
||||||
|
if (this.info.live) {
|
||||||
|
let timeBack = Math.abs(this.__videoElement.currentTime - this.__videoElement.buffered.end(this.__videoElement.buffered.length - 1));
|
||||||
|
this.bufferingScreen.style.display = timeBack < .1 ? "flex" : "none";
|
||||||
|
} else {
|
||||||
|
switch (this.__videoElement.readyState) {
|
||||||
|
case 1:
|
||||||
|
this.bufferingScreen.style.display = "flex";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.bufferingScreen.style.display = "none";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fallbackFromShaka() {
|
||||||
|
if (this.externalPlayerType !== "shaka") return;
|
||||||
|
this.externalPlayerType = "html5";
|
||||||
|
console.log("Shaka player crashed, falling back");
|
||||||
|
let cTime = this.__videoElement.currentTime;
|
||||||
|
await this.__externalPlayer.detach();
|
||||||
|
await this.__externalPlayer.destroy();
|
||||||
|
this.__videoElement.src = this.sources[0].src;
|
||||||
|
this.__externalPlayer = undefined;
|
||||||
|
this.__videoElement.currentTime = cTime;
|
||||||
|
this.updateMenu();
|
||||||
|
console.log("Fallback complete!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPlayerWithShaka = async (query, info, sources, manifestUri) => {
|
||||||
|
let player;
|
||||||
|
if (manifestUri !== undefined) {
|
||||||
|
shaka.polyfill.installAll();
|
||||||
|
let shakaUsable = shaka.Player.isBrowserSupported();
|
||||||
|
|
||||||
|
if (shakaUsable) {
|
||||||
|
const video = document.querySelector(query);
|
||||||
|
player = new shaka.Player(video);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await player.load(manifestUri);
|
||||||
|
} catch (e) {
|
||||||
|
await player.destroy();
|
||||||
|
return new Player(query, info, sources, undefined, "html5");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Player(query, info, sources, await player, "shaka");
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPlayerWithHls = (query, info, manifestUri) => {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
let hls;
|
||||||
|
|
||||||
|
const video = document.querySelector(query);
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
hls = new Hls();
|
||||||
|
hls.loadSource(manifestUri);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {
|
||||||
|
res(new Player(query, info, [], hls, "hls.js"));
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
rej("You can't watch livestreams / premieres because hls.js is not supported in your browser.")
|
||||||
|
})
|
||||||
|
}
|
292
core/LightTube/wwwroot/js/lt-video/player-mobile.js
Normal file
|
@ -0,0 +1,292 @@
|
||||||
|
class Player {
|
||||||
|
constructor(query, info, sources, externalPlayer, externalPlayerType) {
|
||||||
|
// vars
|
||||||
|
this.externalPlayerType = externalPlayerType ?? "html5";
|
||||||
|
this.muted = false;
|
||||||
|
this.info = info;
|
||||||
|
this.sources = sources;
|
||||||
|
this.__videoElement = document.querySelector(query);
|
||||||
|
this.__videoElement.removeAttribute("controls");
|
||||||
|
this.__externalPlayer = externalPlayer;
|
||||||
|
|
||||||
|
// container
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.classList.add("player");
|
||||||
|
this.__videoElement.parentElement.appendChild(container);
|
||||||
|
container.appendChild(this.__videoElement);
|
||||||
|
this.container = container;
|
||||||
|
if (info.embed) {
|
||||||
|
this.container.classList.add("embed");
|
||||||
|
this.__videoElement.classList.remove("embed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// default source
|
||||||
|
switch (this.externalPlayerType) {
|
||||||
|
case "html5":
|
||||||
|
for (let source of sources) {
|
||||||
|
if (source.height <= 720) {
|
||||||
|
this.__videoElement.src = source.src;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "hls.js":
|
||||||
|
for (let level = this.__externalPlayer.levels.length - 1; level >= 0; level--) {
|
||||||
|
if (this.__externalPlayer.levels[level].height <= 720) {
|
||||||
|
this.__externalPlayer.currentLevel = level;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "shaka":
|
||||||
|
this.__externalPlayer.configure({abr: {enabled: false}})
|
||||||
|
let variants = this.__externalPlayer.getVariantTracks();
|
||||||
|
for (let variant = variants.length - 1; variant >= 0; variant--) {
|
||||||
|
let v = variants[variant];
|
||||||
|
if (v.height <= 720) {
|
||||||
|
this.__externalPlayer.selectVariantTrack(v, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// controls
|
||||||
|
const createButton = (tag, icon) => {
|
||||||
|
const b = document.createElement(tag);
|
||||||
|
b.classList.add("player-button");
|
||||||
|
if (icon !== "")
|
||||||
|
b.innerHTML = `<i class="bi bi-${icon}"></i>`;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controls = {
|
||||||
|
container: document.createElement("div"),
|
||||||
|
play: createButton("div", "play-fill"),
|
||||||
|
fullscreen: createButton("div", "fullscreen"),
|
||||||
|
time: document.createElement("span"),
|
||||||
|
duration: document.createElement("span"),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controls.container.classList.add("player-controls");
|
||||||
|
this.controls.container.appendChild(this.controls.play);
|
||||||
|
this.controls.fullscreen.classList.replace("player-button", "player-tiny-button")
|
||||||
|
container.appendChild(this.controls.container);
|
||||||
|
|
||||||
|
this.controls.play.onclick = () => this.togglePlayPause();
|
||||||
|
this.controls.fullscreen.onclick = () => this.fullscreen();
|
||||||
|
this.setVolume({target: {value: 1}});
|
||||||
|
|
||||||
|
// playback bar
|
||||||
|
this.playbackBar = {
|
||||||
|
bg: document.createElement("div"),
|
||||||
|
played: document.createElement("div"),
|
||||||
|
buffered: document.createElement("div")
|
||||||
|
}
|
||||||
|
this.playbackBar.bg.classList.add("player-playback-bar");
|
||||||
|
this.playbackBar.bg.classList.add("player-playback-bar-bg");
|
||||||
|
this.playbackBar.played.classList.add("player-playback-bar");
|
||||||
|
this.playbackBar.played.classList.add("player-playback-bar-fg");
|
||||||
|
this.playbackBar.buffered.classList.add("player-playback-bar");
|
||||||
|
this.playbackBar.buffered.classList.add("player-playback-bar-buffer");
|
||||||
|
this.playbackBar.bg.appendChild(this.playbackBar.buffered);
|
||||||
|
this.playbackBar.bg.appendChild(this.playbackBar.played);
|
||||||
|
|
||||||
|
let playbackBarContainer = document.createElement("div");
|
||||||
|
playbackBarContainer.classList.add("player-playback-bar-container")
|
||||||
|
this.playbackBar.bg.onclick = e => {
|
||||||
|
this.playbackBarSeek(e)
|
||||||
|
}
|
||||||
|
playbackBarContainer.appendChild(this.controls.time);
|
||||||
|
playbackBarContainer.appendChild(this.playbackBar.bg);
|
||||||
|
playbackBarContainer.appendChild(this.controls.duration);
|
||||||
|
playbackBarContainer.appendChild(this.controls.fullscreen);
|
||||||
|
container.appendChild(playbackBarContainer);
|
||||||
|
|
||||||
|
|
||||||
|
// events
|
||||||
|
container.onfullscreenchange = () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
this.controls.fullscreen.querySelector("i").setAttribute("class", "bi bi-fullscreen");
|
||||||
|
} else {
|
||||||
|
this.controls.fullscreen.querySelector("i").setAttribute("class", "bi bi-fullscreen-exit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updatePlayButtons = () => {
|
||||||
|
if (this.__videoElement.paused) {
|
||||||
|
this.controls.play.querySelector("i").classList.replace("bi-pause-fill", "bi-play-fill");
|
||||||
|
} else {
|
||||||
|
this.controls.play.querySelector("i").classList.replace("bi-play-fill", "bi-pause-fill");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.__videoElement.onplay = () => updatePlayButtons();
|
||||||
|
this.__videoElement.onpause = () => updatePlayButtons();
|
||||||
|
updatePlayButtons();
|
||||||
|
this.__videoElement.onclick = e => this.toggleControls(e);
|
||||||
|
this.controls.container.onclick = e => this.toggleControls(e);
|
||||||
|
this.__videoElement.onclick = e => this.toggleControls(e);
|
||||||
|
|
||||||
|
switch (this.externalPlayerType) {
|
||||||
|
case "shaka":
|
||||||
|
externalPlayer.addEventListener("variantchanged", () => {
|
||||||
|
this.updateMenu();
|
||||||
|
});
|
||||||
|
externalPlayer.addEventListener('error', this.fallbackFromShaka);
|
||||||
|
break;
|
||||||
|
case "hls.js":
|
||||||
|
// uhhhhhh...
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buffering
|
||||||
|
this.bufferingScreen = document.createElement("div");
|
||||||
|
this.bufferingScreen.classList.add("player-buffering");
|
||||||
|
this.container.appendChild(this.bufferingScreen);
|
||||||
|
|
||||||
|
let bufferingSpinner = document.createElement("img");
|
||||||
|
bufferingSpinner.classList.add("player-buffering-spinner");
|
||||||
|
bufferingSpinner.src = "/img/spinner.gif";
|
||||||
|
this.bufferingScreen.appendChild(bufferingSpinner);
|
||||||
|
|
||||||
|
setInterval(() => this.update(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlayPause(e) {
|
||||||
|
if (this.__videoElement.paused)
|
||||||
|
this.__videoElement.play();
|
||||||
|
else
|
||||||
|
this.__videoElement.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMenu() {
|
||||||
|
// todo: mobile resolution switching
|
||||||
|
}
|
||||||
|
|
||||||
|
fullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
this.container.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeUpdate() {
|
||||||
|
if (this.info.live) {
|
||||||
|
let timeBack = this.__videoElement.duration - this.__videoElement.currentTime;
|
||||||
|
this.controls.time.innerHTML = timeBack > 10 ? this.getTimeString(timeBack) : "LIVE";
|
||||||
|
} else {
|
||||||
|
this.controls.time.innerHTML = this.getTimeString(this.__videoElement.currentTime);
|
||||||
|
this.controls.duration.innerHTML = this.getTimeString(this.__videoElement.duration);
|
||||||
|
}
|
||||||
|
this.playbackBar.played.style.width = ((this.__videoElement.currentTime / this.__videoElement.duration) * 100) + "%";
|
||||||
|
this.playbackBar.buffered.style.width = ((this.getLoadEnd() / this.__videoElement.duration) * 100) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(e) {
|
||||||
|
this.__videoElement.volume = 1;
|
||||||
|
localStorage.setItem("ltvideo.volume", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLoadEnd() {
|
||||||
|
let longest = -1;
|
||||||
|
for (let i = 0; i < this.__videoElement.buffered.length; i++) {
|
||||||
|
const end = this.__videoElement.buffered.end(i);
|
||||||
|
if (end > longest) longest = end;
|
||||||
|
}
|
||||||
|
return longest;
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackBarSeek(e) {
|
||||||
|
let percentage = (e.offsetX / (this.playbackBar.bg.clientLeft + this.playbackBar.bg.clientWidth));
|
||||||
|
this.playbackBar.played.style.width = (percentage * 100) + "%";
|
||||||
|
this.__videoElement.currentTime = this.__videoElement.duration * percentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeString(s) {
|
||||||
|
let res = s < 3600 ? new Date(s * 1000).toISOString().substr(14, 5) : new Date(s * 1000).toISOString().substr(11, 8);
|
||||||
|
if (res.startsWith("0"))
|
||||||
|
res = res.substr(1);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.timeUpdate();
|
||||||
|
|
||||||
|
if (this.info.live) {
|
||||||
|
let timeBack = Math.abs(this.__videoElement.currentTime - this.__videoElement.buffered.end(this.__videoElement.buffered.length - 1));
|
||||||
|
this.bufferingScreen.style.display = timeBack < .1 ? "flex" : "none";
|
||||||
|
} else {
|
||||||
|
switch (this.__videoElement.readyState) {
|
||||||
|
case 1:
|
||||||
|
this.bufferingScreen.style.display = "flex";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.bufferingScreen.style.display = "none";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fallbackFromShaka() {
|
||||||
|
if (this.externalPlayerType !== "shaka") return;
|
||||||
|
this.externalPlayerType = "html5";
|
||||||
|
console.log("Shaka player crashed, falling back");
|
||||||
|
let cTime = this.__videoElement.currentTime;
|
||||||
|
await this.__externalPlayer.detach();
|
||||||
|
await this.__externalPlayer.destroy();
|
||||||
|
this.__videoElement.src = this.sources[0].src;
|
||||||
|
this.__externalPlayer = undefined;
|
||||||
|
this.__videoElement.currentTime = cTime;
|
||||||
|
this.updateMenu();
|
||||||
|
console.log("Fallback complete!");
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleControls(e) {
|
||||||
|
if (["DIV", "VIDEO"].includes(e.target.tagName))
|
||||||
|
if (this.container.classList.contains("hide-controls")) {
|
||||||
|
this.container.classList.remove("hide-controls")
|
||||||
|
} else {
|
||||||
|
this.container.classList.add("hide-controls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPlayerWithShaka = async (query, info, sources, manifestUri) => {
|
||||||
|
let player;
|
||||||
|
if (manifestUri !== undefined) {
|
||||||
|
shaka.polyfill.installAll();
|
||||||
|
let shakaUsable = shaka.Player.isBrowserSupported();
|
||||||
|
|
||||||
|
if (shakaUsable) {
|
||||||
|
const video = document.querySelector(query);
|
||||||
|
player = new shaka.Player(video);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await player.load(manifestUri);
|
||||||
|
} catch (e) {
|
||||||
|
await player.destroy();
|
||||||
|
return new Player(query, info, sources, undefined, "html5");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Player(query, info, sources, await player, "shaka");
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPlayerWithHls = (query, info, manifestUri) => {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
let hls;
|
||||||
|
|
||||||
|
const video = document.querySelector(query);
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
hls = new Hls();
|
||||||
|
hls.loadSource(manifestUri);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {
|
||||||
|
res(new Player(query, info, [], hls, "hls.js"));
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
rej("You can't watch livestreams / premieres because hls.js is not supported in your browser.")
|
||||||
|
})
|
||||||
|
}
|
32
core/LightTube/wwwroot/js/shaka-player/shaka-player.compiled.min.js
vendored
Normal file
43
core/LightTube/wwwroot/js/site.js
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
const subscribeButtons = document.querySelectorAll("button.subscribe-button");
|
||||||
|
const subscribeToChannel = (e) => {
|
||||||
|
const channelId = e.target.attributes["data-cid"].value;
|
||||||
|
e.target.disabled = true;
|
||||||
|
|
||||||
|
let xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("GET", "/Account/Subscribe?channel=" + channelId, false)
|
||||||
|
xhr.send()
|
||||||
|
|
||||||
|
e.target.disabled = false;
|
||||||
|
if (xhr.status !== 200)
|
||||||
|
alert("You need to login to subscribe to a channel")
|
||||||
|
|
||||||
|
if (xhr.responseText === "true") {
|
||||||
|
e.target.innerText = "Subscribed";
|
||||||
|
e.target.classList.add("subscribed")
|
||||||
|
} else {
|
||||||
|
e.target.innerText = "Subscribe";
|
||||||
|
e.target.classList.remove("subscribed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscribeButtons.length > 0) {
|
||||||
|
let xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("GET", "/Account/SubscriptionsJson", false)
|
||||||
|
xhr.send()
|
||||||
|
|
||||||
|
let subscribedChannels = JSON.parse(xhr.responseText);
|
||||||
|
|
||||||
|
for (let i = 0; i < subscribeButtons.length; i++) {
|
||||||
|
let button = subscribeButtons[i];
|
||||||
|
if (subscribedChannels.includes(button.attributes["data-cid"].value)) {
|
||||||
|
button.innerText = "Subscribed";
|
||||||
|
button.classList.add("subscribed")
|
||||||
|
} else {
|
||||||
|
button.innerText = "Subscribe";
|
||||||
|
button.classList.remove("subscribed")
|
||||||
|
}
|
||||||
|
|
||||||
|
button.onclick = subscribeToChannel;
|
||||||
|
button.style.display = ""
|
||||||
|
}
|
||||||
|
}
|
41
core/LightTube/wwwroot/tampermonkey.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
// ==UserScript==
|
||||||
|
// @name LightTube Redirect Button
|
||||||
|
// @namespace http://youtube.com
|
||||||
|
// @version 0.1
|
||||||
|
// @description Adds a redirect button to the YouTube watch page to redirect to LightTube
|
||||||
|
// @match https://www.youtube.com/*
|
||||||
|
// @require http://code.jquery.com/jquery-latest.js
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const createLtButton = () => {
|
||||||
|
let ltButton = document.createElement("button");
|
||||||
|
ltButton.onclick = () => {
|
||||||
|
ltButton.innerHTML = "Loading proxy, please wait...";
|
||||||
|
ltButton.disabled = true;
|
||||||
|
window.location = "https://lighttube.herokuapp.com/watch" + window.location.search;
|
||||||
|
};
|
||||||
|
ltButton.innerHTML = "Proxy (lighttube)";
|
||||||
|
ltButton.id = "lighttube-button";
|
||||||
|
return ltButton;
|
||||||
|
};
|
||||||
|
|
||||||
|
let ltButton = createLtButton();
|
||||||
|
|
||||||
|
// Add button whenever you can
|
||||||
|
setInterval(() => {
|
||||||
|
if (window.location.pathname === "/watch" && !document.getElementById("lighttube-button") && document.getElementById("sponsor-button")) {
|
||||||
|
console.log("Inserted button!");
|
||||||
|
document.getElementById("sponsor-button").parentElement.insertBefore(ltButton, document.getElementById("sponsor-button"));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Ping lighttube so it stays awake
|
||||||
|
setInterval(() => {
|
||||||
|
fetch("https://lighttube.herokuapp.com/").then(() => {})
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
console.log("Pog!");
|
||||||
|
})();
|