diff --git a/TwitchDownloaderCore/Options/VideoDownloadOptions.cs b/TwitchDownloaderCore/Options/VideoDownloadOptions.cs index b1effc91..7684ccd9 100644 --- a/TwitchDownloaderCore/Options/VideoDownloadOptions.cs +++ b/TwitchDownloaderCore/Options/VideoDownloadOptions.cs @@ -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 CacheCleanerCallback { get; set; } diff --git a/TwitchDownloaderCore/Tools/Enums.cs b/TwitchDownloaderCore/Tools/Enums.cs index 8c7bdfaa..8e3402b1 100644 --- a/TwitchDownloaderCore/Tools/Enums.cs +++ b/TwitchDownloaderCore/Tools/Enums.cs @@ -27,4 +27,11 @@ public enum VideoTrimMode Safe, Exact } + + public enum SubtitlesStyle + { + None, + Embedded, + OutputSrt + } } \ No newline at end of file diff --git a/TwitchDownloaderCore/Tools/FfmpegConcatList.cs b/TwitchDownloaderCore/Tools/FfmpegConcatList.cs index c3ea6c5b..2add5e94 100644 --- a/TwitchDownloaderCore/Tools/FfmpegConcatList.cs +++ b/TwitchDownloaderCore/Tools/FfmpegConcatList.cs @@ -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; @@ -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)); } } } diff --git a/TwitchDownloaderCore/VideoDownloader.cs b/TwitchDownloaderCore/VideoDownloader.cs index 27b7e24e..51c210b3 100644 --- a/TwitchDownloaderCore/VideoDownloader.cs +++ b/TwitchDownloaderCore/VideoDownloader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; @@ -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; @@ -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 { @@ -64,11 +70,25 @@ 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); @@ -76,7 +96,7 @@ private async Task DownloadAsyncImpl(FileInfo outputFileInfo, FileStream outputF downloadOptions.TempFolder, $"{downloadOptions.Id}_{DateTimeOffset.UtcNow.Ticks}"); - _progress.SetStatus("Fetching Video Info [1/4]"); + _progress.SetStatus($"Fetching Video Info [1/{TOTAL_STEPS}]"); try { @@ -104,15 +124,30 @@ 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, @@ -120,7 +155,8 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, d 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(); @@ -128,7 +164,7 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, d 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..."); @@ -156,6 +192,22 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, d } } + private async Task 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, @@ -326,22 +378,124 @@ private async Task VerifyDownloadedParts(ICollection playlist, Rang } } - private int RunFfmpegVideoCopy(string tempFolder, FileInfo outputFile, string concatListPath, string metadataPath, decimal startOffset, decimal endDuration, TimeSpan videoLength) + private async Task ExtractSubtitles(ICollection 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 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 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 + { + "-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 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 + { + "-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 { "-stats", @@ -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(); diff --git a/TwitchDownloaderWPF/App.config b/TwitchDownloaderWPF/App.config index 087773d6..fbf80568 100644 --- a/TwitchDownloaderWPF/App.config +++ b/TwitchDownloaderWPF/App.config @@ -235,6 +235,9 @@ 1 + + False + \ No newline at end of file diff --git a/TwitchDownloaderWPF/PageVodDownload.xaml b/TwitchDownloaderWPF/PageVodDownload.xaml index 3b52ae01..f11de5c7 100644 --- a/TwitchDownloaderWPF/PageVodDownload.xaml +++ b/TwitchDownloaderWPF/PageVodDownload.xaml @@ -69,8 +69,9 @@ (?): - - + + + (?): @@ -95,6 +96,7 @@ + diff --git a/TwitchDownloaderWPF/PageVodDownload.xaml.cs b/TwitchDownloaderWPF/PageVodDownload.xaml.cs index c2f74ddf..5782f070 100644 --- a/TwitchDownloaderWPF/PageVodDownload.xaml.cs +++ b/TwitchDownloaderWPF/PageVodDownload.xaml.cs @@ -51,6 +51,9 @@ private void SetEnabled(bool isEnabled) checkEnd.IsEnabled = isEnabled; SplitBtnDownload.IsEnabled = isEnabled; MenuItemEnqueue.IsEnabled = isEnabled; + RadioTrimSafe.IsEnabled = isEnabled; + RadioTrimExact.IsEnabled = isEnabled; + CheckExtractSubtitles.IsEnabled = isEnabled; SetEnabledTrimStart(isEnabled & checkStart.IsChecked.GetValueOrDefault()); SetEnabledTrimEnd(isEnabled & checkEnd.IsChecked.GetValueOrDefault()); } @@ -221,6 +224,11 @@ public VideoDownloadOptions GetOptions(string filename, string folder) else if (RadioTrimExact.IsChecked == true) options.TrimMode = VideoTrimMode.Exact; + if (CheckExtractSubtitles.IsChecked.GetValueOrDefault()) + options.SubtitlesStyle = SubtitlesStyle.OutputSrt; + else + options.SubtitlesStyle = SubtitlesStyle.None; + return options; } @@ -349,6 +357,7 @@ private void Page_Initialized(object sender, EventArgs e) VideoTrimMode.Exact => RadioTrimExact.IsChecked = true, _ => RadioTrimSafe.IsChecked = true, }; + CheckExtractSubtitles.IsChecked = Settings.Default.ExtractVideoSubtitles; } private void numDownloadThreads_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e) @@ -576,5 +585,14 @@ private void RadioTrimExact_OnCheckedStateChanged(object sender, RoutedEventArgs Settings.Default.Save(); } } + + private void CheckExtractSubtitles_OnCheckedChanged(object sender, RoutedEventArgs e) + { + if (IsInitialized) + { + Settings.Default.ExtractVideoSubtitles = CheckExtractSubtitles.IsChecked.GetValueOrDefault(); + Settings.Default.Save(); + } + } } } \ No newline at end of file diff --git a/TwitchDownloaderWPF/Properties/Settings.Designer.cs b/TwitchDownloaderWPF/Properties/Settings.Designer.cs index ab2114d5..4900d04d 100644 --- a/TwitchDownloaderWPF/Properties/Settings.Designer.cs +++ b/TwitchDownloaderWPF/Properties/Settings.Designer.cs @@ -933,5 +933,17 @@ public int VodTrimMode { this["VodTrimMode"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool ExtractVideoSubtitles { + get { + return ((bool)(this["ExtractVideoSubtitles"])); + } + set { + this["ExtractVideoSubtitles"] = value; + } + } } } diff --git a/TwitchDownloaderWPF/Properties/Settings.settings b/TwitchDownloaderWPF/Properties/Settings.settings index f3be4edd..a8f51cee 100644 --- a/TwitchDownloaderWPF/Properties/Settings.settings +++ b/TwitchDownloaderWPF/Properties/Settings.settings @@ -230,6 +230,9 @@ 1 + + False +