1
Fork 0
This commit is contained in:
Ashley 2022-08-05 22:33:38 +03:00 committed by GitHub
parent f431111611
commit 72143fede3
100 changed files with 12438 additions and 0 deletions

View 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
View file

@ -0,0 +1,12 @@
namespace InnerTube
{
public enum ChannelTabs
{
Home,
Videos,
Playlists,
Community,
Channels,
About
}
}

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,7 @@
namespace LightTube.Contexts
{
public class BaseContext
{
public bool MobileLayout;
}
}

View file

@ -0,0 +1,7 @@
namespace LightTube.Contexts
{
public class ErrorContext : BaseContext
{
public string Path;
}
}

View file

@ -0,0 +1,11 @@
using LightTube.Database;
namespace LightTube.Contexts
{
public class FeedContext : BaseContext
{
public LTChannel[] Channels;
public FeedVideo[] Videos;
public string RssToken;
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,8 @@
@{
ViewData["Title"] = "Explore";
ViewData["SelectedGuideItem"] = "explore";
}
<div style="text-align: center">
<h1>Coming soon!</h1>
</div>

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

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

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

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

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

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

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

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

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

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

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

View 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&nbsp; @Model.Video.UploadDate</span>&nbsp; @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>
}
}

View file

@ -0,0 +1,3 @@
@using LightTube
@using LightTube.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View file

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

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

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View file

@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}

File diff suppressed because it is too large Load diff

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

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

File diff suppressed because it is too large Load diff

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

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

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

View 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.")
})
}

View 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.")
})
}

File diff suppressed because one or more lines are too long

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

View 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!");
})();