Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support subtitles in video downloader #970

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions TwitchDownloaderCore/Options/VideoDownloadOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class VideoDownloadOptions
public int DownloadThreads { get; set; }
public int ThrottleKib { get; set; }
public string Oauth { get; set; }
public SubtitlesStyle SubtitlesStyle { get; set; }
public string FfmpegPath { get; set; }
public string TempFolder { get; set; }
public Func<DirectoryInfo[], DirectoryInfo[]> CacheCleanerCallback { get; set; }
Expand Down
7 changes: 7 additions & 0 deletions TwitchDownloaderCore/Tools/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@ public enum VideoTrimMode
Safe,
Exact
}

public enum SubtitlesStyle
{
None,
Embedded,
OutputSrt
}
}
11 changes: 5 additions & 6 deletions TwitchDownloaderCore/Tools/FfmpegConcatList.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

Expand All @@ -12,23 +11,23 @@ public static class FfmpegConcatList
{
private const string LINE_FEED = "\u000A";

public static async Task SerializeAsync(string filePath, M3U8 playlist, Range videoListCrop, CancellationToken cancellationToken = default)
public static async Task SerializeAsync(string filePath, IEnumerable<(string path, decimal duration)> playlist, CancellationToken cancellationToken = default)
{
await using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var sw = new StreamWriter(fs) { NewLine = LINE_FEED };

await sw.WriteLineAsync("ffconcat version 1.0");

foreach (var stream in playlist.Streams.Take(videoListCrop))
foreach (var stream in playlist)
{
cancellationToken.ThrowIfCancellationRequested();

await sw.WriteAsync("file '");
await sw.WriteAsync(DownloadTools.RemoveQueryString(stream.Path));
await sw.WriteAsync(DownloadTools.RemoveQueryString(stream.path));
await sw.WriteLineAsync('\'');

await sw.WriteAsync("duration ");
await sw.WriteLineAsync(stream.PartInfo.Duration.ToString(CultureInfo.InvariantCulture));
await sw.WriteLineAsync(stream.duration.ToString(CultureInfo.InvariantCulture));
}
}
}
Expand Down
189 changes: 170 additions & 19 deletions TwitchDownloaderCore/VideoDownloader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
Expand Down Expand Up @@ -26,6 +26,8 @@ public sealed class VideoDownloader
private readonly ITaskProgress _progress;
private bool _shouldClearCache = true;

private const string TOTAL_STEPS = "5";

public VideoDownloader(VideoDownloadOptions videoDownloadOptions, ITaskProgress progress = default)
{
downloadOptions = videoDownloadOptions;
Expand All @@ -41,13 +43,17 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
{
var outputFileInfo = TwitchHelper.ClaimFile(downloadOptions.Filename, downloadOptions.FileCollisionCallback, _progress);
downloadOptions.Filename = outputFileInfo.FullName;
var subtitleFileInfo = downloadOptions.SubtitlesStyle == SubtitlesStyle.OutputSrt
? TwitchHelper.ClaimFile(Path.ChangeExtension(downloadOptions.Filename, "srt"), downloadOptions.FileCollisionCallback, _progress)
: null;

// Open the destination file so that it exists in the filesystem.
// Open the destination files so that it exists in the filesystem.
await using var outputFs = outputFileInfo.Open(FileMode.Create, FileAccess.Write, FileShare.Read);
await using var subtitlesFs = subtitleFileInfo?.Open(FileMode.Create, FileAccess.Write, FileShare.Read);

try
{
await DownloadAsyncImpl(outputFileInfo, outputFs, cancellationToken);
await DownloadAsyncImpl(outputFileInfo, outputFs, subtitleFileInfo, subtitlesFs, cancellationToken);
}
catch
{
Expand All @@ -64,19 +70,33 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
catch { }
}

if (subtitleFileInfo is not null)
{
subtitleFileInfo.Refresh();
if (subtitleFileInfo.Exists && subtitleFileInfo.Length == 0)
{
try
{
await subtitlesFs.DisposeAsync();
subtitleFileInfo.Delete();
}
catch { }
}
}

throw;
}
}

