diff --git a/README.md b/README.md index faf1e02..bf43c2b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ My web based YouTube downloader that I created in 2025 writen in [CrossLang](htt [Website](https://crosslang.tesseslanguage.com/software/webapps/tytd2025/) # Features -- Uses [SQLite3](https://www.sqlite.org/) for it's database (embedded into TessesFramework) +- PWA with the ability to add videos when server is down - Can download videos, playlists and channels (you need a channel url like this [https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A](https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A)) - Can subscribe to channels - Can create playlists (that are stored on the server) @@ -18,6 +18,19 @@ My web based YouTube downloader that I created in 2025 writen in [CrossLang](htt - Can download YouTube videos either Low quality (but doesn't) require [ffmpeg](https://ffmpeg.org/), you can also download individual streams (also doesn't need [ffmpeg](https://ffmpeg.org/)), or to MP4 (doesn't work on wii due to libx264 having illegal instruction), MKV (so no transcode), MP3 or FLAC (these do need [ffmpeg](https://ffmpeg.org/) in your PATH however) - Runs on the Wii using the [Wii Linux Continuation Project](https://wiibrew.org/wiki/Wii-Linux#Wii_Linux_Continuation_Project) (albeit extremely slowly, despite this that's where I run it) +# What this project uses (attribution) +I don't feel like storing their licenses in my project, so I link to their projects instead + +- [SQLite3](https://www.sqlite.org/) for it's database (embedded into TessesFramework) +- [BeerCSS](https://www.beercss.com/) for its webui, licensed under MIT +- [HTMX](https://htmx.org/) for the SPA experience +- [FFmpeg](https://ffmpeg.org/) if you convert the videos, uses the cli +- [YouTubeExplode](https://github.com/Tyrrrz/YoutubeExplode) for some of its json payloads +- [NewPipe](https://newpipe.net/) for some of its json payloads +- [Material Symbols Font](https://fonts.google.com/icons) (wget from beercss css files) +- [VideoJS](https://videojs.org/) for video player +- [CrossLang](https://crosslang.tesseslanguage.com/) My programming language + ## To Install Install [crosslang](https://crosslang.tesseslanguage.com/downloads/index.html) diff --git a/Tesses.YouTubeDownloader.Server/cross.json b/Tesses.YouTubeDownloader.Server/cross.json index d710983..b546c35 100644 --- a/Tesses.YouTubeDownloader.Server/cross.json +++ b/Tesses.YouTubeDownloader.Server/cross.json @@ -1,5 +1,5 @@ { - "icon": "icon.png", + "icon": "tytd-128.png", "info": { "description": "Download YouTube Videos (using CrossLang, via web interface, great for homelabs)", "maintainer": "Mike Nolan", @@ -14,5 +14,6 @@ "project_dependencies": [ "..\/Tesses.YouTubeDownloader" ], - "version": "1.0.0.0-dev" + "version": "1.0.0.1-prod", + "compTime": "secure" } \ No newline at end of file diff --git a/Tesses.YouTubeDownloader.Server/res/offline-progress.html b/Tesses.YouTubeDownloader.Server/res/offline-progress.html new file mode 100644 index 0000000..a6161d3 --- /dev/null +++ b/Tesses.YouTubeDownloader.Server/res/offline-progress.html @@ -0,0 +1,4 @@ +
+

Cannot connect to the TYTD2025 server.

+ Refresh page to add to offline queue +
\ No newline at end of file diff --git a/Tesses.YouTubeDownloader.Server/res/offline.html b/Tesses.YouTubeDownloader.Server/res/offline.html new file mode 100644 index 0000000..89d0294 --- /dev/null +++ b/Tesses.YouTubeDownloader.Server/res/offline.html @@ -0,0 +1,106 @@ + + + + + + TYTD2025 is Offline + + + + + + + +
+
+
+
+ tytd-logo +
+
+
+
+
+
+

Cannot connect to the TYTD2025 server.

+
+
+
+
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + arrow_drop_down +
+
+
+ +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/Tesses.YouTubeDownloader.Server/res/offline.js b/Tesses.YouTubeDownloader.Server/res/offline.js new file mode 100644 index 0000000..9a26e66 --- /dev/null +++ b/Tesses.YouTubeDownloader.Server/res/offline.js @@ -0,0 +1,54 @@ +function addOffline(url,res) +{ + var videos = JSON.parse(localStorage.getItem("videos") ?? "[]"); + videos.push({ + url: url, + res: res + }); + localStorage.setItem('videos',JSON.stringify(videos)); +} + +async function syncOffline() +{ + const json = localStorage.getItem("videos") ?? "[]"; + if(json !== "[]") + { + const resp = await fetch('/api/v1/add',{ + body: json, + headers: { + 'Content-Type': 'application/json' + }, + method: "POST" + }); + if(resp.ok) + { + localStorage.removeItem('videos'); + } + } +} + +if(navigator.online) +{ + syncOffline(); +} + +window.addEventListener('online',()=>{ + syncOffline(); +}); + +async function getPersonalTempLink() +{ + const searchParams = new URLSearchParams(window.location.search); + const ent = searchParams.get("name"); + if(ent) + { + const resp=await fetch(`./api/v1/personal_tmp_link?name=${encodeURIComponent(ent)}`); + if(resp.ok) + { + const text = await resp.text(); + the_playlist_url.innerText = text; + the_playlist_url.href = text; + ui("#dialog"); + } + } +} diff --git a/Tesses.YouTubeDownloader.Server/res/service_worker.js b/Tesses.YouTubeDownloader.Server/res/service_worker.js new file mode 100644 index 0000000..1d32a87 --- /dev/null +++ b/Tesses.YouTubeDownloader.Server/res/service_worker.js @@ -0,0 +1,42 @@ +const assets = ["<@ASSETS@>"]; + +const staticCacheName = "tytd-static-<@BUILD_TIME@>"; + +self.addEventListener('install',evt => { + evt.waitUntil( + caches.open(staticCacheName).then(cache =>{ + cache.addAll(assets); + }) + ); +}); + +self.addEventListener('activate',(evt)=>{ + evt.waitUntil( + caches.keys().then(keys => { + return Promise.all(keys.filter(key => key !== staticCacheName).map(key => caches.delete(key))); + }) + ); +}); + +self.addEventListener('fetch', evt => { + const uri = new URL(evt.request.url); + + evt.respondWith( + + caches.match(evt.request).then(cacheRes=>{ + return cacheRes || fetch(evt.request); + }).catch(()=>{ + if(uri.pathname === '/queue-size') + { + return new Response('?',{ + headers: { 'Content-Type': 'text/html' } + }); + } + if(uri.pathname === "/progress") + { + return caches.match("/offline-progress.html"); + } + return caches.match('/offline.html'); + }) + ); +}); \ No newline at end of file diff --git a/Tesses.YouTubeDownloader.Server/res/site.webmanifest b/Tesses.YouTubeDownloader.Server/res/site.webmanifest new file mode 100644 index 0000000..cb05eaa --- /dev/null +++ b/Tesses.YouTubeDownloader.Server/res/site.webmanifest @@ -0,0 +1,73 @@ +{ + "short_name": "TYTD2025", + "name": "Tesses YouTubeDownloader 2025", + "start_url": "/", + "display": "standalone", + "theme_color": "#ffb2be", + "background_color": "#241e1f", + "description": "A web based YouTube archiver to preserve videos that might be removed", + "icons": [ + { + "src": "/tytd.svg", + "sizes": "192x192 256x256 384x384 512x512", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/tytd-128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "/tytd-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/tytd-256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "/tytd-384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "/tytd-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/tytd-1024.png", + "sizes": "1024x1024", + "type": "image/png" + } + ], + "shortcuts": [ + { + "name": "Open Downloads", + "short_name": "Downloads", + "description": "Open Downloads page", + "url": "/downloads" + }, + { + "name": "Open Installed Plugins", + "short_name": "Installed plugins", + "description": "Open the installed plugins page", + "url": "/plugins" + }, + { + "name": "Open Download Plugins", + "short_name": "Download plugins", + "description": "Open the download plugins page", + "url": "/plugins-download" + }, + { + "name": "Open Settings", + "short_name": "Settings", + "description": "Open settings page", + "url": "/settings" + } + ] +} \ No newline at end of file diff --git a/Tesses.YouTubeDownloader.Server/res/tytd-1024.png b/Tesses.YouTubeDownloader.Server/res/tytd-1024.png new file mode 100644 index 0000000..24b8cdd Binary files /dev/null and b/Tesses.YouTubeDownloader.Server/res/tytd-1024.png differ diff --git a/Tesses.YouTubeDownloader.Server/res/icon.png b/Tesses.YouTubeDownloader.Server/res/tytd-128.png similarity index 100% rename from Tesses.YouTubeDownloader.Server/res/icon.png rename to Tesses.YouTubeDownloader.Server/res/tytd-128.png diff --git a/Tesses.YouTubeDownloader.Server/res/tytd-192.png b/Tesses.YouTubeDownloader.Server/res/tytd-192.png new file mode 100644 index 0000000..7fa9539 Binary files /dev/null and b/Tesses.YouTubeDownloader.Server/res/tytd-192.png differ diff --git a/Tesses.YouTubeDownloader.Server/res/tytd-256.png b/Tesses.YouTubeDownloader.Server/res/tytd-256.png new file mode 100644 index 0000000..79b9cda Binary files /dev/null and b/Tesses.YouTubeDownloader.Server/res/tytd-256.png differ diff --git a/Tesses.YouTubeDownloader.Server/res/tytd-384.png b/Tesses.YouTubeDownloader.Server/res/tytd-384.png new file mode 100644 index 0000000..748d897 Binary files /dev/null and b/Tesses.YouTubeDownloader.Server/res/tytd-384.png differ diff --git a/Tesses.YouTubeDownloader.Server/res/tytd-512.png b/Tesses.YouTubeDownloader.Server/res/tytd-512.png new file mode 100644 index 0000000..cd84e5a Binary files /dev/null and b/Tesses.YouTubeDownloader.Server/res/tytd-512.png differ diff --git a/Tesses.YouTubeDownloader.Server/src/components/personallistdescription.tcross b/Tesses.YouTubeDownloader.Server/src/components/personallistdescription.tcross index 51a5c9a..f23e179 100644 --- a/Tesses.YouTubeDownloader.Server/src/components/personallistdescription.tcross +++ b/Tesses.YouTubeDownloader.Server/src/components/personallistdescription.tcross @@ -20,11 +20,19 @@ func Components.PersonalListDescription(tytd,name,editing) + +
The url
+
+ +
+
diff --git a/Tesses.YouTubeDownloader.Server/src/components/progress.tcross b/Tesses.YouTubeDownloader.Server/src/components/progress.tcross index ea20ea9..0c2fba0 100644 --- a/Tesses.YouTubeDownloader.Server/src/components/progress.tcross +++ b/Tesses.YouTubeDownloader.Server/src/components/progress.tcross @@ -1,4 +1,4 @@ -var progress=0; + func Components.Progress(tytd) { var vid = tytd.CurrentVideo; @@ -8,6 +8,5 @@ func Components.Progress(tytd)

{vid.Channel}

; - progress++; return html; } \ No newline at end of file diff --git a/Tesses.YouTubeDownloader.Server/src/components/queuesz.tcross b/Tesses.YouTubeDownloader.Server/src/components/queuesz.tcross new file mode 100644 index 0000000..39b57dc --- /dev/null +++ b/Tesses.YouTubeDownloader.Server/src/components/queuesz.tcross @@ -0,0 +1,12 @@ + +func Components.QueueSZ(tytd) +{ + var vid = tytd.CurrentVideo; + tytd.Mutex.Lock(); + + + var html = {tytd.VideoQueueCount}; + + tytd.Mutex.Unlock(); + return html; +} \ No newline at end of file diff --git a/Tesses.YouTubeDownloader.Server/src/components/shell.tcross b/Tesses.YouTubeDownloader.Server/src/components/shell.tcross index cda4c37..0bee664 100644 --- a/Tesses.YouTubeDownloader.Server/src/components/shell.tcross +++ b/Tesses.YouTubeDownloader.Server/src/components/shell.tcross @@ -1,11 +1,21 @@ func Components.Shell(title, html, page, $mypage) { + if(TypeOf(mypage) != "Path") mypage = /; var index = (/).MakeRelative(mypage).ToString(); if(index == "") index = "./"; + const service_worker_script_path = (/"service_worker.js").MakeRelative(mypage).ToString(); + const service_worker_script = $""; + var pages = [ { text="Home", @@ -47,7 +57,11 @@ func Components.Shell(title, html, page, $mypage) TYTD - {title} + + + + @@ -56,7 +70,15 @@ func Components.Shell(title, html, page, $mypage) {page.icon} + + + + ? + + +
{page.text}
+
@@ -69,7 +91,7 @@ func Components.Shell(title, html, page, $mypage)
- + ; } \ No newline at end of file diff --git a/Tesses.YouTubeDownloader.Server/src/main.tcross b/Tesses.YouTubeDownloader.Server/src/main.tcross index 0c90b65..24cdada 100644 --- a/Tesses.YouTubeDownloader.Server/src/main.tcross +++ b/Tesses.YouTubeDownloader.Server/src/main.tcross @@ -1,3 +1,5 @@ +const BUILD_TIME = comptime DateTime.NowEpoch; + var TYTDResources = [ {path="/beer.min.css",value=embed("beer.min.css")}, {path="/beer.min.js",value=embed("beer.min.js")}, @@ -8,12 +10,31 @@ var TYTDResources = [ {path="/htmx.min.js",value=embed("htmx.min.js")}, {path="/favicon.ico",value=embed("favicon.ico")}, {path="/tytd.svg",value=embed("tytd.svg")}, + {path="/tytd-128.png",value=embed("tytd-128.png")}, + {path="/tytd-192.png",value=embed("tytd-192.png")}, + {path="/tytd-256.png",value=embed("tytd-256.png")}, + {path="/tytd-384.png",value=embed("tytd-384.png")}, + {path="/tytd-512.png",value=embed("tytd-512.png")}, + {path="/tytd-1024.png",value=embed("tytd-1024.png")}, {path="/loading-indicator.svg",value=embed("loading-indicator.svg")}, {path="/theme.css",value=embed("theme.css")}, {path="/video.min.js",value=embed("video.min.js")}, {path="/video-js.css",value=embed("video-js.css")}, - {path="/wavy.svg",value=embed("wavy.svg")} + {path="/wavy.svg",value=embed("wavy.svg")}, + {path="/site.webmanifest",value=embed("site.webmanifest")}, + {path="/offline-progress.html",value=embed("offline-progress.html")}, + {path="/offline.html",value=embed("offline.html")}, + {path="/offline.js", value=embed("offline.js")}, ]; + +const fileNames = []; +each(var item : TYTDResources) +{ + fileNames.Add(item.path); +} + +const service_worker_str = embed("service_worker.js").ToString().Replace("[\"<@ASSETS@>\"]",Json.Encode(fileNames)).Replace("<@BUILD_TIME@>",BUILD_TIME.ToString()); + var times=1; class TYTDApp { @@ -29,21 +50,66 @@ class TYTDApp { public TYTDApp() { - + Console.WriteLine($"Built at {new DateTime(BUILD_TIME).ToString()}"); var tytdfs = new SubdirFilesystem(FS.Local, GetTYTDDir()); this.TYTD = new TYTD.Downloader(tytdfs,FS.MakeFull(GetTYTDDir())); this.TYTD.Start(); } - public Handle(ctx) { + if(ctx.Path == "/service_worker.js" || fileNames.Contains(ctx.Path)) + { + const inm=ctx.RequestHeaders.TryGetFirst("If-None-Match"); + if(TypeIsString(inm)) + { + const strs = inm.Split(", "); + each(var item : strs) + { + if(item == $"W/\"{BUILD_TIME}\"" || item == $"\"{BUILD_TIME}\"") + { + ctx.StatusCode = 304; + ctx.WriteHeaders(); + return true; + } + } + } + ctx.WithHeader("ETag",$"W/\"{BUILD_TIME}\""); + } + if(ctx.Path == "/service_worker.js") + { + ctx.WithMimeType(Net.Http.MimeType("service_worker.js")).SendText(service_worker_str); + return true; + } + if(ctx.Path == "/api/v1/auth") + { + if(ctx.Method == "POST") + { + const req=ctx.ReadJson(); + const result = this.TYTD.Auth(req.username, req.password); + if(result) + { + ctx.SendJson({ + success = true, + flags = result.flags + }); + } + else { + ctx.SendJson({ + success=false + }); + } + return true; + } + + return false; + } if(ctx.Path == "/api/v1/login") { if(ctx.Method == "POST") { const req=ctx.ReadJson(); - const result = this.TYTD.Login(req.username, req.password); + const result = this.TYTD.Login(req.username, req.password, false); if(result) { ctx.SendJson({ @@ -70,14 +136,17 @@ class TYTDApp { const redirect = ctx.QueryParams.TryGetFirst("redirect") ?? "/"; const username = ctx.QueryParams.TryGetFirst("username") ?? ""; const password = ctx.QueryParams.TryGetFirst("password") ?? ""; + + if(ctx.Method == "POST") { - const result = this.TYTD.Login(username, password); + const result = this.TYTD.Login(username, password,true); if(result) { ctx.StatusCode = 303; - ctx.WithHeader("Set-Cookie",$"Session={result}; SameSite=Strict").SendRedirect("/"); + var date = new DateTime(DateTime.NowEpoch + UserFlags.Expires); + ctx.WithHeader("Set-Cookie",$"Session={result}; SameSite=Lax; Expires={date.ToHttpDate()}; HttpOnly").SendRedirect(redirect); return true; } else incorrect=true; @@ -97,6 +166,25 @@ class TYTDApp { return true; } } + if(ctx.Path == "/progress") + { + ctx.WithMimeType("text/html").SendText( +
+

You have been logged out

+ Login +
+ ); + return true; + } + if(ctx.Path == "/sso") + { + const app = ctx.QueryParams.TryGetFirst("app") ?? ""; + const token = ctx.QueryParams.TryGetFirst("token") ?? ""; + const path = $"/sso?app={Net.Http.UrlEncode(app)}&token={Net.Http.UrlEncode(token)}"; + ctx.StatusCode = 307; + ctx.SendRedirect($"/login?redirect={Net.Http.UrlEncode(path)}"); + return true; + } ctx.StatusCode = 307; ctx.SendRedirect($"/login?redirect={Net.Http.UrlEncode(ctx.Path)}"); return true; @@ -208,7 +296,6 @@ class TYTDApp { return true; } } - if(ctx.Path == "/welcome") { const redirect = ctx.QueryParams.TryGetFirst("redirect") ?? "/settings"; @@ -245,11 +332,49 @@ class TYTDApp { return true; } } + if(ctx.Path == "/") { ctx.WithMimeType("text/html").SendText(Pages.Index(this.TYTD)); return true; } + else if(ctx.Path == "/sso") + { + const app = ctx.QueryParams.TryGetFirst("app"); + const token = ctx.QueryParams.TryGetFirst("token"); + if(TypeIsString(app) && TypeIsString(token)) + { + + const sso = this.TYTD.GetSSO(app); + if(TypeIsDictionary(sso)) + { + const user = this.TYTD.WhoAmI(ctx); + if(TypeIsDictionary(user) && user.flags != 0) + { + const postJson = { + username = user.username, + flags = user.flags, + token = token, + sso_app_key = sso.sso_app_key + }; + + const resp = Net.Http.MakeRequest(sso.service_auth_post, { + Method = "POST", + Body = Net.Http.TextHttpRequestBody(postJson.ToString(),"application/json") + }); + + if(resp.StatusCode >= 200 && resp.StatusCode <= 299) + { + ctx.StatusCode = 307; + ctx.SendRedirect($"{sso.service_auth_redirect}{Net.Http.UrlEncode(token)}"); + return true; + } + } + } + } + ctx.StatusCode = 401; + return false; + } else if(ctx.Path == "/passwd") { var incorrect=false; @@ -265,6 +390,7 @@ class TYTDApp { ctx.WithMimeType("text/html").SendText(Pages.ChangePassword(redirect, incorrect)); } + else if(ctx.Path == "/newuser") { var error = null; @@ -340,6 +466,36 @@ class TYTDApp { } return false; } + else if(ctx.Path == "/api/v1/register_sso") + { + if(ctx.Method!="POST") + { + ctx.WithMimeType("application/json").SendJson({ + success=false, + reason = "Method must be post", + type = "method" + }); + return true; + } + if(!UserFlags.IsAdmin(this.TYTD.IsLoggedIn(ctx))) + { + ctx.WithMimeType("application/json").SendJson({ + success=false, + reason = "You are either not logged in or not admin", + type ="auth" + }); + return true; + } + + const json = ctx.ReadJson(); + + const resp = this.TYTD.RegisterSSO(json); + + ctx.WithMimeType("application/json").SendJson( + resp + ); + return true; + } else if(ctx.Path == "/api/v1/download") { var v = ctx.QueryParams.TryGetFirst("v"); @@ -371,6 +527,20 @@ class TYTDApp { ctx.WithMimeType("application/json").SendJson(jo); return true; } + else if(ctx.Path == "/api/v1/add") + { + if(ctx.Method=="POST") + { + const json = ctx.ReadJson(); + each(var item : json) + { + this.TYTD.DownloadItem(item.url,item.res); + } + ctx.StatusCode=204; + ctx.WriteHeaders(); + return true; + } + } else if(ctx.Path == "/api/v1/video.json") { var id = ctx.QueryParams.TryGetFirst("v"); @@ -439,6 +609,20 @@ class TYTDApp { } return true; } + else if(ctx.Path == "/api/v1/personal_tmp_link") + { + const name = ctx.QueryParams.TryGetFirst("name"); + if(TypeIsString(name)) + { + const ents = this.TYTD.GetPersonalListTempUrl(name); + if(TypeIsString(ents)) + { + ctx.WithMimeType("text/plain").SendText(ents); + return true; + } + + } + } else if(ctx.Path == "/api/v1/personal") { /* @@ -714,6 +898,36 @@ class TYTDApp { ctx.WithMimeType("text/html").SendText(Pages.VideoInfo(this.TYTD,ctx)); return true; } + + else if(ctx.Path == "/watch_videos") + { + const video_ids = ctx.QueryParams.TryGetFirst("video_ids"); + if(TypeIsString(video_ids)) + { + if(ctx.Method=="GET") + { + ctx.WithMimeType("text/html").SendText(Pages.YouTubeAnonyPlaylist(video_ids)); + return true; + } + if(ctx.Method=="POST") + { + const name = ctx.QueryParams.TryGetFirst("name"); + if(TypeIsString(name)) + { + const nameParts=video_ids.Split(","); + Console.WriteLine(nameParts); + each(var item : nameParts) + { + + this.TYTD.AddToPersonalList(name,item); + } + Console.WriteLine(name); + ctx.SendRedirect($"/list?name={Net.Http.UrlEncode(name)}",303); + return true; + } + } + } + } else if(ctx.Path == "/playlist") { ctx.WithMimeType("text/html").SendText(Pages.PlaylistInfo(this.TYTD,ctx)); @@ -802,6 +1016,12 @@ class TYTDApp { ctx.WithMimeType("text/html").SendText(Components.Progress(this.TYTD)); return true; } + else if(ctx.Path == "/queue-size") + { + + ctx.WithMimeType("text/html").SendText(Components.QueueSZ(this.TYTD)); + return true; + } else if(ctx.Path == "/plugins") { ctx.WithMimeType("text/html").SendText(Pages.Plugins(this.TYTD,ctx)); diff --git a/Tesses.YouTubeDownloader.Server/src/pages/personal-from-yt-temp.tcross b/Tesses.YouTubeDownloader.Server/src/pages/personal-from-yt-temp.tcross new file mode 100644 index 0000000..710421c --- /dev/null +++ b/Tesses.YouTubeDownloader.Server/src/pages/personal-from-yt-temp.tcross @@ -0,0 +1,26 @@ +func Pages.YouTubeAnonyPlaylist(video_ids) +{ + const html = +
+
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+
+
; + return Components.Shell("Create playlist",html ,1); +} \ No newline at end of file diff --git a/Tesses.YouTubeDownloader/src/YouTubeDownloader.tcross b/Tesses.YouTubeDownloader/src/YouTubeDownloader.tcross index 55e5f0d..f5096d3 100644 --- a/Tesses.YouTubeDownloader/src/YouTubeDownloader.tcross +++ b/Tesses.YouTubeDownloader/src/YouTubeDownloader.tcross @@ -35,6 +35,8 @@ class UserFlags { return false; } static getITTR() 35000; + + static getExpires() 86400 * 7; } class TYTD.Downloader { @@ -72,6 +74,22 @@ class TYTD.Downloader { ^/ public DownloadVideo(id,$res) { + const theVideoId = TYTD.GetVideoId(id); + if(TypeIsString(theVideoId)) + { + const ent = { + Id = theVideoId, + Resolution = res, + Cancel = false, + Type = "Video" + }; + this.BeforeQueued.Invoke(this, ent); + if(ent.Cancel) { + this.LOG("Adding video canceled: {theVideoId}"); + return; + } + } + this.LOG($"Adding video: {TYTD.GetVideoId(id)}, original val: {id}"); switch(res) { @@ -121,6 +139,19 @@ class TYTD.Downloader { if(pid != null) { + + const ent = { + Id = pid, + Resolution = res, + Cancel = false, + Type = "Playlist" + }; + this.BeforeQueued.Invoke(this, ent); + if(ent.Cancel) { + this.LOG("Adding playlist canceled: {pid}"); + return; + } + this.LOG($"Adding playlist: https://www.youtube.com/playlist?list={pid}"); this.PlaylistQueue.Push(()=>{ each(var item : this.QueryPlaylistItems(pid,true)) @@ -145,6 +176,18 @@ class TYTD.Downloader { if(cid != null) { + + const ent = { + Id = cid, + Resolution = res, + Cancel = false, + Type = "Channel" + }; + this.BeforeQueued.Invoke(this, ent); + if(ent.Cancel) { + this.LOG("Adding channel canceled: {cid}"); + return; + } this.LOG($"Adding channel: https://www.youtube.com/channel/{cid}"); this.PlaylistQueue.Push(()=>{ each(var item : this.QueryPlaylistItems($"UU{cid.Substring(2)}",false)) @@ -182,6 +225,7 @@ class TYTD.Downloader { var pid = TYTD.GetPlaylistId(url); var cid = TYTD.GetChannelId(url); + var tmp = TYTD.GetYouTubeTempPlaylist(url); if(vid != null) { @@ -195,7 +239,13 @@ class TYTD.Downloader { { this.DownloadChannel(cid,res); } - + else if(tmp != null) + { + each(var item : tmp) + { + this.DownloadItem(item,res); + } + } } /^ Redirect url to info page @@ -204,6 +254,8 @@ class TYTD.Downloader { { var vid = TYTD.GetVideoId(url); var pid = TYTD.GetPlaylistId(url); + var cid = TYTD.GetChannelId(url); + var tmp = TYTD.GetYouTubeTempPlaylistRedirect(url); if(vid != null) { @@ -217,6 +269,10 @@ class TYTD.Downloader { { return $"./channel?id={Net.Http.UrlEncode(cid)}"; } + else if(tmp != null) + { + return tmp; + } return "./"; } @@ -590,6 +646,29 @@ class TYTD.Downloader { } return ""; } + public GetPersonalListTempUrl(name) + { + this.Muxex.Lock(); + var db = this.OpenDB(); + var items = []; + + var lists = Sqlite.Exec(db, $"SELECT * FROM personal_list_entries WHERE listName = {Sqlite.Escape(name)};"); + Sqlite.Close(db); + this.Mutex.Unlock(); + if(TypeIsList(lists)) + { + var url = $"https://www.youtube.com/watch_videos?video_ids="; + var first = true; + each(var item : lists) + { + if(!first) url += $",{item.videoId}"; + else url += item.videoId; + first=false; + } + return url; + } + return null; + } /^ ^/ public GetPersonalListContents(name, offset, count) { @@ -716,6 +795,8 @@ class TYTD.Downloader { public VideoProgress = new TYTD.Event(); + public BeforeQueued = new TYTD.Event(); + public CurrentVideo = { Title = "N/A", Channel = "N/A", @@ -930,6 +1011,7 @@ class TYTD.Downloader { while(this.Running) { try { + this.FlushExpired(); var res = this.PlaylistQueue.Pop(); if(TypeOf(res) != "Null") @@ -937,7 +1019,7 @@ class TYTD.Downloader { res(); } - var currentTime = DateTime.NowEpoch; + var currentTime = DateTime.NowEpoch ?? 0; var bt = this.Config.BellTimer; @@ -1057,26 +1139,27 @@ class TYTD.Downloader { if(TypeOf(res) != "Null") { - res.TYTD = this; - res.Progress = (progress)=>{ - this.CurrentVideoProgress = progress; - this.VideoProgress.Invoke(this, { - Video = res.Video, - progress + if(TypeIsDefined(res.TYTD = this)){ + res.Progress = (progress)=>{ + this.CurrentVideoProgress = progress; + this.VideoProgress.Invoke(this, { + Video = res.Video, + progress + }); + }; + + this.CurrentVideo = res.Video; + + this.VideoStarted.Invoke(this,{ + Video = res.Video }); - }; - - this.CurrentVideo = res.Video; - - this.VideoStarted.Invoke(this,{ - Video = res.Video - }); - res.Start(); + res.Start(); - this.VideoEnded.Invoke(this,{ - Video = res.Video - }); + this.VideoEnded.Invoke(this,{ + Video = res.Video + }); + } } } catch(ex) { try{ @@ -1297,7 +1380,10 @@ class TYTD.Downloader { Sqlite.Exec(db,"CREATE TABLE IF NOT EXISTS plugin_settings (id INTEGER PRIMARY KEY AUTOINCREMENT, extension TEXT, key TEXT, value TEXT, UNIQUE(extension,key) ON CONFLICT REPLACE);"); Sqlite.Exec(db,"CREATE TABLE IF NOT EXISTS subscriptions (id INTEGER PRIMARY KEY AUTOINCREMENT, channelId TEXT UNIQUE ON CONFLICT REPLACE, bell TEXT);"); Sqlite.Exec(db,"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT, password_salt TEXT, flags INTEGER);"); - Sqlite.Exec(db,"CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, accountId INTEGER, key TEXT UNIQUE);"); + Sqlite.Exec(db,"CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, accountId INTEGER, key TEXT UNIQUE, expires INTEGER);"); + Sqlite.Exec(db,"CREATE TABLE IF NOT EXISTS sso (id INTEGER PRIMARY KEY AUTOINCREMENT, service_name TEXT UNIQUE, service_pretty_name TEXT, sso_app_key TEXT UNIQUE, service_auth_post TEXT, service_auth_redirect TEXT);"); + Sqlite.Exec(db,"ALTER TABLE sessions ADD expires INTEGER;"); + Sqlite.Exec(db,"DELETE FROM sessions WHERE expires IS NULL;"); var config=Sqlite.Exec(db,"SELECT * FROM plugin_settings WHERE extension = '' AND key = 'settings';"); if(TypeOf(config) == "List" && config.Length>0) { @@ -1798,7 +1884,6 @@ class TYTD.Downloader { const token = GetSessionToken(ctx); if(TypeIsString(token)) { - this.Mutex.Lock(); const db = this.OpenDB(); Sqlite.Exec(db, $"DELETE FROM sessions WHERE key = {Sqlite.Escape(token)};"); @@ -1831,7 +1916,17 @@ class TYTD.Downloader { } return null; } + public FlushExpired() + { + this.Mutex.Lock(); + const db = this.OpenDB(); + const currentTime = DateTime.NowEpoch ?? 0; + const sessions = Sqlite.Exec(db, $"DELETE FROM sessions WHERE expires != 0 AND expires < {currentTime};"); + Sqlite.Close(db); + this.Mutex.Unlock(); + + } public IsLoggedIn(ctx) { this.Mutex.Lock(); @@ -1852,13 +1947,32 @@ class TYTD.Downloader { if(TypeIsString(sessionToken)) { const res = Sqlite.Exec(db, $"SELECT * FROM sessions s INNER JOIN users u ON s.accountId = u.id WHERE key = {Sqlite.Escape(sessionToken)};"); + if(TypeIsList(res)) each(var item : res) { + const whenItExpires = ParseLong(item.expires); + const currentTime = DateTime.NowEpoch ?? 0; + if(whenItExpires != 0 && currentTime < whenItExpires && (whenItExpires - currentTime) < (UserFlags.Expires-3600)) + { + const expiry = currentTime + UserFlags.Expires; + Sqlite.Exec(db, $"UPDATE sessions SET expires = {expiry} WHERE key = {Sqlite.Escape(sessionToken)};"); + + ctx.WithHeader("Set-Cookie",$"Session={sessionToken}; SameSite=Lax; Expires={new DateTime(expiry).ToHttpDate()}; HttpOnly"); + } + else if(whenItExpires != 0 && currentTime >= whenItExpires) + { + + Sqlite.Exec(db, $"DELETE FROM sessions WHERE key = {Sqlite.Escape(sessionToken)};"); + item.flags = 0; + } Sqlite.Close(db); this.Mutex.Unlock(); + return ParseLong(item.flags) | 1; + + } } @@ -1867,9 +1981,26 @@ class TYTD.Downloader { return 0; } + public GetSSO(appname) + { + this.Mutex.Lock(); + const db = this.OpenDB(); + const res = Sqlite.Exec(db, $"SELECT * FROM sso WHERE service_name = {Sqlite.Escape(appname)}"); + Sqlite.Close(db); + this.Mutex.Unlock(); + if(TypeIsList(res)) + { + each(var item : res) + { + return item; + } + } + return null; + } + public WhoAmI(ctx) { - his.Mutex.Lock(); + this.Mutex.Lock(); const db = this.OpenDB(); const res=Sqlite.Exec(db, "SELECT COUNT(*) FROM users;"); var noAccounts=true; @@ -1891,6 +2022,20 @@ class TYTD.Downloader { if(TypeIsList(res)) each(var item : res) { + const whenItExpires = ParseLong(item.expires); + const currentTime = DateTime.NowEpoch ?? 0; + if(whenItExpires != 0 && currentTime < whenItExpires && (whenItExpires - currentTime) < (UserFlags.Expires-3600)) + { + const expiry = currentTime + UserFlags.Expires; + Sqlite.Exec(db, $"UPDATE sessions SET expires = {expiry} WHERE key = {Sqlite.Escape(sessionToken)};"); + + ctx.WithHeader("Set-Cookie",$"Session={sessionToken}; SameSite=Lax; Expires={new DateTime(expiry).ToHttpDate()}"); + } + else if(whenItExpires != 0 && currentTime >= whenItExpires) + { + Sqlite.Exec(db, $"DELETE FROM sessions WHERE key = {Sqlite.Escape(sessionToken)};"); + item.flags = "0"; + } Sqlite.Close(db); this.Mutex.Unlock(); item.flags = ParseLong(item.flags); @@ -1905,9 +2050,9 @@ class TYTD.Downloader { public Passwd(ctx, oldPassword, newPassword, logout) { const whoami = this.WhoAmI(ctx); - if(TypeIsDictionary(user) && TypeIsString(item.password_salt)) + if(whoami.flags != 0 && TypeIsDictionary(whoami) && TypeIsString(whoami.password_salt)) { - var salt = Crypto.Base64Decode(item.password_salt); + var salt = Crypto.Base64Decode(whoami.password_salt); var hash = Crypto.PBKDF2(password, salt, UserFlags.ITTR,64,384); var hashStr = Crypto.Base64Encode(hash); @@ -1937,17 +2082,45 @@ class TYTD.Downloader { } } + return { success=false, reason = "Unable to login for some reason, maybe your token expired"}; } - public Login(username, password) + public Auth(username, password) { this.Mutex.Lock(); const db = this.OpenDB(); const user = Sqlite.Exec(db, $"SELECT * FROM users WHERE username = {Sqlite.Escape(username)};"); + Sqlite.Close(db); + + this.Mutex.Unlock(); + if(TypeIsList(user)) + { + each(var item : user) + { + + var salt = Crypto.Base64Decode(item.password_salt); + var hash = Crypto.PBKDF2(password, salt, UserFlags.ITTR,64,384); + var hashStr = Crypto.Base64Encode(hash); + + if(item.password_hash == hashStr) + { + return {flags = ParseLong(item.flags)}; + } + + } + } + + return null; + } + public Login(username, password, doesExpire) + { + this.Mutex.Lock(); + const db = this.OpenDB(); + const user = Sqlite.Exec(db, $"SELECT * FROM users WHERE username = {Sqlite.Escape(username)};"); + if(TypeIsList(user)) { each(var item : user) { - this.Mutex.Unlock(); var salt = Crypto.Base64Decode(item.password_salt); var hash = Crypto.PBKDF2(password, salt, UserFlags.ITTR,64,384); @@ -1956,18 +2129,22 @@ class TYTD.Downloader { if(item.password_hash == hashStr) { var rand = Net.Http.UrlEncode(Crypto.Base64Encode(Crypto.RandomBytes(32, "TYTD2025"))); - this.Mutex.Lock(); - const dbCon = this.OpenDB(); - Sqlite.Exec(dbCon, $"INSERT INTO sessions (accountId,key) VALUES ({item.id},{Sqlite.Escape(rand)});"); - Sqlite.Close(dbCon); + + const expires = doesExpire ? ((DateTime.NowEpoch??0) + UserFlags.Expires) : 0; + Sqlite.Exec(db, $"INSERT INTO sessions (accountId,key,expires) VALUES ({item.id},{Sqlite.Escape(rand)},{expires});"); + Sqlite.Close(db); this.Mutex.Unlock(); return rand; } + Sqlite.Close(db); + this.Mutex.Unlock(); return null; } } + Sqlite.Close(db); + this.Mutex.Unlock(); return null; } @@ -1992,6 +2169,25 @@ class TYTD.Downloader { return false; } - + public RegisterSSO(req) + { + this.Mutex.Lock(); + const db = this.OpenDB(); + /* + service_name TEXT UNIQUE, service_pretty_name TEXT, sso_app_key TEXT UNIQUE, service_auth_post TEXT, service_auth_redirect TEXT + */ + const resp = Sqlite.Exec(db, $"INSERT INTO sso (service_name, service_pretty_name, sso_app_key, service_auth_post, service_auth_redirect) VALUES ({Sqlite.Escape(req.service_name)},{Sqlite.Escape(req.service_pretty_name)},{Sqlite.Escape(req.sso_app_key)},{Sqlite.Escape(req.service_auth_post)}, {Sqlite.Escape(req.service_auth_redirect)});"); + Sqlite.Close(db); + this.Mutex.Unlock(); + if(TypeIsList(resp)) + { + return { success=true}; + } + else if(TypeIsString(resp)) + { + return { success = false, reason = resp , type="db"}; + } + return {success = false, reason = "Unknown", type ="db"}; + } } diff --git a/Tesses.YouTubeDownloader/src/ids.tcross b/Tesses.YouTubeDownloader/src/ids.tcross index e120754..7eba4c3 100644 --- a/Tesses.YouTubeDownloader/src/ids.tcross +++ b/Tesses.YouTubeDownloader/src/ids.tcross @@ -27,6 +27,7 @@ func TYTD.GetVideoId(v) return null; } + func TYTD.GetPlaylistId(pid) { func IsValidId(v) @@ -84,4 +85,49 @@ func TYTD.GetChannelId(cid) } return null; +} + +func TYTD.CreateYouTubeTempPlaylist(ids) +{ + var url = "https://www.youtube.com/watch_videos?video_ids="; + var first=true; + each(var item : ids) + { + if(!first) url += $"{url},{Net.Http.UrlEncode(item)}"; + else + url += Net.Http.UrlEncode(item); + first=false; + } + return url; +} +func TYTD.GetYouTubeTempPlaylistRedirect(url) +{ + if(url.Contains("/watch_videos?") && url.Contains("video_ids=")) + { + var queryPart = url.Split("?",true,2); + return $"/watch_videos?{queryPart[1]}"; + } + return null; +} +func TYTD.GetYouTubeTempPlaylist(url) +{ + if(url.Contains("/watch_videos?") && url.Contains("video_ids=")) + { + var queryPart = url.Split("?",true,2); + if(queryPart.Length == 2) + { + var queryParms =queryPart[1].Split("&"); + + each(var item : queryParms) + { + const vals = item.Split("=",true,2); + if(vals.Length == 2 && vals[0] == "video_ids") + { + return Net.Http.UrlDecode(vals[1]).Split(","); + + } + } + } + } + return null; } \ No newline at end of file diff --git a/Tesses.YouTubeDownloader/src/videodownload/audioonlydownload.tcross b/Tesses.YouTubeDownloader/src/videodownload/audioonlydownload.tcross index 6c7c4ca..89fe433 100644 --- a/Tesses.YouTubeDownloader/src/videodownload/audioonlydownload.tcross +++ b/Tesses.YouTubeDownloader/src/videodownload/audioonlydownload.tcross @@ -22,7 +22,7 @@ class TYTD.AOVideoDownload : IVideoDownload { var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"ao.bin"; if(this.tytd.Storage.FileExists(path)) { this.done = true; - return tytd; + return null; } var req = this.tytd.ManifestRequest(id).playerResponse; diff --git a/Tesses.YouTubeDownloader/src/videodownload/noconvertdownload.tcross b/Tesses.YouTubeDownloader/src/videodownload/noconvertdownload.tcross index 325be4e..9d600be 100644 --- a/Tesses.YouTubeDownloader/src/videodownload/noconvertdownload.tcross +++ b/Tesses.YouTubeDownloader/src/videodownload/noconvertdownload.tcross @@ -29,7 +29,7 @@ class TYTD.NoConvertVideoDownload : IVideoDownload { var pathA = /"Streams"/id.Substring(0,4)/id.Substring(4)/"ao.bin"; if(this.tytd.Storage.FileExists(path) && this.tytd.Storage.FileExists(pathA)) { this.done = true; - return tytd; + return null; } var req = this.tytd.ManifestRequest(id).playerResponse; diff --git a/Tesses.YouTubeDownloader/src/videodownload/sdvideodownload.tcross b/Tesses.YouTubeDownloader/src/videodownload/sdvideodownload.tcross index 7d247a1..b5a6e6a 100644 --- a/Tesses.YouTubeDownloader/src/videodownload/sdvideodownload.tcross +++ b/Tesses.YouTubeDownloader/src/videodownload/sdvideodownload.tcross @@ -22,7 +22,7 @@ class TYTD.SDVideoDownload : IVideoDownload { var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"ytmux.mp4"; if(this.tytd.Storage.FileExists(path)) { this.done = true; - return tytd; + return null; } var req = this.tytd.ManifestRequest(id).playerResponse; diff --git a/Tesses.YouTubeDownloader/src/videodownload/videoonlydownload.tcross b/Tesses.YouTubeDownloader/src/videodownload/videoonlydownload.tcross index c20c545..2a48053 100644 --- a/Tesses.YouTubeDownloader/src/videodownload/videoonlydownload.tcross +++ b/Tesses.YouTubeDownloader/src/videodownload/videoonlydownload.tcross @@ -22,7 +22,7 @@ class TYTD.VOVideoDownload : IVideoDownload { var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"vo.bin"; if(this.tytd.Storage.FileExists(path)) { this.done = true; - return tytd; + return null; } var req = this.tytd.ManifestRequest(id).playerResponse;