595 lines
24 KiB
C#
595 lines
24 KiB
C#
using System;
|
|
using YoutubeExplode;
|
|
using YoutubeExplode.Videos;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Net.Http;
|
|
using YoutubeExplode.Videos.Streams;
|
|
using System.IO;
|
|
using System.Diagnostics;
|
|
using System.Text;
|
|
using System.Globalization;
|
|
using Newtonsoft.Json;
|
|
|
|
namespace Tesses.YouTubeDownloader
|
|
{
|
|
public abstract partial class TYTDStorage
|
|
{
|
|
|
|
private async Task DownloadLoop(CancellationToken token = default(CancellationToken))
|
|
{
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
bool hasAny;
|
|
var (Video, Resolution) = Dequeue(out hasAny);
|
|
if (hasAny)
|
|
{
|
|
await DownloadVideoAsync(Video, Resolution, token);
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
public readonly SavedVideoProgress Progress = new SavedVideoProgress();
|
|
private void ReportProgress(double progress)
|
|
{
|
|
Progress.Progress = (int)(progress * 100);
|
|
Progress.ProgressRaw = progress;
|
|
|
|
if (ExtensionContext != null)
|
|
{
|
|
foreach (var ext in ExtensionContext.Extensions)
|
|
{
|
|
ext.VideoProgress(Progress.Video, progress);
|
|
}
|
|
}
|
|
}
|
|
private void ReportStartVideo(SavedVideo video, Resolution resolution, long length)
|
|
{
|
|
Progress.Video = video;
|
|
Progress.Progress = 0;
|
|
Progress.ProgressRaw = 0;
|
|
Progress.Length = length;
|
|
|
|
if (ExtensionContext != null)
|
|
{
|
|
foreach (var item in ExtensionContext.Extensions)
|
|
{
|
|
item.VideoStarted(video, resolution, length);
|
|
}
|
|
}
|
|
}
|
|
private void ReportEndVideo(SavedVideo video, Resolution resolution)
|
|
{
|
|
Progress.Progress = 100;
|
|
Progress.ProgressRaw = 1;
|
|
|
|
|
|
if (ExtensionContext != null)
|
|
{
|
|
foreach (var item in ExtensionContext.Extensions)
|
|
{
|
|
item.VideoFinished(video, resolution);
|
|
}
|
|
}
|
|
}
|
|
private async Task DownloadVideoAsync(SavedVideo video, Resolution resolution, CancellationToken token)
|
|
{
|
|
switch (resolution)
|
|
{
|
|
case Resolution.Mux:
|
|
await DownloadVideoMuxedAsync(video,token);
|
|
break;
|
|
case Resolution.PreMuxed:
|
|
await DownloadPreMuxedVideoAsync(video, token);
|
|
break;
|
|
case Resolution.AudioOnly:
|
|
await DownloadAudioOnlyAsync(video,token);
|
|
break;
|
|
case Resolution.VideoOnly:
|
|
await DownloadVideoOnlyAsync(video,token);
|
|
break;
|
|
}
|
|
|
|
}
|
|
public async Task<bool> Continue(string path)
|
|
{
|
|
|
|
if (await FileExistsAsync(path))
|
|
{
|
|
return (await GetLengthAsync(path) == 0);
|
|
}
|
|
return true;
|
|
}
|
|
private static async Task<bool> run_process(string filename,CancellationToken token,params string[] args)
|
|
{
|
|
return await run_process(filename,null,token,args);
|
|
}
|
|
private static async Task<bool> run_process(string filename,IProgress<string> new_line,CancellationToken token, params string[] args)
|
|
{
|
|
using(Process process=new Process()){
|
|
process.StartInfo.FileName = filename;
|
|
StringBuilder builder=new StringBuilder();
|
|
foreach(var arg in args)
|
|
{
|
|
builder.Append($"\"{arg}\" ");
|
|
}
|
|
process.StartInfo.Arguments=builder.ToString();
|
|
if(new_line !=null)
|
|
{
|
|
process.StartInfo.UseShellExecute=false;
|
|
process.StartInfo.RedirectStandardError=true;
|
|
process.StartInfo.RedirectStandardOutput=true;
|
|
|
|
}
|
|
try{
|
|
if(process.Start())
|
|
{
|
|
if(new_line != null)
|
|
{
|
|
while(!process.HasExited)
|
|
{
|
|
if(token.IsCancellationRequested)
|
|
{
|
|
process.Kill();
|
|
}
|
|
new_line.Report(await process.StandardError.ReadLineAsync());
|
|
}
|
|
}else{
|
|
while(!process.HasExited)
|
|
{
|
|
if(token.IsCancellationRequested)
|
|
{
|
|
process.Kill();
|
|
}
|
|
await Task.Delay(100);
|
|
}
|
|
}
|
|
}
|
|
}catch(Exception ex)
|
|
{
|
|
Console.WriteLine(ex.Message);
|
|
_=ex;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
protected async Task<bool> Convert(SavedVideo video,string videoFile,string audioFile,string outFile,CancellationToken token,IProgress<string> new_line,string ffmpeg="ffmpeg")
|
|
{
|
|
|
|
Func<string,string> escape_ffmetadata_str = (e)=>{
|
|
StringBuilder builder=new StringBuilder(e);
|
|
builder.Replace("\r\n","\n");
|
|
foreach(var item in new char[] {'\\','#',';','=','\n'})
|
|
{
|
|
builder.Replace(item.ToString(), $"\\{item}");
|
|
}
|
|
return builder.ToString();
|
|
};
|
|
Action<List<string>,Chapter> add_chapter = (list,chapter)=>{
|
|
list.Add("[CHAPTER]");
|
|
list.Add("TIMEBASE=1/1");
|
|
list.Add($"START={(int)chapter.Offset.TotalSeconds}");
|
|
list.Add($"END={(int)(chapter.Offset.Add(chapter.Length).TotalSeconds)}");
|
|
list.Add($"TITLE={escape_ffmetadata_str(chapter.ChapterName)}");
|
|
list.Add("");
|
|
};
|
|
string txtFile=Path.Combine("TYTD_TEMP","video_info.txt");
|
|
|
|
DeleteIfExists (txtFile);
|
|
if(! await run_process(ffmpeg,token,"-y","-i",videoFile,"-f","ffmetadata",txtFile))
|
|
{
|
|
return false;
|
|
}
|
|
List<string> entries=File.ReadAllLines(txtFile).ToList();
|
|
if(token.IsCancellationRequested)
|
|
{
|
|
return false;
|
|
}
|
|
entries.Add($"title={escape_ffmetadata_str(video.Title)}");
|
|
entries.Add($"artist={escape_ffmetadata_str(video.AuthorTitle)}");
|
|
entries.Add($"description={escape_ffmetadata_str(video.Description)}");
|
|
entries.Add($"comments=videoId\\={video.Id},likes\\={video.Likes},views\\={video.Views},authorChannelId\\={video.AuthorChannelId}");
|
|
entries.Add("");
|
|
foreach(var chapter in video.GetChapters())
|
|
{
|
|
// Console.WriteLine(chapter.ChapterName);
|
|
add_chapter(entries,chapter);
|
|
}
|
|
File.WriteAllLines(txtFile,entries);
|
|
return await run_process(ffmpeg,new_line,token,"-y","-i",videoFile,"-i",txtFile,"-i",audioFile,"-map_metadata","1","-c","copy","-map","0:v","-map","2:a",outFile);
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
private async Task<bool> CopyStreamAsync(Stream src,Stream dest,long pos=0,long len=0,int bufferSize=4096,IProgress<double> progress=null,CancellationToken token=default(CancellationToken))
|
|
{
|
|
if(pos > 0)
|
|
{
|
|
src.Position=pos;
|
|
dest.Position=pos;
|
|
}
|
|
double curPos = pos;
|
|
int read;
|
|
byte[] buffer=new byte[bufferSize];
|
|
do{
|
|
read=await src.ReadAsync(buffer,0,buffer.Length,token);
|
|
if(token.IsCancellationRequested)
|
|
{
|
|
return false;
|
|
}
|
|
curPos+=read;
|
|
await dest.WriteAsync(buffer,0,read);
|
|
if(progress != null)
|
|
{
|
|
progress.Report(curPos / len);
|
|
}
|
|
}while(read>0 && !token.IsCancellationRequested);
|
|
if(token.IsCancellationRequested)
|
|
{
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
public abstract Task<Stream> OpenOrCreateAsync(string path);
|
|
public abstract void RenameFile(string src,string dest);
|
|
public static string FFmpeg {get;set;}
|
|
|
|
public virtual async Task<bool> MuxVideosAsync(SavedVideo video,string videoSrc,string audioSrc,string videoDest,IProgress<double> progress=null,CancellationToken token=default(CancellationToken))
|
|
{
|
|
if(string.IsNullOrWhiteSpace(FFmpeg))
|
|
{
|
|
return false;
|
|
}
|
|
Func<string,TimeSpan> get_percent=(e)=>{
|
|
if(string.IsNullOrWhiteSpace(e))
|
|
return TimeSpan.Zero;
|
|
|
|
int index=e.IndexOf("time=");
|
|
if(index < 0)
|
|
return TimeSpan.Zero;
|
|
|
|
int spaceIndex=e.IndexOf(' ',index+5);
|
|
string j=e.Substring(index+5,spaceIndex-(index+5));
|
|
|
|
double val;
|
|
if(double.TryParse(j,out val))
|
|
return TimeSpan.FromSeconds(val);
|
|
|
|
return TimeSpan.ParseExact(j, "c", CultureInfo.InvariantCulture);
|
|
|
|
//return TimeSpan.Zero;
|
|
};
|
|
|
|
bool ret=false;
|
|
string video_bin=Path.Combine("TYTD_TEMP","video.bin");
|
|
string audio_bin=Path.Combine("TYTD_TEMP","audio.bin");
|
|
string output_mkv=Path.Combine("TYTD_TEMP","output.mkv");
|
|
|
|
Directory.CreateDirectory("TYTD_TEMP");
|
|
//hince we are in a unknown environment we need to copy video
|
|
DeleteIfExists(video_bin);
|
|
DeleteIfExists(audio_bin);
|
|
DeleteIfExists(output_mkv);
|
|
long len=await GetLengthAsync(videoSrc);
|
|
|
|
using(var vstrm_src=await OpenReadAsync(videoSrc))
|
|
{
|
|
using(var vstrm_dest = File.Create(video_bin))
|
|
{
|
|
Console.WriteLine("Opening vstream");
|
|
if(!await CopyStreamAsync(vstrm_src,vstrm_dest,0,len,4096,
|
|
new Progress<double>((e)=>{
|
|
if(progress !=null)
|
|
{
|
|
progress.Report(e/4);
|
|
}
|
|
})
|
|
,token
|
|
))
|
|
{
|
|
|
|
goto end;
|
|
}
|
|
}
|
|
}
|
|
using(var astrm_src=await OpenReadAsync(audioSrc))
|
|
{
|
|
using(var astrm_dest = File.Create(audio_bin))
|
|
{
|
|
Console.WriteLine("opening astream");
|
|
if(!await CopyStreamAsync(astrm_src,astrm_dest,0,len,4096,
|
|
new Progress<double>((e)=>{
|
|
if(progress !=null)
|
|
{
|
|
progress.Report(e/4+0.25);
|
|
}
|
|
})
|
|
,token
|
|
))
|
|
{
|
|
goto end;
|
|
}
|
|
}
|
|
}
|
|
string videoFile=Path.Combine("TYTD_TEMP","video.bin");
|
|
string audioFile=Path.Combine("TYTD_TEMP","audio.bin");
|
|
string outFile = Path.Combine("TYTD_TEMP","output.mkv");
|
|
ret=await Convert(video,videoFile,audioFile,outFile,token,new Progress<string>((e)=>{
|
|
// time=\"\"
|
|
if(progress!=null)
|
|
{
|
|
var progr=get_percent(e).TotalSeconds / video.Duration.TotalSeconds;
|
|
progress.Report(progr / 4 + 0.50);
|
|
}
|
|
}),FFmpeg);
|
|
if(ret)
|
|
{
|
|
using(var mstrm_src=File.OpenRead(output_mkv))
|
|
{
|
|
using(var mstrm_dest=await CreateAsync(videoDest))
|
|
{
|
|
ret=await CopyStreamAsync(mstrm_src,mstrm_dest,0,mstrm_src.Length,4096,new Progress<double>((e)=>{
|
|
if(progress!=null)
|
|
{
|
|
progress.Report(e / 4 + 0.75);
|
|
}
|
|
}),token);
|
|
|
|
}
|
|
}
|
|
}
|
|
end:
|
|
Directory.Delete("TYTD_TEMP",true);
|
|
return ret;
|
|
}
|
|
private async Task DownloadVideoMuxedAsync(SavedVideo video,CancellationToken token)
|
|
{
|
|
bool isValid=true;
|
|
isValid=await DownloadVideoOnlyAsync(video,token);
|
|
if(token.IsCancellationRequested || !isValid)
|
|
{
|
|
return;
|
|
}
|
|
isValid = await DownloadAudioOnlyAsync(video,token);
|
|
if(token.IsCancellationRequested || !isValid)
|
|
{
|
|
return;
|
|
}
|
|
var streams=await BestStreamInfo.GetBestStreams(this,video.Id,token,false);
|
|
if(token.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
ReportStartVideo(video,Resolution.Mux,0);
|
|
string complete = $"Muxed/{video.Id}.mkv";
|
|
string incomplete = $"Muxed/{video.Id}incomplete.mkv";
|
|
string complete_vidonly = $"VideoOnly/{video.Id}.{streams.VideoOnlyStreamInfo.Container}";
|
|
string complete_audonly = $"AudioOnly/{video.Id}.{streams.AudioOnlyStreamInfo.Container}";
|
|
|
|
if(await Continue(complete))
|
|
{
|
|
|
|
if(await MuxVideosAsync(video,complete_vidonly,complete_audonly,incomplete,new Progress<double>(ReportProgress),token))
|
|
{
|
|
RenameFile(incomplete,complete);
|
|
}
|
|
}
|
|
|
|
ReportEndVideo(video,Resolution.Mux);
|
|
}
|
|
private void DeleteIfExists(string path)
|
|
{
|
|
if(File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
public async Task<bool> DownloadVideoOnlyAsync(SavedVideo video,CancellationToken token)
|
|
{
|
|
bool ret=false;
|
|
var streams = await BestStreamInfo.GetBestStreams(this, video.Id, token, false);
|
|
if(streams != null)
|
|
{
|
|
await MoveLegacyStreams(video,streams);
|
|
string complete = $"VideoOnly/{video.Id}.{streams.VideoOnlyStreamInfo.Container}";
|
|
string incomplete = $"VideoOnly/{video.Id}incomplete.{streams.VideoOnlyStreamInfo.Container}";
|
|
|
|
if(await Continue(complete))
|
|
{
|
|
streams = await BestStreamInfo.GetBestStreams(this,video.Id,token);
|
|
if(streams != null)
|
|
{
|
|
using(var strm = await YoutubeClient.Videos.Streams.GetAsync(streams.VideoOnlyStreamInfo,token))
|
|
{
|
|
if(token.IsCancellationRequested)
|
|
{
|
|
return false;
|
|
}
|
|
ReportStartVideo(video, Resolution.VideoOnly,streams.VideoOnlyStreamInfo.Size.Bytes);
|
|
long len=await GetLengthAsync(incomplete);
|
|
|
|
using(var dest = await OpenOrCreateAsync(incomplete))
|
|
{
|
|
ret=await CopyStreamAsync(strm,dest,len,streams.VideoOnlyStreamInfo.Size.Bytes,4096,new Progress<double>(ReportProgress),token);
|
|
}
|
|
if(ret)
|
|
{
|
|
RenameFile(incomplete,complete);
|
|
ReportEndVideo(video, Resolution.VideoOnly);
|
|
}
|
|
}
|
|
}
|
|
}else{
|
|
|
|
ret=true;
|
|
}
|
|
}
|
|
|
|
//We know its resolution
|
|
return ret;
|
|
}
|
|
private async Task MoveLegacyStream(string src,string dest)
|
|
{
|
|
if(src.Equals(dest))
|
|
return;
|
|
|
|
if(!await Continue(dest))
|
|
return;
|
|
|
|
if(!await FileExistsAsync(src))
|
|
return;
|
|
|
|
RenameFile(src,dest);
|
|
|
|
}
|
|
public async Task MoveLegacyStreams(SavedVideo video,BestStreams streams)
|
|
{
|
|
if(video.LegacyVideo)
|
|
{
|
|
string legacyVideoOnlyComplete = $"VideoOnly/{video.Id}.mp4";
|
|
string legacyAudioOnlyComplete = $"AudioOnly/{video.Id}.mp4";
|
|
string legacyPreMuxedComplete = $"PreMuxed/{video.Id}.mp4";
|
|
|
|
string modernVideoOnlyComplete = $"VideoOnly/{video.Id}.{streams.VideoOnlyStreamInfo.Container}";
|
|
string modernAudioOnlyComplete = $"AudioOnly/{video.Id}.{streams.AudioOnlyStreamInfo.Container}";
|
|
string modernPreMuxedComplete = $"PreMuxed/{video.Id}.{streams.MuxedStreamInfo}";
|
|
|
|
string legacyVideoOnlyInComplete = $"VideoOnly/{video.Id}incomplete.mp4";
|
|
string legacyAudioOnlyInComplete = $"AudioOnly/{video.Id}incomplete.mp4";
|
|
string legacyPreMuxedInComplete = $"PreMuxed/{video.Id}incomplete.mp4";
|
|
|
|
string modernVideoOnlyInComplete = $"VideoOnly/{video.Id}incomplete.{streams.VideoOnlyStreamInfo.Container}";
|
|
string modernAudioOnlyInComplete = $"AudioOnly/{video.Id}incomplete.{streams.AudioOnlyStreamInfo.Container}";
|
|
string modernPreMuxedInComplete = $"PreMuxed/{video.Id}incomplete.{streams.MuxedStreamInfo}";
|
|
|
|
await MoveLegacyStream(legacyVideoOnlyComplete,modernVideoOnlyComplete);
|
|
await MoveLegacyStream(legacyAudioOnlyComplete,modernAudioOnlyComplete);
|
|
await MoveLegacyStream(legacyPreMuxedComplete,modernPreMuxedComplete);
|
|
|
|
await MoveLegacyStream(legacyVideoOnlyInComplete,modernVideoOnlyInComplete);
|
|
await MoveLegacyStream(legacyAudioOnlyInComplete,modernAudioOnlyInComplete);
|
|
await MoveLegacyStream(legacyPreMuxedInComplete,modernPreMuxedInComplete);
|
|
video.LegacyVideo=false;
|
|
await WriteAllTextAsync($"Info/{video.Id}.json",JsonConvert.SerializeObject(video));
|
|
|
|
|
|
}
|
|
}
|
|
public async Task<bool> DownloadAudioOnlyAsync(SavedVideo video,CancellationToken token)
|
|
{
|
|
bool ret=false;
|
|
var streams = await BestStreamInfo.GetBestStreams(this, video.Id, token, false);
|
|
if(streams != null)
|
|
{
|
|
string complete = $"AudioOnly/{video.Id}.{streams.AudioOnlyStreamInfo.Container}";
|
|
string incomplete = $"AudioOnly/{video.Id}incomplete.{streams.AudioOnlyStreamInfo.Container}";
|
|
await MoveLegacyStreams(video,streams);
|
|
if(await Continue(complete))
|
|
{
|
|
|
|
streams = await BestStreamInfo.GetBestStreams(this,video.Id,token);
|
|
if(streams != null)
|
|
{
|
|
|
|
using(var strm = await YoutubeClient.Videos.Streams.GetAsync(streams.AudioOnlyStreamInfo,token))
|
|
{
|
|
if(token.IsCancellationRequested)
|
|
{
|
|
return false;
|
|
}
|
|
ReportStartVideo(video, Resolution.AudioOnly,streams.AudioOnlyStreamInfo.Size.Bytes);
|
|
long len=await GetLengthAsync(incomplete);
|
|
|
|
using(var dest = await OpenOrCreateAsync(incomplete))
|
|
{
|
|
ret=await CopyStreamAsync(strm,dest,len,streams.AudioOnlyStreamInfo.Size.Bytes,4096,new Progress<double>(ReportProgress),token);
|
|
}
|
|
if(ret)
|
|
{
|
|
RenameFile(incomplete,complete);
|
|
ReportEndVideo(video, Resolution.AudioOnly);
|
|
}
|
|
}
|
|
}
|
|
}else{
|
|
|
|
ret=true;
|
|
}
|
|
}
|
|
|
|
//We know its resolution
|
|
return ret;
|
|
}
|
|
private async Task DownloadPreMuxedVideoAsync(SavedVideo video, CancellationToken token)
|
|
{
|
|
var streams = await BestStreamInfo.GetBestStreams(this, video.Id, token, false);
|
|
if(streams != null)
|
|
{
|
|
await MoveLegacyStreams(video,streams);
|
|
string complete = $"PreMuxed/{video.Id}.{streams.MuxedStreamInfo.Container}";
|
|
string incomplete = $"PreMuxed/{video.Id}incomplete.{streams.MuxedStreamInfo.Container}";
|
|
|
|
if(await Continue(complete))
|
|
{
|
|
|
|
streams = await BestStreamInfo.GetBestStreams(this,video.Id,token);
|
|
if(streams != null)
|
|
{
|
|
|
|
using(var strm = await YoutubeClient.Videos.Streams.GetAsync(streams.MuxedStreamInfo,token))
|
|
{
|
|
if(token.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
ReportStartVideo(video,Resolution.PreMuxed,streams.MuxedStreamInfo.Size.Bytes);
|
|
long len=await GetLengthAsync(incomplete);
|
|
bool ret;
|
|
using(var dest = await OpenOrCreateAsync(incomplete))
|
|
{
|
|
ret=await CopyStreamAsync(strm,dest,len,streams.MuxedStreamInfo.Size.Bytes,4096,new Progress<double>(ReportProgress),token);
|
|
}
|
|
//We know its resolution
|
|
if(ret)
|
|
{
|
|
RenameFile(incomplete,complete);
|
|
ReportEndVideo(video, Resolution.PreMuxed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
}
|
|
private (SavedVideo video, Resolution resolution) Dequeue(out bool hasAny)
|
|
{
|
|
SavedVideo video;
|
|
Resolution resolution;
|
|
hasAny = false;
|
|
lock (QueueList)
|
|
{
|
|
if (QueueList.Count > 0)
|
|
{
|
|
(video, resolution) = QueueList[QueueList.Count - 1];
|
|
hasAny = true;
|
|
QueueList.RemoveAt(QueueList.Count - 1);
|
|
}
|
|
else
|
|
{
|
|
video = null;
|
|
resolution = Resolution.PreMuxed;
|
|
}
|
|
}
|
|
return (video, resolution);
|
|
}
|
|
}
|
|
} |