private async Task DownloadAsyncImpl(FileInfo outputFileInfo, FileStream outputFs, CancellationToken cancellationToken)
private async Task DownloadAsyncImpl(FileInfo outputFileInfo, FileStream outputFs, FileInfo subtitleFileInfo, FileStream subtitlesFs, CancellationToken cancellationToken)
{
await TwitchHelper.CleanupAbandonedVideoCaches(downloadOptions.TempFolder, downloadOptions.CacheCleanerCallback, _progress);

string downloadFolder = Path.Combine(
downloadOptions.TempFolder,
$"{downloadOptions.Id}_{DateTimeOffset.UtcNow.Ticks}");

_progress.SetStatus("Fetching Video Info [1/4]");
_progress.SetStatus($"Fetching Video Info [1/{TOTAL_STEPS}]");

try
{
Expand Down Expand Up @@ -104,31 +124,47 @@ private async Task DownloadAsyncImpl(FileInfo outputFileInfo, FileStream outputF
Directory.Delete(downloadFolder, true);
TwitchHelper.CreateDirectory(downloadFolder);

_progress.SetTemplateStatus("Downloading {0}% [2/4]", 0);
_progress.SetTemplateStatus($"Downloading {{0}}% [2/{TOTAL_STEPS}]", 0);

await DownloadVideoPartsAsync(playlist.Streams, videoListCrop, baseUrl, downloadFolder, airDate, cancellationToken);

_progress.SetTemplateStatus("Verifying Parts {0}% [3/4]", 0);
_progress.SetTemplateStatus($"Verifying Parts {{0}}% [3/{TOTAL_STEPS}]", 0);

await VerifyDownloadedParts(playlist.Streams, videoListCrop, baseUrl, downloadFolder, airDate, cancellationToken);

_progress.SetTemplateStatus("Finalizing Video {0}% [4/4]", 0);
string subtitlesPath = null;
if (downloadOptions.SubtitlesStyle != SubtitlesStyle.None)
{
_progress.SetTemplateStatus($"Extracting subtitles {{0}}% [4/{TOTAL_STEPS}]", 0);

var subtitlesConcatListPath = await ExtractSubtitles(playlist.Streams, videoListCrop, downloadFolder, cancellationToken);

subtitlesPath = await ConcatSubtitles(subtitlesConcatListPath, downloadFolder, startOffset, endDuration, videoLength, cancellationToken);
}

if (downloadOptions.SubtitlesStyle == SubtitlesStyle.OutputSrt && subtitlesPath != null)
{
_shouldClearCache = await TryCopySubtitlesToOutput(subtitlesPath, subtitleFileInfo, subtitlesFs);
}

_progress.SetTemplateStatus($"Finalizing Video {{0}}% [5/{TOTAL_STEPS}]", 0);

string metadataPath = Path.Combine(downloadFolder, "metadata.txt");
await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, downloadOptions.Id.ToString(), videoInfo.title, videoInfo.createdAt, videoInfo.viewCount,
videoInfo.description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd(), downloadOptions.TrimBeginning ? downloadOptions.TrimBeginningTime : TimeSpan.Zero,
videoLength, videoChapterResponse.data.video.moments.edges, cancellationToken);

var concatListPath = Path.Combine(downloadFolder, "concat.txt");
await FfmpegConcatList.SerializeAsync(concatListPath, playlist, videoListCrop, cancellationToken);
var toConcat = playlist.Streams.Take(videoListCrop).Select(x => (x.Path, x.PartInfo.Duration));
await FfmpegConcatList.SerializeAsync(concatListPath, toConcat, cancellationToken);

outputFs.Close();

int ffmpegExitCode;
var ffmpegRetries = 0;
do
{
ffmpegExitCode = await Task.Run(() => RunFfmpegVideoCopy(downloadFolder, outputFileInfo, concatListPath, metadataPath, startOffset, endDuration, videoLength), cancellationToken);
ffmpegExitCode = await Task.Run(() => RunFfmpegVideoCopy(downloadFolder, outputFileInfo, concatListPath, metadataPath, subtitlesPath, startOffset, endDuration, videoLength), cancellationToken);
if (ffmpegExitCode != 0)
{
_progress.LogError($"Failed to finalize video (code {ffmpegExitCode}), retrying in 10 seconds...");
Expand Down Expand Up @@ -156,6 +192,22 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, d
}
}

private async Task<bool> TryCopySubtitlesToOutput(string subtitlesPath, FileInfo subtitleFileInfo, FileStream subtitlesFs)
{
try
{
await subtitlesFs.DisposeAsync();
File.Copy(subtitlesPath, subtitleFileInfo.FullName, true);
}
catch (Exception e)
{
_progress.LogError($"Failed to copy subtitles to {subtitleFileInfo.FullName}, the subtitle file can be found at {subtitlesPath}. Error message: {e.Message}");
return false;
}

return true;
}

private void CheckAvailableStorageSpace(int bandwidth, TimeSpan videoLength)
{
var videoSizeInBytes = VideoSizeEstimator.EstimateVideoSize(bandwidth,
Expand Down Expand Up @@ -326,22 +378,124 @@ private async Task VerifyDownloadedParts(ICollection<M3U8.Stream> playlist, Rang
}
}

private int RunFfmpegVideoCopy(string tempFolder, FileInfo outputFile, string concatListPath, string metadataPath, decimal startOffset, decimal endDuration, TimeSpan videoLength)
private async Task<string> ExtractSubtitles(ICollection<M3U8.Stream> playlist, Range videoListCrop, string downloadFolder, CancellationToken cancellationToken)
{
var partCount = videoListCrop.End.Value - videoListCrop.Start.Value;
var doneCount = 0;
var concatList = new List<(string, decimal)>(partCount);

foreach (var videoPart in playlist.Take(videoListCrop))
{
doneCount = await RunFfmpegSubtitleExtract(downloadFolder, cancellationToken, videoPart, concatList, partCount, doneCount);
}

var concatListPath = Path.Combine(downloadFolder, "srt_concat.txt");
await FfmpegConcatList.SerializeAsync(concatListPath, concatList, cancellationToken);

return concatListPath;
}

private static Process GetFfmpegProcess(string ffmpegPath, string workingDirectory, IEnumerable<string> args)
{
using var process = new Process
var process = new Process
{
StartInfo =
{
FileName = downloadOptions.FfmpegPath,
FileName = ffmpegPath,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardInput = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = tempFolder
WorkingDirectory = workingDirectory
}
};

foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}

return process;
}

private async Task<int> RunFfmpegSubtitleExtract(string tempFolder, CancellationToken cancellationToken, M3U8.Stream videoPart, List<(string, decimal)> concatList, int partCount, int doneCount)
{
cancellationToken.ThrowIfCancellationRequested();

var partName = DownloadTools.RemoveQueryString(videoPart.Path);
var subtitlePath = $"{partName}.srt";

// movie=file.ts[out+subcc] is super slow, but `-i file.ts file.srt` doesn't seem to work on TS files :/
var args = new List<string>
{
"-y",
"-f", "lavfi",
"-i", $"movie={partName}[out+subcc]",
"-map", "0:1",
subtitlePath
};

using var process = GetFfmpegProcess(downloadOptions.FfmpegPath, tempFolder, args);

process.Start();
await process.WaitForExitAsync(cancellationToken);

concatList.Add((Path.GetFileName(subtitlePath), videoPart.PartInfo.Duration));

doneCount++;
var percent = (int)(doneCount / (double)partCount * 100);
_progress.ReportProgress(percent);

return doneCount;
}

private async Task<string> ConcatSubtitles(string concatListPath, string downloadFolder, decimal startOffset, decimal endOffset, TimeSpan videoLength, CancellationToken cancellationToken)
{
var finalSubtitlePath = Path.Combine(downloadFolder, "subtitles.srt");

await RunFfmpegSubtitleConcat(downloadFolder, concatListPath, finalSubtitlePath, startOffset, endOffset, videoLength, cancellationToken);

var fi = new FileInfo(finalSubtitlePath);
if (!fi.Exists || fi.Length == 0)
{
// Video does not contain subtitles or something went wrong during the concat
_progress.LogInfo("Video does not contain any subtitles.");
return null;
}

return finalSubtitlePath;
}

private async Task RunFfmpegSubtitleConcat(string tempFolder, string concatListPath, string outputPath, decimal startOffset, decimal endOffset, TimeSpan videoLength, CancellationToken cancellationToken)
{
var args = new List<string>
{
"-y",
"-f", "concat",
"-i", concatListPath,
outputPath
};

if (endOffset > 0)
{
args.Insert(0, "-t");
args.Insert(1, videoLength.TotalSeconds.ToString(CultureInfo.InvariantCulture));
}

if (startOffset > 0)
{
args.Insert(0, "-ss");
args.Insert(1, startOffset.ToString(CultureInfo.InvariantCulture));
}

using var process = GetFfmpegProcess(downloadOptions.FfmpegPath, tempFolder, args);

process.Start();
await process.WaitForExitAsync(cancellationToken);
}

private int RunFfmpegVideoCopy(string tempFolder, FileInfo outputFile, string concatListPath, string metadataPath, string subtitlesPath, decimal startOffset, decimal endDuration, TimeSpan videoLength)
{
var args = new List<string>
{
"-stats",
Expand Down Expand Up @@ -370,10 +524,7 @@ private int RunFfmpegVideoCopy(string tempFolder, FileInfo outputFile, string co
args.Insert(1, startOffset.ToString(CultureInfo.InvariantCulture));
}

foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
using var process = GetFfmpegProcess(downloadOptions.FfmpegPath, tempFolder, args);

var encodingTimeRegex = new Regex(@"(?<=time=)(\d\d):(\d\d):(\d\d)\.(\d\d)", RegexOptions.Compiled);
var logQueue = new ConcurrentQueue<string>();
Expand Down
3 changes: 3 additions & 0 deletions TwitchDownloaderWPF/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@
<setting name="VodTrimMode" serializeAs="String">
<value>1</value>
</setting>
<setting name="ExtractVideoSubtitles" serializeAs="String">
<value>False</value>
</setting>
</TwitchDownloaderWPF.Properties.Settings>
</userSettings>
</configuration>
6 changes: 4 additions & 2 deletions TwitchDownloaderWPF/PageVodDownload.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@
<TextBlock Text="{lex:Loc Length}" HorizontalAlignment="Right" Foreground="{DynamicResource AppText}" />
<TextBlock Text="{lex:Loc Quality}" HorizontalAlignment="Right" Margin="0,15,0,0" Foreground="{DynamicResource AppText}" />
<TextBlock HorizontalAlignment="Right" Margin="0,15,0,0" Foreground="{DynamicResource AppText}"><Run Text="{lex:Loc VideoTrimMode}"/><Hyperlink ToolTipService.ShowDuration="30000" Foreground="{DynamicResource AppHyperlink}"><Hyperlink.ToolTip><Run Text="{lex:Loc VideoTrimModeTooltip}"/></Hyperlink.ToolTip>(?)</Hyperlink>:</TextBlock>
<TextBlock Text="{lex:Loc TrimVideo}" HorizontalAlignment="Right" Margin="0,11,0,0" Foreground="{DynamicResource AppText}" />
<TextBlock Text="{lex:Loc VideoDownloadThreads}" HorizontalAlignment="Right" Margin="0,46,0,0" Foreground="{DynamicResource AppText}" />
<TextBlock Text="{lex:Loc TrimVideo}" HorizontalAlignment="Right" Margin="0,10,0,0" Foreground="{DynamicResource AppText}" />
<TextBlock Text="Extract Subtitles:" HorizontalAlignment="Right" Margin="0,41,0,0" Foreground="{DynamicResource AppText}" />
<TextBlock Text="{lex:Loc VideoDownloadThreads}" HorizontalAlignment="Right" Margin="0,11,0,0" Foreground="{DynamicResource AppText}" />
<TextBlock HorizontalAlignment="Right" Margin="0,21,0,0" Foreground="{DynamicResource AppText}"><Run Text="{lex:Loc Oauth}"/><Hyperlink NavigateUri="https://www.youtube.com/watch?v=1MBsUoFGuls" RequestNavigate="Hyperlink_RequestNavigate" ToolTipService.ShowDuration="30000" Foreground="{DynamicResource AppHyperlink}"><Hyperlink.ToolTip><Run Text="{lex:Loc OauthTooltip}"/></Hyperlink.ToolTip>(?)</Hyperlink>:</TextBlock>
</StackPanel>
<StackPanel>
Expand All @@ -95,6 +96,7 @@
<hc:NumericUpDown Margin="3,-1,0,0" Minimum="0" Maximum="60" Value="0" x:Name="numEndMinute" ValueChanged="numEndMinute_ValueChanged" Background="{DynamicResource AppElementBackground}" BorderBrush="{DynamicResource AppElementBorder}" Foreground="{DynamicResource AppText}" />
<hc:NumericUpDown Margin="3,-1,0,0" Minimum="0" Maximum="60" Value="0" x:Name="numEndSecond" ValueChanged="numEndSecond_ValueChanged" Background="{DynamicResource AppElementBackground}" BorderBrush="{DynamicResource AppElementBorder}" Foreground="{DynamicResource AppText}" />
</StackPanel>
<CheckBox x:Name="CheckExtractSubtitles" Margin="0,5,0,0" Checked="CheckExtractSubtitles_OnCheckedChanged" Unchecked="CheckExtractSubtitles_OnCheckedChanged" />
<hc:NumericUpDown Margin="0,5,0,0" Minimum="1" Value="4" Maximum="20" x:Name="numDownloadThreads" HorizontalAlignment="Left" ValueChanged="numDownloadThreads_ValueChanged" Background="{DynamicResource AppElementBackground}" BorderBrush="{DynamicResource AppElementBorder}" Foreground="{DynamicResource AppText}" />
<TextBox x:Name="TextOauth" Margin="0,8,3,3" MinWidth="200" MaxWidth="400" TextChanged="TextOauth_TextChanged" Background="{DynamicResource AppElementBackground}" BorderBrush="{DynamicResource AppElementBorder}" Foreground="{DynamicResource AppText}" />
</StackPanel>
Expand Down
Loading