using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using TwitchDownloaderCore.Interfaces; using TwitchDownloaderCore.Options; using TwitchDownloaderCore.Tools; namespace TwitchDownloaderCore { public sealed class TsMerger { private readonly TsMergeOptions mergeOptions; private readonly ITaskProgress _progress; public TsMerger(TsMergeOptions tsMergeOptions, ITaskProgress progress) { mergeOptions = tsMergeOptions; _progress = progress; } public async Task MergeAsync(CancellationToken cancellationToken) { if (!File.Exists(mergeOptions.InputFile)) { throw new FileNotFoundException("Input file does not exist"); } var outputFileInfo = TwitchHelper.ClaimFile(mergeOptions.OutputFile, mergeOptions.FileCollisionCallback, _progress); mergeOptions.OutputFile = outputFileInfo.FullName; // Open the destination file so that it exists in the filesystem. await using var outputFs = outputFileInfo.Open(FileMode.Create, FileAccess.Write, FileShare.Read); try { await MergeAsyncImpl(outputFileInfo, outputFs, cancellationToken); } catch { await Task.Delay(100, CancellationToken.None); TwitchHelper.CleanUpClaimedFile(outputFileInfo, outputFs, _progress); throw; } } private async Task MergeAsyncImpl(FileInfo outputFileInfo, FileStream outputFs, CancellationToken cancellationToken) { var isM3U8 = false; var isFirst = true; var fileList = new List(); await using (var fs = File.Open(mergeOptions.InputFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { using var sr = new StreamReader(fs); while (await sr.ReadLineAsync() is { } line) { if (string.IsNullOrWhiteSpace(line)) continue; if (isFirst && line.StartsWith("#EXTM3U")) isM3U8 = true; isFirst = false; if (isM3U8 && line.StartsWith('#')) continue; fileList.Add(line); } } _progress.SetTemplateStatus("Verifying Parts {0}% [1/2]", 0); await VerifyVideoParts(fileList, cancellationToken); _progress.SetTemplateStatus("Combining Parts {0}% [2/2]", 0); await CombineVideoParts(fileList, outputFileInfo, outputFs, cancellationToken); } private async Task VerifyVideoParts(IReadOnlyCollection fileList, CancellationToken cancellationToken) { var failedParts = new List(); var partCount = fileList.Count; var doneCount = 0; foreach (var part in fileList) { var isValidTs = await VerifyVideoPart(part); if (!isValidTs) { failedParts.Add(part); } doneCount++; var percent = (int)(doneCount / (double)partCount * 100); _progress.ReportProgress(percent); cancellationToken.ThrowIfCancellationRequested(); } _progress.ReportProgress(100); if (failedParts.Count != 0) { if (failedParts.Count == fileList.Count) { // Every video part returned corrupted, probably a false positive. return; } _progress.LogInfo($"The following TS files are invalid or corrupted: {string.Join(", ", failedParts)}"); } } private static async Task VerifyVideoPart(string filePath) { const int TS_PACKET_LENGTH = 188; // MPEG TS packets are made of a header and a body: [ 4B ][ 184B ] - https://tsduck.io/download/docs/mpegts-introduction.pdf if (!File.Exists(filePath)) { return false; } await using var fs = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); var fileLength = fs.Length; if (fileLength == 0 || fileLength % TS_PACKET_LENGTH != 0) { return false; } return true; } private async Task CombineVideoParts(IReadOnlyCollection fileList, FileInfo outputFileInfo, FileStream outputStream, CancellationToken cancellationToken) { DriveInfo outputDrive = DriveHelper.GetOutputDrive(outputFileInfo.FullName); int partCount = fileList.Count; int doneCount = 0; foreach (var partFile in fileList) { await DriveHelper.WaitForDrive(outputDrive, _progress, cancellationToken); await using (var fs = File.Open(partFile, FileMode.Open, FileAccess.Read, FileShare.Read)) { await fs.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); } doneCount++; int percent = (int)(doneCount / (double)partCount * 100); _progress.ReportProgress(percent); cancellationToken.ThrowIfCancellationRequested(); } _progress.ReportProgress(100); } } }