1038 lines
46 KiB
C#
1038 lines
46 KiB
C#
/*
|
|
TYTD Lite: A YouTube Downloader website that archives videos when people download videos using it.
|
|
Copyright (C) 2024 Mike Nolan
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Web;
|
|
using Humanizer;
|
|
using LiteDB;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using Scriban;
|
|
using Tesses.WebServer;
|
|
using YoutubeExplode;
|
|
using YoutubeExplode.Exceptions;
|
|
using YoutubeExplode.Videos;
|
|
using YoutubeExplode.Videos.Streams;
|
|
|
|
namespace TYTDLite
|
|
{
|
|
public class TYTDLiteServer
|
|
{
|
|
public ILiteDatabase Database {get;}
|
|
public ILiteCollection<SavedVideo> Videos => Database.GetCollection<SavedVideo>("Videos");
|
|
|
|
List<Job> Jobs {get;set;}=new List<Job>();
|
|
public string ServerPath {get;}
|
|
|
|
public RouteServer Server {get;}
|
|
|
|
public YoutubeClient YoutubeClient {get;}
|
|
public HttpClient HttpClient {get;}
|
|
|
|
Lazy<Template> template;
|
|
Lazy<Template> pageIndex;
|
|
Lazy<Template> pageDownload;
|
|
private Lazy<Template> pageInitDownload;
|
|
SemaphoreSlim semaphore=new SemaphoreSlim(1,1);
|
|
|
|
bool HasConverter => File.Exists(Path.Combine(ServerPath,"converter.txt"));
|
|
string ConverterPath => File.ReadAllText(Path.Combine(ServerPath,"converter.txt")).Replace("\r","").Replace("\n","");
|
|
|
|
public TYTDLiteServer(string serverPath,HttpClient client)
|
|
{
|
|
template = new Lazy<Template>(()=> Template.Parse(AssetProvider.ReadAllText("/template.html")),true);
|
|
pageIndex = new Lazy<Template>(()=> Template.Parse(AssetProvider.ReadAllText("/index.html")),true);
|
|
pageDownload = new Lazy<Template>(()=> Template.Parse(AssetProvider.ReadAllText("/video_download.html")),true);
|
|
pageInitDownload = new Lazy<Template>(()=> Template.Parse(AssetProvider.ReadAllText("/video_init_download.html")),true);
|
|
|
|
ServerPath = serverPath;
|
|
Directory.CreateDirectory(ServerPath);
|
|
Database = new LiteDatabase(Path.Combine(ServerPath,"tytdlite.db"));
|
|
|
|
Server = new RouteServer(new AssetProvider());
|
|
HttpClient = client;
|
|
YoutubeClient = new YoutubeClient(client);
|
|
Server.Add("/",IndexAsync,"GET");
|
|
Server.Add("/thumbnail",ThumbnailAsync,"GET");
|
|
Server.Add("/download",DownloadAsync,"GET");
|
|
Server.Add("/video",VideoAsync,"GET");
|
|
Server.Add("/init_download",InitDownloadAsync,"GET");
|
|
Server.Add("/init_download_event",InitDownloadEventAsync,"GET");
|
|
Server.Add("/info.json",InfoAsync,"GET");
|
|
Server.Add("/count",CountAsync,"GET");
|
|
}
|
|
|
|
public static void MainFunc(string[] args)
|
|
{
|
|
|
|
int port = 4220;
|
|
string path = "data";
|
|
HttpClient client=null;
|
|
if(args.Length >= 1)
|
|
{
|
|
if(args[0] == "--help")
|
|
{
|
|
Console.WriteLine("TYTD Lite: A YouTube Downloader website that archives videos when people download videos using it.");
|
|
Console.WriteLine("Created by Tesses (Mike Nolan): https://tesses.net/");
|
|
Console.WriteLine("Released under the GPLv3 License, for more info go: https://www.gnu.org/licenses/");
|
|
Console.WriteLine("Thanks for using this software, consider buying me a coffee: https://www.buymeacoffee.com/tesses50");
|
|
Console.WriteLine();
|
|
Console.WriteLine("USAGE:");
|
|
Console.WriteLine("tytdlite");
|
|
Console.WriteLine("tytdlite <path>");
|
|
Console.WriteLine("tytdlite <path> <port>");
|
|
Console.WriteLine("tytdlite <path> <port> <ignorecert:true|false>");
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
path = args[0];
|
|
if(args.Length >= 2)
|
|
{
|
|
if(int.TryParse(args[1],out var p)) port = p;
|
|
|
|
if(args.Length >= 3)
|
|
{
|
|
if(args[2] == "true")
|
|
{
|
|
System.Net.ServicePointManager.ServerCertificateValidationCallback += (a,b,c,d)=>true;
|
|
HttpClientHandler ch=new HttpClientHandler();
|
|
ch.ServerCertificateCustomValidationCallback = (a,b,c,d)=>true;
|
|
client = new HttpClient(ch);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TYTDLiteServer server=new TYTDLiteServer(path,client ?? new HttpClient());
|
|
server.Server.StartServer(port);
|
|
}
|
|
|
|
private async Task CountAsync(ServerContext ctx)
|
|
{
|
|
await ctx.SendJsonAsync(new{count = Videos.LongCount()});
|
|
}
|
|
|
|
private async Task InfoAsync(ServerContext ctx)
|
|
{
|
|
if(ctx.QueryParams.TryGetFirst("v",out var v))
|
|
{
|
|
var video=VideoId.TryParse(v);
|
|
if(video.HasValue)
|
|
{
|
|
var (_v,rfy,off)=await GetSavedVideoAsync(video.Value);
|
|
await ctx.SendJsonAsync(new{Video=_v,RemovedFromYouTube=rfy,Offline=off});
|
|
}
|
|
else
|
|
await ctx.SendNotFoundAsync();
|
|
}else{
|
|
await ctx.SendNotFoundAsync();
|
|
}
|
|
}
|
|
|
|
private async Task InitDownloadAsync(ServerContext ctx)
|
|
{
|
|
|
|
if(ctx.QueryParams.TryGetFirst("v",out var v) && ctx.QueryParams.TryGetFirst("res",out var res))
|
|
{
|
|
var video = VideoId.TryParse(v);
|
|
|
|
if(video.HasValue && reses.Contains(res))
|
|
{
|
|
|
|
await ctx.SendTextAsync(await template.Value.RenderAsync(new{body=await pageInitDownload.Value.RenderAsync(new{v,res})}));
|
|
}
|
|
}
|
|
}
|
|
string[] reses=new string[]{"PreMuxed","VideoOnly","AudioOnly","MKV","MP3","MP4"};
|
|
private async Task InitDownloadEventAsync(ServerContext ctx)
|
|
{
|
|
if(ctx.QueryParams.TryGetFirst("v",out var v) && ctx.QueryParams.TryGetFirst("res",out var res))
|
|
{
|
|
var video = VideoId.TryParse(v);
|
|
|
|
if(video.HasValue && reses.Contains(res))
|
|
{
|
|
SendEvents evts=new SendEvents();
|
|
|
|
Task.Run(()=>ctx.ServerSentEvents(evts)).Wait(0);
|
|
try{
|
|
|
|
await this.PreformTask(video.Value,res,new Progress<double>(e=>{
|
|
try{
|
|
evts.SendEvent(new{type="progress",progress=e});
|
|
}catch(Exception){}
|
|
}));
|
|
evts.SendEvent(new{type="done",url=$"./video?v={video.Value.Value}&res={res}"});
|
|
|
|
|
|
}catch(Exception ex)
|
|
{
|
|
try{
|
|
evts.SendEvent(new{type="error",error=ex});
|
|
|
|
}catch(Exception){}
|
|
}
|
|
|
|
|
|
}
|
|
else
|
|
{
|
|
await ctx.SendExceptionAsync(new ArgumentException("Must provide video id \"v\" and \"res\" must be PreMuxed, VideoOnly, AudioOnly, MKV, MP3 or MP4"));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await ctx.SendExceptionAsync(new ArgumentException("Must provide video id \"v\" and \"res\" must be PreMuxed, VideoOnly, AudioOnly, MKV, MP3 or MP4"));
|
|
}
|
|
}
|
|
|
|
private async Task VideoAsync(ServerContext ctx)
|
|
{
|
|
if(ctx.QueryParams.TryGetFirst("v",out var v) && ctx.QueryParams.TryGetFirst("res",out var res))
|
|
{
|
|
var video = VideoId.TryParse(v);
|
|
|
|
if(video.HasValue && reses.Contains(res))
|
|
{
|
|
await PreformTask(video.Value,res,new Progress<double>(e=>{
|
|
|
|
}));
|
|
var (svideo,_,_) = await GetSavedVideoAsync(video.Value);
|
|
|
|
switch(res)
|
|
{
|
|
case "PreMuxed":
|
|
{
|
|
string ext = svideo.PreMuxed != null ? svideo.PreMuxed.Container : "mp4";
|
|
await ctx.WithFileName(svideo.SafeFileName(ext),false).SendFileAsync(Path.Combine(ServerPath,"PreMuxed",$"{svideo.VideoId}.{ext}"));
|
|
}
|
|
break;
|
|
case "VideoOnly":
|
|
{
|
|
string ext = svideo.VideoOnly != null ? svideo.VideoOnly.Container : "mp4";
|
|
await ctx.WithFileName(svideo.SafeFileName(ext),false).SendFileAsync(Path.Combine(ServerPath,"VideoOnly",$"{svideo.VideoId}.{ext}"));
|
|
}
|
|
break;
|
|
case "AudioOnly":
|
|
{
|
|
string ext = svideo.AudioOnly != null ? svideo.AudioOnly.Container : "mp4";
|
|
await ctx.WithFileName(svideo.SafeFileName(ext),false).SendFileAsync(Path.Combine(ServerPath,"AudioOnly",$"{svideo.VideoId}.{ext}"));
|
|
}
|
|
break;
|
|
case "MP3":
|
|
{
|
|
await ctx.WithFileName(svideo.SafeFileName("mp3"),false).SendFileAsync(Path.Combine(ServerPath,"MP3",$"{svideo.VideoId}.mp3"));
|
|
}
|
|
break;
|
|
case "MP4":
|
|
{
|
|
await ctx.WithFileName(svideo.SafeFileName("mp4"),false).SendFileAsync(Path.Combine(ServerPath,"MP4",$"{svideo.VideoId}.mp4"));
|
|
}
|
|
break;
|
|
case "MKV":
|
|
{
|
|
await ctx.WithFileName(svideo.SafeFileName("mkv"),false).SendFileAsync(Path.Combine(ServerPath,"MKV",$"{svideo.VideoId}.mkv"));
|
|
}
|
|
break;
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<(SavedVideo video,bool removedByYt,bool offline)> GetSavedVideoAsync(VideoId id)
|
|
{
|
|
string _id = id.Value;
|
|
bool removedByYt=false;
|
|
bool offline=false;
|
|
SavedVideo video=new SavedVideo();
|
|
await LockAsync(async()=>{
|
|
var _video=Videos.FindOne(e=>e.VideoId == _id);
|
|
if(_video != null)
|
|
{
|
|
video = _video;
|
|
DateTime now = DateTime.Now;
|
|
if(video.Expires < now)
|
|
{
|
|
try{
|
|
var streams = await YoutubeClient.Videos.Streams.GetManifestAsync(id);
|
|
video.PreMuxed.Stream = streams.GetMuxedStreams().GetWithHighestVideoQuality();
|
|
video.VideoOnly.Stream = streams.GetVideoOnlyStreams().GetWithHighestVideoQuality();
|
|
video.AudioOnly.Stream = streams.GetAudioOnlyStreams().GetWithHighestBitrate();
|
|
video.Expires = now.AddHours(6);
|
|
Videos.Update(video);
|
|
}
|
|
catch(VideoRequiresPurchaseException ex)
|
|
{
|
|
_=ex;
|
|
removedByYt=true;
|
|
}
|
|
catch(VideoUnavailableException ex)
|
|
{
|
|
_=ex;
|
|
removedByYt=true;
|
|
}
|
|
catch(VideoUnplayableException ex)
|
|
{
|
|
_=ex;
|
|
removedByYt=true;
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
_=ex;
|
|
offline=true;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
try{
|
|
var now = DateTime.Now;
|
|
video = await YoutubeClient.Videos.GetAsync(id);
|
|
var streams = await YoutubeClient.Videos.Streams.GetManifestAsync(id);
|
|
video.PreMuxed.Stream = streams.GetMuxedStreams().GetWithHighestVideoQuality();
|
|
video.VideoOnly.Stream = streams.GetVideoOnlyStreams().GetWithHighestVideoQuality();
|
|
video.AudioOnly.Stream = streams.GetAudioOnlyStreams().GetWithHighestBitrate();
|
|
video.Expires = now.AddHours(6);
|
|
video.Id=Videos.Insert(video);
|
|
await DownloadThumbnailsAsync(id);
|
|
}catch(VideoRequiresPurchaseException ex)
|
|
{
|
|
_=ex;
|
|
removedByYt=true;
|
|
}
|
|
catch(VideoUnavailableException ex)
|
|
{
|
|
_=ex;
|
|
removedByYt=true;
|
|
}
|
|
catch(VideoUnplayableException ex)
|
|
{
|
|
_=ex;
|
|
removedByYt=true;
|
|
}
|
|
}
|
|
});
|
|
return (video,removedByYt,offline);
|
|
}
|
|
|
|
private async Task DownloadThumbnailsAsync(VideoId id)
|
|
{
|
|
async Task DLAsync(string resolution)
|
|
{
|
|
try{
|
|
string dir = Path.Combine(ServerPath,"Thumbnails",id.Value);
|
|
string filename = Path.Combine(dir,$"{resolution}.jpg");
|
|
|
|
Directory.CreateDirectory(dir);
|
|
if(!File.Exists(filename))
|
|
{
|
|
File.WriteAllBytes(filename,await HttpClient.GetByteArrayAsync($"https://s.ytimg.com/vi/{id.Value}/{resolution}.jpg"));
|
|
}
|
|
}catch(Exception ex){_=ex;}
|
|
}
|
|
await DLAsync("default");
|
|
await DLAsync("sddefault");
|
|
await DLAsync("mqdefault");
|
|
await DLAsync("hqdefault");
|
|
await DLAsync("maxresdefault");
|
|
}
|
|
|
|
private async Task ConvertAsync(string command,IProgress<TimeSpan> progress)
|
|
{
|
|
if(!HasConverter) return;
|
|
using(Process p=new Process())
|
|
{
|
|
p.StartInfo.CreateNoWindow = true;
|
|
p.StartInfo.UseShellExecute = false;
|
|
p.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
|
|
p.StartInfo.FileName = ConverterPath;
|
|
p.StartInfo.Arguments = command;
|
|
p.StartInfo.RedirectStandardError=true;
|
|
p.Start();
|
|
while(!p.HasExited)
|
|
{
|
|
|
|
var res=await p.StandardError.ReadLineAsync();
|
|
if(!string.IsNullOrEmpty(res))
|
|
{
|
|
int index = res.IndexOf("time=");
|
|
if(index > -1)
|
|
{
|
|
index+=5;
|
|
int index2 = res.IndexOf(' ',index);
|
|
string timeStr = index2 == -1 ? res.Substring(index) : res.Substring(index,index2-index);
|
|
if(!string.IsNullOrWhiteSpace(timeStr))
|
|
{
|
|
if(TimeSpan.TryParse(timeStr,out var ts))
|
|
{
|
|
progress?.Report(ts);
|
|
}
|
|
else
|
|
{
|
|
if(double.TryParse(timeStr,out var tsn)) //for avconv
|
|
{
|
|
progress?.Report(TimeSpan.FromSeconds(tsn));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task DownloadPreMuxed(SavedVideo video,IProgress<double> progress=null)
|
|
{
|
|
|
|
string dir = Path.Combine(ServerPath,"PreMuxed");
|
|
string complete=Path.Combine(dir,$"{video.VideoId}.{(video.PreMuxed != null ? video.PreMuxed.Container : "mp4")}");
|
|
string incomplete = Path.Combine(dir,$"{video.VideoId}incomplete.{(video.PreMuxed != null ? video.PreMuxed.Container : "mp4")}");
|
|
if(Directory.Exists(dir) && File.Exists(complete)) return;
|
|
//assume saved video has not expired
|
|
if(!Directory.Exists(dir))
|
|
Directory.CreateDirectory(dir);
|
|
var killSwitch = video.Expires-DateTime.Now;
|
|
using(CancellationTokenSource src=new CancellationTokenSource())
|
|
{
|
|
src.CancelAfter(killSwitch);
|
|
var token = src.Token;
|
|
byte[] buffer=new byte[4096];
|
|
using(var destStrm = File.Open(incomplete,FileMode.OpenOrCreate,FileAccess.Write))
|
|
{
|
|
destStrm.Position = destStrm.Length;
|
|
using(var srcStrm = await YoutubeClient.Videos.Streams.GetAsync(video.PreMuxed.Stream,token))
|
|
{
|
|
long pos = destStrm.Position;
|
|
long len = srcStrm.Length;
|
|
if(pos > 0)
|
|
srcStrm.Position = pos;
|
|
int read=0;
|
|
do {
|
|
read = await srcStrm.ReadAsync(buffer,0,buffer.Length,token);
|
|
if(token.IsCancellationRequested) return;
|
|
await destStrm.WriteAsync(buffer,0,read,token);
|
|
if(token.IsCancellationRequested) return;
|
|
pos+=read;
|
|
progress?.Report(pos / (double)len);
|
|
} while(read != 0 && !token.IsCancellationRequested);
|
|
if(token.IsCancellationRequested) return;
|
|
File.Move(incomplete,complete);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
private async Task DownloadVideoOnly(SavedVideo video,IProgress<double> progress=null)
|
|
{
|
|
string dir = Path.Combine(ServerPath,"VideoOnly");
|
|
string complete=Path.Combine(dir,$"{video.VideoId}.{(video.VideoOnly != null ? video.VideoOnly.Container : "mp4")}");
|
|
string incomplete = Path.Combine(dir,$"{video.VideoId}incomplete.{(video.VideoOnly != null ? video.VideoOnly.Container : "mp4")}");
|
|
if(Directory.Exists(dir) && File.Exists(complete)) return;
|
|
//assume saved video has not expired
|
|
if(!Directory.Exists(dir))
|
|
Directory.CreateDirectory(dir);
|
|
var killSwitch = video.Expires - DateTime.Now;
|
|
using(CancellationTokenSource src=new CancellationTokenSource())
|
|
{
|
|
src.CancelAfter(killSwitch);
|
|
var token = src.Token;
|
|
byte[] buffer=new byte[4096];
|
|
using(var destStrm = File.Open(incomplete,FileMode.OpenOrCreate,FileAccess.Write))
|
|
{
|
|
destStrm.Position = destStrm.Length;
|
|
using(var srcStrm = await YoutubeClient.Videos.Streams.GetAsync(video.VideoOnly.Stream,token))
|
|
{
|
|
long pos = destStrm.Position;
|
|
long len = srcStrm.Length;
|
|
if(pos > 0)
|
|
srcStrm.Position = pos;
|
|
int read=0;
|
|
do {
|
|
read = await srcStrm.ReadAsync(buffer,0,buffer.Length,token);
|
|
if(token.IsCancellationRequested) return;
|
|
await destStrm.WriteAsync(buffer,0,read,token);
|
|
if(token.IsCancellationRequested) return;
|
|
pos+=read;
|
|
progress?.Report(pos / (double)len);
|
|
} while(read != 0 && !token.IsCancellationRequested);
|
|
if(token.IsCancellationRequested) return;
|
|
File.Move(incomplete,complete);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task DownloadAudioOnly(SavedVideo video,IProgress<double> progress=null)
|
|
{
|
|
string dir = Path.Combine(ServerPath,"AudioOnly");
|
|
string complete=Path.Combine(dir,$"{video.VideoId}.{(video.AudioOnly != null ? video.AudioOnly.Container : "mp4")}");
|
|
string incomplete = Path.Combine(dir,$"{video.VideoId}incomplete.{(video.AudioOnly != null ? video.AudioOnly.Container : "mp4")}");
|
|
if(Directory.Exists(dir) && File.Exists(complete)) return;
|
|
//assume saved video has not expired
|
|
if(!Directory.Exists(dir))
|
|
Directory.CreateDirectory(dir);
|
|
var killSwitch = video.Expires - DateTime.Now;
|
|
using(CancellationTokenSource src=new CancellationTokenSource())
|
|
{
|
|
src.CancelAfter(killSwitch);
|
|
var token = src.Token;
|
|
byte[] buffer=new byte[4096];
|
|
using(var destStrm = File.Open(incomplete,FileMode.OpenOrCreate,FileAccess.Write))
|
|
{
|
|
destStrm.Position = destStrm.Length;
|
|
using(var srcStrm = await YoutubeClient.Videos.Streams.GetAsync(video.AudioOnly.Stream,token))
|
|
{
|
|
long pos = destStrm.Position;
|
|
long len = srcStrm.Length;
|
|
if(pos > 0)
|
|
srcStrm.Position = pos;
|
|
int read=0;
|
|
do {
|
|
read = await srcStrm.ReadAsync(buffer,0,buffer.Length,token);
|
|
if(token.IsCancellationRequested) return;
|
|
await destStrm.WriteAsync(buffer,0,read,token);
|
|
if(token.IsCancellationRequested) return;
|
|
pos+=read;
|
|
progress?.Report(pos / (double)len);
|
|
} while(read != 0 && !token.IsCancellationRequested);
|
|
if(token.IsCancellationRequested) return;
|
|
File.Move(incomplete,complete);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ConvertToMp3(SavedVideo video,IProgress<double> progress=null)
|
|
{
|
|
string dir = Path.Combine(ServerPath,"MP3");
|
|
string incomplete = Path.Combine(dir,$"{video.VideoId}incomplete.mp3");
|
|
string complete = Path.Combine(dir,$"{video.VideoId}.mp3");
|
|
string audioonly=Path.Combine(ServerPath,"AudioOnly",$"{video.VideoId}.{(video.AudioOnly != null ? video.AudioOnly.Container : "mp4")}");
|
|
|
|
string premuxed = Path.Combine(ServerPath,"PreMuxed",$"{video.VideoId}.{(video.PreMuxed != null ? video.PreMuxed.Container : "mp4")}");
|
|
if(!Directory.Exists(dir))
|
|
Directory.CreateDirectory(dir);
|
|
if(File.Exists(complete)) return;
|
|
await PreformTask(video.VideoId,"AudioOnly",new Progress<double>(e=>{
|
|
progress?.Report(e / 2);
|
|
}));
|
|
if(!File.Exists(audioonly))
|
|
{
|
|
await PreformTask(video.VideoId,"PreMuxed",new Progress<double>(e=>{
|
|
progress?.Report(e/2);
|
|
}));
|
|
audioonly = premuxed;
|
|
}
|
|
if(File.Exists(audioonly) && !File.Exists(complete))
|
|
{
|
|
if(File.Exists(incomplete))
|
|
File.Delete(incomplete);
|
|
await ConvertAsync($"-i \"{audioonly}\" -c:a libmp3lame -vn -preset ultrafast \"{incomplete}\"",new Progress<TimeSpan>((v)=>{
|
|
progress?.Report(( v.TotalSeconds/ video.Duration.TotalSeconds/2)+0.5);
|
|
}));
|
|
}
|
|
if(File.Exists(incomplete))
|
|
File.Move(incomplete,complete);
|
|
}
|
|
private async Task MuxToMkv(SavedVideo video,IProgress<double> progress=null)
|
|
{
|
|
string dir = Path.Combine(ServerPath,"MKV");
|
|
string incomplete = Path.Combine(dir,$"{video.VideoId}incomplete.mkv");
|
|
string complete = Path.Combine(dir,$"{video.VideoId}.mkv");
|
|
string audioonly=Path.Combine(ServerPath,"AudioOnly",$"{video.VideoId}.{(video.AudioOnly != null ? video.AudioOnly.Container : "mp4")}");
|
|
string videoonly=Path.Combine(ServerPath,"VideoOnly",$"{video.VideoId}.{(video.VideoOnly != null ? video.VideoOnly.Container : "mp4")}");
|
|
string premuxed = Path.Combine(ServerPath,"PreMuxed",$"{video.VideoId}.{(video.PreMuxed != null ? video.PreMuxed.Container : "mp4")}");
|
|
|
|
if(File.Exists(complete)) return;
|
|
if(!Directory.Exists(dir))
|
|
Directory.CreateDirectory(dir);
|
|
await PreformTask(video.VideoId,"VideoOnly",new Progress<double>(e=>{
|
|
progress?.Report(e / 3);
|
|
}));
|
|
await PreformTask(video.VideoId,"AudioOnly",new Progress<double>(e=>{
|
|
progress?.Report((e/3)+0.3333);
|
|
}));
|
|
if(!File.Exists(audioonly))
|
|
{
|
|
await PreformTask(video.VideoId,"PreMuxed",new Progress<double>(e=>{
|
|
progress?.Report((e/3)+0.3333);
|
|
}));
|
|
audioonly = premuxed;
|
|
}
|
|
if(File.Exists(audioonly) && File.Exists(videoonly) && !File.Exists(complete))
|
|
{
|
|
if(File.Exists(incomplete))
|
|
File.Delete(incomplete);
|
|
await ConvertAsync($"-i \"{videoonly}\" -i \"{audioonly}\" -c copy -map 0:v -map 1:a -preset ultrafast \"{incomplete}\"",new Progress<TimeSpan>((v)=>{
|
|
progress?.Report(( v.TotalSeconds/video.Duration.TotalSeconds/3)+0.6666); //no we are not worshiping satan that is 2/3
|
|
}));
|
|
}
|
|
if(File.Exists(incomplete))
|
|
File.Move(incomplete,complete);
|
|
}
|
|
|
|
private async Task ConvertToMp4(SavedVideo video,IProgress<double> progress=null)
|
|
{
|
|
string dir = Path.Combine(ServerPath,"MP4");
|
|
string incomplete = Path.Combine(dir,$"{video.VideoId}incomplete.mp4");
|
|
string complete = Path.Combine(dir,$"{video.VideoId}.mp4");
|
|
string audioonly=Path.Combine(ServerPath,"AudioOnly",$"{video.VideoId}.{(video.AudioOnly != null ? video.AudioOnly.Container : "mp4")}");
|
|
string videoonly=Path.Combine(ServerPath,"VideoOnly",$"{video.VideoId}.{(video.VideoOnly != null ? video.VideoOnly.Container : "mp4")}");
|
|
string premuxed = Path.Combine(ServerPath,"PreMuxed",$"{video.VideoId}.{(video.PreMuxed != null ? video.PreMuxed.Container : "mp4")}");
|
|
|
|
if(File.Exists(complete)) return;
|
|
if(!Directory.Exists(dir))
|
|
Directory.CreateDirectory(dir);
|
|
await PreformTask(video.VideoId,"VideoOnly",new Progress<double>(e=>{
|
|
progress?.Report(e / 3);
|
|
}));
|
|
await PreformTask(video.VideoId,"AudioOnly",new Progress<double>(e=>{
|
|
progress?.Report((e/3)+33.3333);
|
|
}));
|
|
if(!File.Exists(audioonly))
|
|
{
|
|
await PreformTask(video.VideoId,"PreMuxed",new Progress<double>(e=>{
|
|
progress?.Report((e/3)+33.3333);
|
|
}));
|
|
audioonly = premuxed;
|
|
}
|
|
if(File.Exists(audioonly) && File.Exists(videoonly) && !File.Exists(complete))
|
|
{
|
|
if(File.Exists(incomplete))
|
|
File.Delete(incomplete);
|
|
await ConvertAsync($"-i \"{videoonly}\" -i \"{audioonly}\" -c:v libx264 -c:a aac -map 0:v -map 1:a -preset ultrafast \"{incomplete}\"",new Progress<TimeSpan>((v)=>{
|
|
progress?.Report((v.TotalSeconds/video.Duration.TotalSeconds/3)+66.6666); //no we are not worshiping satan that is 2/3
|
|
}));
|
|
}
|
|
if(File.Exists(incomplete))
|
|
File.Move(incomplete,complete);
|
|
}
|
|
private async Task PreformTask(VideoId id, string resolution,IProgress<double> progress=null)
|
|
{
|
|
Job _j=null;
|
|
await LockAsync(async()=>{
|
|
foreach(var j in Jobs)
|
|
{
|
|
if(j.Id == id && j.JobType == resolution)
|
|
{
|
|
_j = j;
|
|
}
|
|
}
|
|
if(_j == null)
|
|
{
|
|
_j = new Job();
|
|
_j.Id = id;
|
|
_j.JobType = resolution;
|
|
_j.Slim = semaphore;
|
|
Jobs.Add(_j);
|
|
_j.JobTask = async(p)=>{
|
|
switch(resolution)
|
|
{
|
|
case "PreMuxed":
|
|
{
|
|
var (video,removedByYT,off) = await GetSavedVideoAsync(id);
|
|
if(off && !Directory.Exists(Path.Combine(ServerPath,"PreMuxed")) && !File.Exists(Path.Combine(ServerPath,"PreMuxed",$"{video.VideoId}.{(video.PreMuxed != null ? video.PreMuxed.Container : "mp4")}")))
|
|
throw new VideoUnavailableException("We don't have access to YouTube");
|
|
|
|
if(removedByYT && !Directory.Exists(Path.Combine(ServerPath,"PreMuxed")) && !File.Exists(Path.Combine(ServerPath,"PreMuxed",$"{video.VideoId}.{(video.PreMuxed != null ? video.PreMuxed.Container : "mp4")}")))
|
|
throw new VideoUnavailableException("We dont have the video either");
|
|
|
|
await DownloadPreMuxed(video,p);
|
|
}
|
|
break;
|
|
case "AudioOnly":
|
|
{
|
|
var (video,removedByYT,off) = await GetSavedVideoAsync(id);
|
|
if(off && !Directory.Exists(Path.Combine(ServerPath,"AudioOnly")) && !File.Exists(Path.Combine(ServerPath,"AudioOnly",$"{video.VideoId}.{(video.AudioOnly != null ? video.AudioOnly.Container : "mp4")}")))
|
|
throw new VideoUnavailableException("We don't have access to YouTube");
|
|
if(removedByYT && !Directory.Exists(Path.Combine(ServerPath,"AudioOnly")) && !File.Exists(Path.Combine(ServerPath,"AudioOnly",$"{video.VideoId}.{(video.AudioOnly != null ? video.AudioOnly.Container : "mp4")}")))
|
|
throw new VideoUnavailableException("We dont have the video either");
|
|
|
|
await DownloadAudioOnly(video,p);
|
|
}
|
|
break;
|
|
case "VideoOnly":
|
|
{
|
|
var (video,removedByYT,off) = await GetSavedVideoAsync(id);
|
|
if(off && !Directory.Exists(Path.Combine(ServerPath,"VideoOnly")) && !File.Exists(Path.Combine(ServerPath,"VideoOnly",$"{video.VideoId}.{(video.VideoOnly != null ? video.VideoOnly.Container : "mp4")}")))
|
|
throw new VideoUnavailableException("We don't have access to YouTube");
|
|
if(removedByYT && !Directory.Exists(Path.Combine(ServerPath,"VideoOnly")) && !File.Exists(Path.Combine(ServerPath,"VideoOnly",$"{video.VideoId}.{(video.VideoOnly != null ? video.VideoOnly.Container : "mp4")}")))
|
|
throw new VideoUnavailableException("We dont have the video either");
|
|
|
|
await DownloadVideoOnly(video,p);
|
|
}
|
|
break;
|
|
case "MP3":
|
|
{
|
|
var (video,removedByYT,off) = await GetSavedVideoAsync(id);
|
|
|
|
if(!HasConverter && !Directory.Exists(Path.Combine(ServerPath,"MP3")) && !File.Exists(Path.Combine(ServerPath,"MP3",$"{id}.mp3")))
|
|
throw new OperationCanceledException("We don't have the converter.");
|
|
|
|
await ConvertToMp3(video,p);
|
|
}
|
|
break;
|
|
case "MP4":
|
|
{
|
|
var (video,removedByYT,off) = await GetSavedVideoAsync(id);
|
|
|
|
if(!HasConverter && !Directory.Exists(Path.Combine(ServerPath,"MP4")) && !File.Exists(Path.Combine(ServerPath,"MP4",$"{id}.mp4")))
|
|
throw new OperationCanceledException("We don't have the converter.");
|
|
|
|
await ConvertToMp4(video,p);
|
|
}
|
|
break;
|
|
case "MKV":
|
|
{
|
|
var (video,removedByYT,off) = await GetSavedVideoAsync(id);
|
|
|
|
if(!HasConverter && !Directory.Exists(Path.Combine(ServerPath,"MKV")) && !File.Exists(Path.Combine(ServerPath,"MKV",$"{id}.mkv")))
|
|
throw new OperationCanceledException("We don't have the converter.");
|
|
|
|
await MuxToMkv(video,p);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
_j.Run(Jobs);
|
|
}
|
|
await Task.CompletedTask;
|
|
});
|
|
|
|
if(_j == null) throw new NullReferenceException();
|
|
Action<double> update= (e)=>{
|
|
try{
|
|
progress?.Report(e);
|
|
}catch(Exception ex){_=ex;}
|
|
};
|
|
_j.Update+=update;
|
|
|
|
while(!_j.Done)
|
|
{
|
|
if(_j.CurrentException != null) throw _j.CurrentException;
|
|
}
|
|
_j.Update-=update;
|
|
}
|
|
|
|
private async Task DownloadAsync(ServerContext ctx)
|
|
{
|
|
if(ctx.QueryParams.TryGetFirst("url",out var url))
|
|
{
|
|
VideoId? id = VideoId.TryParse(url);
|
|
object result = new {invalidurl=true};
|
|
|
|
if(id.HasValue)
|
|
{
|
|
(SavedVideo video,bool notavailable,bool offline)=await GetSavedVideoAsync(id.Value);
|
|
bool premuxed = (notavailable || offline) ? await StreamExistsAsync(video,"PreMuxed") : true;
|
|
bool audioonly = (notavailable || offline) ? await StreamExistsAsync(video,"AudioOnly") : true;
|
|
bool videoonly = (notavailable || offline) ? await StreamExistsAsync(video,"VideoOnly") : true;
|
|
bool converter=HasConverter; //change me
|
|
result = new {
|
|
offline,
|
|
notavailable,
|
|
premuxed,
|
|
audioonly,
|
|
videoonly,
|
|
converter,
|
|
v = id.Value.Value,
|
|
title = HttpUtility.HtmlEncode(video.VideoTitle),
|
|
channel = HttpUtility.HtmlEncode(video.ChannelTitle),
|
|
otherstats = $"{MakeWeb(video.Views)} View(s) {MakeWeb(video.Likes)} Like(s)<br>Uploaded: {video.UploadDate.Humanize()}<br>Length: {video.Duration.ToString()}",
|
|
desc=DescriptLinkUtils(video.Description ?? "").Replace("\n","<br>")
|
|
};
|
|
}
|
|
|
|
await ctx.SendTextAsync(await template.Value.RenderAsync(new{body=await pageDownload.Value.RenderAsync(result)}));
|
|
|
|
}
|
|
}
|
|
|
|
private static bool StartsWithAt(string str,int indexof,string startsWith)
|
|
{
|
|
if(str.Length-indexof < startsWith.Length) return false;
|
|
for(int i = 0;i<startsWith.Length;i++)
|
|
{
|
|
if(str[i+indexof] != startsWith[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
internal static string DescriptLinkUtils(string url)
|
|
{
|
|
StringBuilder b=new StringBuilder();
|
|
for(int i = 0;i<url.Length;i++)
|
|
{
|
|
if(StartsWithAt(url,i,"http:") || StartsWithAt(url,i,"https:") || StartsWithAt(url,i,"ftp:") || StartsWithAt(url,i,"ftps:") || StartsWithAt(url,i,"sftp:") || StartsWithAt(url,i,"magnet:"))
|
|
{
|
|
StringBuilder b2=new StringBuilder();
|
|
for(;i<url.Length;i++)
|
|
{
|
|
if(url[i] == '\n' || url[i] == ' ')
|
|
{
|
|
break;
|
|
}
|
|
b2.Append(url[i]);
|
|
}
|
|
i--;
|
|
b.Append($"<a href=\"{HttpUtility.HtmlAttributeEncode(b2.ToString())}\">{HttpUtility.HtmlEncode(b2.ToString())}</a>");
|
|
}
|
|
else
|
|
{
|
|
b.Append(HttpUtility.HtmlEncode(url[i]));
|
|
}
|
|
}
|
|
return b.ToString();
|
|
}
|
|
private string MakeWeb(long no)
|
|
{
|
|
if(no < 1000) return $"{no}";
|
|
if(no < 1000000) return $"{(long)(no/1000)} k";
|
|
if(no < 1000000000) return $"{(long)(no/1000000)} M";
|
|
if(no < 1000000000000) return $"{(long)(no/1000000000)} B";
|
|
return $"{(long)(no/1000000000000)} T";
|
|
}
|
|
|
|
private async Task<bool> StreamExistsAsync(SavedVideo video, string res)
|
|
{
|
|
|
|
switch(res)
|
|
{
|
|
case "PreMuxed":
|
|
{
|
|
string dir = Path.Combine(ServerPath,"PreMuxed");
|
|
if(!Directory.Exists(dir)) return false;
|
|
return await Task.FromResult(File.Exists(Path.Combine(dir,$"{video.VideoId}.{(video.PreMuxed != null ? video.PreMuxed.Container : "mp4")}")));
|
|
}
|
|
case "VideoOnly":
|
|
{
|
|
string dir = Path.Combine(ServerPath,"VideoOnly");
|
|
if(!Directory.Exists(dir)) return false;
|
|
return await Task.FromResult(File.Exists(Path.Combine(dir,$"{video.VideoId}.{(video.VideoOnly != null ? video.VideoOnly.Container : "mp4")}")));
|
|
}
|
|
case "AudioOnly":
|
|
{
|
|
string dir = Path.Combine(ServerPath,"AudioOnly");
|
|
if(!Directory.Exists(dir)) return false;
|
|
return await Task.FromResult(File.Exists(Path.Combine(dir,$"{video.VideoId}.{(video.AudioOnly != null ? video.AudioOnly.Container : "mp4")}")));
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async Task LockAsync(Func<Task> task)
|
|
{
|
|
Exception ex2=null;
|
|
await semaphore.WaitAsync();
|
|
try{
|
|
await task();
|
|
}catch(Exception ex)
|
|
{
|
|
ex2 = ex;
|
|
}
|
|
semaphore.Release();
|
|
if(ex2 != null) throw ex2;
|
|
}
|
|
private async Task<byte[]> GetThumbnailAsync(VideoId id,string resolution)
|
|
{
|
|
string dir = Path.Combine(ServerPath,"Thumbnails",id.Value);
|
|
string filename = Path.Combine(dir,$"{resolution}.jpg");
|
|
await LockAsync(async()=>{
|
|
Directory.CreateDirectory(dir);
|
|
if(!File.Exists(filename))
|
|
{
|
|
File.WriteAllBytes(filename,await HttpClient.GetByteArrayAsync($"https://s.ytimg.com/vi/{id.Value}/{resolution}.jpg"));
|
|
}
|
|
});
|
|
return File.ReadAllBytes(filename);
|
|
}
|
|
private async Task ThumbnailAsync(ServerContext ctx)
|
|
{
|
|
if(ctx.QueryParams.TryGetFirst("v",out var v))
|
|
{
|
|
if(!ctx.QueryParams.TryGetFirst("res",out var resolution))
|
|
{
|
|
resolution="sddefault";
|
|
}
|
|
VideoId? id = VideoId.TryParse(v);
|
|
if(id.HasValue)
|
|
{
|
|
await ctx.SendBytesAsync(await GetThumbnailAsync(id.Value,resolution),"image/jpeg");
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task IndexAsync(ServerContext ctx)
|
|
{
|
|
await ctx.SendTextAsync(await template.Value.RenderAsync(new{body=await pageIndex.Value.RenderAsync()}));
|
|
}
|
|
/*
|
|
public async Task<IDownloadHandle> GetDownloadHandle(VideoId id, Resolution resolution)
|
|
{
|
|
|
|
}*/
|
|
}
|
|
|
|
public class Job
|
|
{
|
|
public SemaphoreSlim Slim {get;set;}
|
|
public Exception CurrentException {get;set;}=null;
|
|
|
|
|
|
|
|
public void Run(List<Job> jobs)
|
|
{
|
|
Task.Run(async()=>{
|
|
try{
|
|
await JobTask(new Progress<double>(e=>{
|
|
Update?.Invoke(e);
|
|
}));
|
|
}catch(Exception ex)
|
|
{
|
|
CurrentException = ex;
|
|
await Slim.WaitAsync();
|
|
jobs.Remove(this);
|
|
Slim.Release();
|
|
}
|
|
await Slim.WaitAsync();
|
|
jobs.Remove(this);
|
|
Slim.Release();
|
|
Done = true;
|
|
}).Wait(0);
|
|
}
|
|
public Func<IProgress<double>,Task> JobTask {get;set;}
|
|
public event Action<double> Update;
|
|
|
|
public bool Done {get;set;}
|
|
|
|
public string JobType {get;set;}
|
|
|
|
public string Id {get;set;}
|
|
}
|
|
|
|
public class SavedStreamInfo
|
|
{
|
|
[BsonIgnore]
|
|
[JsonIgnore]
|
|
public IStreamInfo Stream {
|
|
get=>new SSI(this);
|
|
set{
|
|
Url=value.Url;
|
|
Container = value.Container.Name;
|
|
Size = value.Size.Bytes;
|
|
Bitrate = value.Bitrate.BitsPerSecond;
|
|
}
|
|
}
|
|
public string Container {get;set;}="";
|
|
public string Url {get;set;}="";
|
|
public long Size {get;set;}
|
|
|
|
public long Bitrate {get;set;}
|
|
|
|
|
|
|
|
private class SSI : IStreamInfo
|
|
{
|
|
SavedStreamInfo info;
|
|
public SSI(SavedStreamInfo info)
|
|
{
|
|
this.info = info;
|
|
}
|
|
public string Url => info.Url;
|
|
|
|
public Container Container => new Container(info.Container);
|
|
|
|
public FileSize Size => new FileSize(info.Size);
|
|
|
|
public Bitrate Bitrate => new Bitrate(info.Bitrate);
|
|
}
|
|
}
|
|
|
|
public class SavedVideo
|
|
{
|
|
public long Id {get;set;}
|
|
public string VideoId {get;set;}="";
|
|
public string VideoTitle {get;set;}="";
|
|
public string ChannelId {get;set;}="";
|
|
public string ChannelTitle {get;set;}="";
|
|
|
|
public string Description {get;set;}="";
|
|
|
|
public List<string> Tags {get;set;}=new List<string>();
|
|
|
|
public TimeSpan Duration {get;set;}=TimeSpan.Zero;
|
|
|
|
public long Likes {get;set;}
|
|
|
|
public long Dislikes {get;set;}
|
|
|
|
public long Views {get;set;}
|
|
|
|
public DateTime UploadDate {get;set;}=DateTime.MinValue;
|
|
|
|
public SavedStreamInfo PreMuxed {get;set;}=new SavedStreamInfo();
|
|
|
|
public SavedStreamInfo AudioOnly {get;set;}=new SavedStreamInfo();
|
|
|
|
public SavedStreamInfo VideoOnly {get;set;}=new SavedStreamInfo();
|
|
|
|
public DateTime Expires {get;set;}=DateTime.Now;
|
|
|
|
internal string SafeFileName(string ext)
|
|
{
|
|
const string BadChars = "<>?/\\*|^?\":";
|
|
StringBuilder b=new StringBuilder();
|
|
foreach(var c in VideoTitle)
|
|
{
|
|
if(BadChars.Contains(c)) continue;
|
|
if(char.IsControl(c)) continue;
|
|
b.Append(c);
|
|
}
|
|
b.Append($"-{VideoId}.{ext}");
|
|
return b.ToString();
|
|
}
|
|
|
|
public static implicit operator SavedVideo(Video video)
|
|
{
|
|
SavedVideo video1=new SavedVideo();
|
|
video1.VideoId = video.Id;
|
|
video1.VideoTitle = video.Title;
|
|
video1.ChannelId = video.Author.ChannelId;
|
|
video1.ChannelTitle = video.Author.ChannelTitle;
|
|
video1.Description=video.Description;
|
|
video1.Duration = video.Duration.HasValue ? video.Duration.Value : TimeSpan.Zero;
|
|
video1.UploadDate = video.UploadDate.Date;
|
|
video1.Likes = video.Engagement.LikeCount;
|
|
video1.Dislikes = video.Engagement.DislikeCount; //why do I bother
|
|
video1.Views = video.Engagement.ViewCount;
|
|
video1.Tags.AddRange(video.Keywords);
|
|
return video1;
|
|
}
|
|
}
|
|
|
|
|
|
}
|