diff --git a/Dockerfile b/Dockerfile index 0072068c..cde97c58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,5 @@ RUN dotnet publish "LightTube.csproj" -c Release -o /app/publish /p:Version=`dat FROM base AS final WORKDIR /app COPY --from=publish /app/publish . +RUN chmod 777 -R /tmp && chmod o+t -R /tmp CMD ASPNETCORE_URLS=http://*:$PORT dotnet LightTube.dll \ No newline at end of file diff --git a/LightTube/Contexts/EmbedContext.cs b/LightTube/Contexts/EmbedContext.cs index 1ad79ffb..06ae4e04 100644 --- a/LightTube/Contexts/EmbedContext.cs +++ b/LightTube/Contexts/EmbedContext.cs @@ -7,9 +7,9 @@ public class EmbedContext : BaseContext public PlayerContext Player; public InnerTubeNextResponse Video; - public EmbedContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeNextResponse innerTubeNextResponse, bool compatibility) : base(context) + public EmbedContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeNextResponse innerTubeNextResponse, bool compatibility, SponsorBlockSegment[] sponsors) : base(context) { - Player = new PlayerContext(context, innerTubePlayer, "embed", compatibility, context.Request.Query["q"]); + Player = new PlayerContext(context, innerTubePlayer, innerTubeNextResponse, "embed", compatibility, context.Request.Query["q"], sponsors); Video = innerTubeNextResponse; } diff --git a/LightTube/Contexts/PlayerContext.cs b/LightTube/Contexts/PlayerContext.cs index 5161ae55..33b04ef1 100644 --- a/LightTube/Contexts/PlayerContext.cs +++ b/LightTube/Contexts/PlayerContext.cs @@ -1,10 +1,13 @@ using InnerTube; +using InnerTube.Renderers; +using Newtonsoft.Json; namespace LightTube.Contexts; public class PlayerContext : BaseContext { public InnerTubePlayer? Player; + public InnerTubeNextResponse Video; public Exception? Exception; public bool UseHls; public bool UseDash; @@ -13,16 +16,19 @@ public class PlayerContext : BaseContext public string PreferredItag = "18"; public bool UseEmbedUi = false; public string? ClassName; + public SponsorBlockSegment[] Sponsors; - public PlayerContext(HttpContext context, InnerTubePlayer innerTubePlayer, string className, bool compatibility, - string preferredItag) : base(context) + public PlayerContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeNextResponse video, + string className, bool compatibility, + string preferredItag, SponsorBlockSegment[] sponsors) : base(context) { Player = innerTubePlayer; + Video = video; ClassName = className; PreferredItag = preferredItag; - UseHls = innerTubePlayer.DashManifestUrl is not null && !compatibility; // Prefer HLS if the video is live - // Live videos contain a DASH manifest URL - UseDash = innerTubePlayer.AdaptiveFormats.Any() && !compatibility; // Prefer DASH if we can provide Adaptive + Sponsors = sponsors; + UseHls = !compatibility; // Prefer HLS + UseDash = innerTubePlayer.AdaptiveFormats.Any() && !compatibility; // Formats if (Configuration.GetVariable("LIGHTTUBE_DISABLE_PROXY", "false") != "false") { @@ -31,9 +37,43 @@ public PlayerContext(HttpContext context, InnerTubePlayer innerTubePlayer, strin } } + public string GetChaptersJson() + { + if (Video.Chapters is null) return "[]"; + ChapterRenderer[] c = Video.Chapters.ToArray(); + List ltChapters = new(); + for (int i = 0; i < c.Length; i++) + { + ChapterRenderer chapter = c[i]; + float to = 100; + if (i + 1 < c.Length) + { + ChapterRenderer next = c[i + 1]; + to = next.TimeRangeStartMillis / (float)Player!.Details.Length.TotalMilliseconds * 100; + } + ltChapters.Add(new LtVideoChapter + { + From = chapter.TimeRangeStartMillis / (float)Player!.Details.Length.TotalMilliseconds * 100, + To = to, + Name = chapter.Title + }); + } + + return JsonConvert.SerializeObject(ltChapters); + } + + private class LtVideoChapter + { + [JsonProperty("from")] public float From; + [JsonProperty("to")] public float To; + [JsonProperty("name")] public string Name; + } + public PlayerContext(HttpContext context, Exception e) : base(context) { Exception = e; + Video = null!; + Sponsors = Array.Empty(); } public string? GetFirstItag() => GetPreferredFormat()?.Itag; @@ -44,8 +84,5 @@ public PlayerContext(HttpContext context, Exception e) : base(context) public string GetClass() => ClassName is not null ? $" {ClassName}" : ""; - public IEnumerable GetFormatsInPreferredOrder() - { - return Player!.Formats.OrderBy(x => x.Itag != PreferredItag); - } + public IEnumerable GetFormatsInPreferredOrder() => Player!.Formats.OrderBy(x => x.Itag != PreferredItag).Where(x => x.Itag != "17"); } \ No newline at end of file diff --git a/LightTube/Contexts/WatchContext.cs b/LightTube/Contexts/WatchContext.cs index 655796fa..6769356f 100644 --- a/LightTube/Contexts/WatchContext.cs +++ b/LightTube/Contexts/WatchContext.cs @@ -9,18 +9,19 @@ public class WatchContext : BaseContext public InnerTubeNextResponse Video; public InnerTubePlaylistInfo? Playlist; public InnerTubeContinuationResponse? Comments; - public int Dislikes; + public SponsorBlockSegment[] Sponsors; public WatchContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeNextResponse innerTubeNextResponse, InnerTubeContinuationResponse? comments, - bool compatibility, int dislikes) : base(context) + bool compatibility, int dislikes, SponsorBlockSegment[] sponsors) : base(context) { - Player = new PlayerContext(context, innerTubePlayer, "embed", compatibility, context.Request.Query["q"]); + Player = new PlayerContext(context, innerTubePlayer, innerTubeNextResponse, "embed", compatibility, context.Request.Query["q"], sponsors); Video = innerTubeNextResponse; Playlist = Video.Playlist; Comments = comments; Dislikes = dislikes; + Sponsors = sponsors; GuideHidden = true; AddMeta("description", Video.Description); @@ -33,20 +34,10 @@ public WatchContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerT AddMeta("twitter:player", $"https://{context.Request.Host}/embed/${Video.Id}"); AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/${Video.Id}/18"); - AddStylesheet("/lib/videojs/video-js.min.css"); - AddStylesheet("/lib/videojs-endscreen/videojs-endscreen.css"); - AddStylesheet("/lib/videojs-vtt-thumbnails/videojs-vtt-thumbnails.min.css"); - AddStylesheet("/lib/videojs-hls-quality-selector/videojs-hls-quality-selector.css"); - AddStylesheet("/lib/silvermine-videojs-quality-selector/silvermine-videojs-quality-selector.css"); - AddStylesheet("/css/vjs-skin.css"); + AddStylesheet("/lib/ltplayer.css"); - AddScript("/lib/videojs/video.min.js"); - AddScript("/lib/videojs-hotkeys/videojs.hotkeys.min.js"); - AddScript("/lib/videojs-endscreen/videojs-endscreen.js"); - AddScript("/lib/videojs-vtt-thumbnails/videojs-vtt-thumbnails.min.js"); - AddScript("/lib/videojs-contrib-quality-levels/videojs-contrib-quality-levels.min.js"); - AddScript("/lib/videojs-hls-quality-selector/videojs-hls-quality-selector.min.js"); - AddScript("/lib/silvermine-videojs-quality-selector/silvermine-videojs-quality-selector.min.js"); + AddScript("/lib/ltplayer.js"); + AddScript("/lib/hls.js"); AddScript("/js/player.js"); } @@ -58,6 +49,7 @@ public WatchContext(HttpContext context, Exception e, InnerTubeNextResponse inne Playlist = Video.Playlist; Comments = comments; Dislikes = dislikes; + Sponsors = Array.Empty(); GuideHidden = true; AddMeta("description", Video.Description); @@ -73,9 +65,9 @@ public WatchContext(HttpContext context, Exception e, InnerTubeNextResponse inne public WatchContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerTubeNextResponse innerTubeNextResponse, DatabasePlaylist? playlist, InnerTubeContinuationResponse? comments, - bool compatibility, int dislikes) : base(context) + bool compatibility, int dislikes, SponsorBlockSegment[] sponsors) : base(context) { - Player = new PlayerContext(context, innerTubePlayer, "embed", compatibility, context.Request.Query["q"]); + Player = new PlayerContext(context, innerTubePlayer, innerTubeNextResponse, "embed", compatibility, context.Request.Query["q"], sponsors); Video = innerTubeNextResponse; Playlist = playlist?.GetInnerTubePlaylistInfo(innerTubePlayer.Details.Id); if (playlist != null && playlist.Visibility == PlaylistVisibility.PRIVATE) @@ -83,6 +75,7 @@ public WatchContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerT Playlist = null; Comments = comments; Dislikes = dislikes; + Sponsors = sponsors; GuideHidden = true; AddMeta("description", Video.Description); @@ -95,20 +88,10 @@ public WatchContext(HttpContext context, InnerTubePlayer innerTubePlayer, InnerT AddMeta("twitter:player", $"https://{context.Request.Host}/embed/${Video.Id}"); AddMeta("twitter:player:stream", $"https://{context.Request.Host}/proxy/media/${Video.Id}/18"); - AddStylesheet("/lib/videojs/video-js.min.css"); - AddStylesheet("/lib/videojs-endscreen/videojs-endscreen.css"); - AddStylesheet("/lib/videojs-vtt-thumbnails/videojs-vtt-thumbnails.min.css"); - AddStylesheet("/lib/videojs-hls-quality-selector/videojs-hls-quality-selector.css"); - AddStylesheet("/lib/silvermine-videojs-quality-selector/silvermine-videojs-quality-selector.css"); - AddStylesheet("/css/vjs-skin.css"); + AddStylesheet("/lib/ltplayer.css"); - AddScript("/lib/videojs/video.min.js"); - AddScript("/lib/videojs-hotkeys/videojs.hotkeys.min.js"); - AddScript("/lib/videojs-endscreen/videojs-endscreen.js"); - AddScript("/lib/videojs-vtt-thumbnails/videojs-vtt-thumbnails.min.js"); - AddScript("/lib/videojs-contrib-quality-levels/videojs-contrib-quality-levels.min.js"); - AddScript("/lib/videojs-hls-quality-selector/videojs-hls-quality-selector.min.js"); - AddScript("/lib/silvermine-videojs-quality-selector/silvermine-videojs-quality-selector.min.js"); + AddScript("/lib/ltplayer.js"); + AddScript("/lib/hls.js"); AddScript("/js/player.js"); } @@ -123,6 +106,7 @@ public WatchContext(HttpContext context, Exception e, InnerTubeNextResponse inne Playlist = null; Comments = comments; Dislikes = dislikes; + Sponsors = Array.Empty(); GuideHidden = true; AddMeta("description", Video.Description); diff --git a/LightTube/Controllers/HomeController.cs b/LightTube/Controllers/HomeController.cs index 46d41ccb..f0f7737d 100644 --- a/LightTube/Controllers/HomeController.cs +++ b/LightTube/Controllers/HomeController.cs @@ -1,6 +1,7 @@ using System.Text; using LightTube.Contexts; using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; namespace LightTube.Controllers; @@ -37,4 +38,19 @@ public IActionResult CustomCss() return NotFound(); } + + [Route("/lib/{name}")] + public IActionResult CachedJs(string name) + { + try + { + return File(Encoding.UTF8.GetBytes(JsCache.GetJsFileContents(name)), + name.EndsWith(".css") ? "text/css" : "text/javascript", + JsCache.CacheUpdateTime, new EntityTagHeaderValue($"\"{JsCache.GetHash(name)}\"")); + } + catch (Exception e) + { + return NotFound(); + } + } } \ No newline at end of file diff --git a/LightTube/Controllers/MediaController.cs b/LightTube/Controllers/MediaController.cs index e5c31c26..86aac490 100644 --- a/LightTube/Controllers/MediaController.cs +++ b/LightTube/Controllers/MediaController.cs @@ -305,25 +305,26 @@ public async Task HlsSegmentProxy(string path) } } - [Route("caption/{videoId}/{language}")] - public async Task SubtitleProxy(string videoId, string language) + [Route("caption/{videoId}/{vssId}")] + public async Task SubtitleProxy(string videoId, string vssId) { try { InnerTubePlayer player = await _youtube.GetPlayerAsync(videoId); InnerTubePlayer.VideoCaption? - subtitle = player.Captions.FirstOrDefault(x => x.LanguageCode == language); + subtitle = player.Captions.FirstOrDefault(x => x.VssId == vssId); 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.Captions.Select(x => $"{x.LanguageCode} \"{x.Label}\""))}")), + $"There are no available subtitles for '{vssId}'. Available subtitle IDs are: {string.Join(", ", player.Captions.Select(x => $"{x.VssId} \"{x.Label}\""))}")), "text/plain"); } - string url = subtitle.BaseUrl.ToString().Replace("fmt=srv3", "fmt=vtt"); + string url = subtitle.BaseUrl.ToString(); + url = url.Contains("fmt=") ? url.Replace("fmt=srv3", "fmt=vtt") : url + "&fmt=vtt"; if (!url.StartsWith("http://") && !url.StartsWith("https://")) url = "https://" + url; diff --git a/LightTube/Controllers/YoutubeController.cs b/LightTube/Controllers/YoutubeController.cs index 4d51789f..2bc8c12f 100644 --- a/LightTube/Controllers/YoutubeController.cs +++ b/LightTube/Controllers/YoutubeController.cs @@ -34,12 +34,22 @@ public async Task Embed(string v, bool contentCheckOk, bool compa player = null; e = ex; } + + SponsorBlockSegment[] sponsors; + try + { + sponsors = await SponsorBlockSegment.GetSponsors(v); + } + catch + { + sponsors = Array.Empty(); + } InnerTubeNextResponse video = await _youtube.GetVideoAsync(v, language: HttpContext.GetLanguage(), region: HttpContext.GetRegion()); if (player is null || e is not null) return View(new EmbedContext(HttpContext, e ?? new Exception("player is null"), video)); - return View(new EmbedContext(HttpContext, player, video, compatibility)); + return View(new EmbedContext(HttpContext, player, video, compatibility, sponsors)); } [Route("/watch")] @@ -86,6 +96,16 @@ await _youtube.GetVideoAsync(v, localPlaylist ? null : list, language: HttpConte catch { dislikes = -1; + } + + SponsorBlockSegment[] sponsors; + try + { + sponsors = await SponsorBlockSegment.GetSponsors(v); + } + catch + { + sponsors = Array.Empty(); } if (player is not null) @@ -97,14 +117,14 @@ await _youtube.GetVideoAsync(v, localPlaylist ? null : list, language: HttpConte if (player is null || e is not null) return View(new WatchContext(HttpContext, e ?? new Exception("player is null"), video, pl, comments, dislikes)); - return View(new WatchContext(HttpContext, player, video, pl, comments, compatibility, dislikes)); + return View(new WatchContext(HttpContext, player, video, pl, comments, compatibility, dislikes, sponsors)); } else { if (player is null || e is not null) return View(new WatchContext(HttpContext, e ?? new Exception("player is null"), video, comments, dislikes)); - return View(new WatchContext(HttpContext, player, video, comments, compatibility, dislikes)); + return View(new WatchContext(HttpContext, player, video, comments, compatibility, dislikes, sponsors)); } } diff --git a/LightTube/JsCache.cs b/LightTube/JsCache.cs new file mode 100644 index 00000000..035e17b6 --- /dev/null +++ b/LightTube/JsCache.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Web; + +namespace LightTube; + +public static class JsCache +{ + private static Dictionary LibraryUrls = new() + { + ["hls.js"] = new Uri("https://cdn.jsdelivr.net/npm/hls.js@1.4.0/dist/hls.min.js"), + ["ltplayer.js"] = new Uri("https://raw.githubusercontent.com/kuylar/LTPlayer/master/dist/player.min.js"), + ["ltplayer.css"] = new Uri("https://raw.githubusercontent.com/kuylar/LTPlayer/master/dist/player.min.css"), + }; + private static Dictionary Hashes = new(); + public static DateTimeOffset CacheUpdateTime = DateTimeOffset.MinValue; + + public static async Task DownloadLibraries() + { + HttpClient client = new(); + Directory.CreateDirectory("/tmp/lighttube/jsCache"); + Console.WriteLine("[JsCache] Downloading libraries..."); + foreach ((string? name, Uri? url) in LibraryUrls) + { + Console.WriteLine($"[JsCache] Downloading '{name}' from {url}"); + + HttpResponseMessage response = await client.GetAsync(url); + string jsData = await response.Content.ReadAsStringAsync(); + await File.WriteAllTextAsync($"/tmp/lighttube/jsCache/{name}", jsData); + Console.WriteLine($"[JsCache] Calculating the MD5 hash of {name}..."); + + using MD5 md5 = MD5.Create(); + byte[] inputBytes = Encoding.ASCII.GetBytes(jsData); + byte[] hashBytes = md5.ComputeHash(inputBytes); + string hash = Convert.ToHexString(hashBytes); + + Hashes[name] = hash; + + Console.WriteLine($"[JsCache] Downloaded '{name}'."); + } + CacheUpdateTime = DateTimeOffset.UtcNow; + } + + public static string GetJsFileContents(string name) => File.ReadAllText($"/tmp/lighttube/jsCache/{HttpUtility.UrlEncode(name)}"); + public static Uri GetUrl(string name) => LibraryUrls.TryGetValue(name, out Uri? url) ? url : new Uri("/"); + + public static string GetHash(string name) => + Hashes.TryGetValue(name, out string? h) ? h : "68b329da9893e34099c7d8ad5cb9c940"; // md5 sum of an empty buffer +} \ No newline at end of file diff --git a/LightTube/Program.cs b/LightTube/Program.cs index 7f2f2514..a4f558da 100644 --- a/LightTube/Program.cs +++ b/LightTube/Program.cs @@ -17,6 +17,7 @@ })); builder.Services.AddSingleton(new HttpClient()); +await JsCache.DownloadLibraries(); ChoreManager.RegisterChores(); DatabaseManager.Init(Configuration.GetVariable("LIGHTTUBE_MONGODB_CONNSTR")); diff --git a/LightTube/SponsorBlockSegment.cs b/LightTube/SponsorBlockSegment.cs new file mode 100644 index 00000000..cc0234ce --- /dev/null +++ b/LightTube/SponsorBlockSegment.cs @@ -0,0 +1,62 @@ +using Newtonsoft.Json; + +namespace LightTube; + +public class SponsorBlockSegment +{ + [JsonProperty("category")] public string Category { get; set; } + [JsonProperty("actionType")] public string ActionType { get; set; } + [JsonProperty("segment")] private double[] Segment { get; set; } + [JsonProperty("UUID")] public string Uuid { get; set; } + [JsonProperty("videoDuration")] public double VideoDuration { get; set; } + [JsonProperty("locked")] public long Locked { get; set; } + [JsonProperty("votes")] public long Votes { get; set; } + [JsonProperty("description")] public string Description { get; set; } + public double StartMs => Segment[0]; + public double EndMs => Segment[1]; + + public string ToLTPlayerJson(double videoDuration) => + $"{{ from: {ToPercentage(StartMs, videoDuration)}, to: {ToPercentage(EndMs, videoDuration)}, color: '#{GetColor()}', onEnter: function(player) {{ player.showSkipButton('Skip {GetName()}', {EndMs});}},onExit:function(player) {{player.hideSkipButton();}} }}"; + + private string GetName() + { + return Category switch + { + "sponsor" => "sponsor", + "selfpromo" => "self promotion", + "interaction" => "interaction", + "intro" => "intro", + "outro" => "outro", + "preview" => "preview", + "filler" => "filler", + _ => Category + "*" + }; + } + + private string GetColor() + { + return Category switch + { + "sponsor" => "00d400", + "selfpromo" => "ff0", + "interaction" => "cof", + "intro" => "0ff", + "outro" => "0202ed", + "preview" => "008fd6", + "filler" => "7300ff", + _ => "#ff0" + }; + } + + private double ToPercentage(double input, double max) => (input / max) * 100; + + public static async Task GetSponsors(string videoId) + { + HttpResponseMessage sbResponse = + await new HttpClient().GetAsync( + $"https://sponsor.ajay.app/api/skipSegments?videoID={videoId}&category=sponsor&category=selfpromo&category=interaction&category=intro&category=outro&category=preview&category=music_offtopic&category=filler"); + if (!sbResponse.IsSuccessStatusCode) return Array.Empty(); + string json = await sbResponse.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(json)!; + } +} \ No newline at end of file diff --git a/LightTube/Views/Shared/Player.cshtml b/LightTube/Views/Shared/Player.cshtml index 8aa606eb..3663a41a 100644 --- a/LightTube/Views/Shared/Player.cshtml +++ b/LightTube/Views/Shared/Player.cshtml @@ -1,7 +1,7 @@ @using InnerTube @using System.Text.Json @using InnerTube.Exceptions -@using Utils = LightTube.Utils +@using JsonSerializer = System.Text.Json.JsonSerializer @model PlayerContext @{ @@ -59,7 +59,7 @@ } else if ((Model.UseHls || Model.UseDash) && !Model.Player.Formats.Any()) { -