diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs index b83b7ae7..4be3d361 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs @@ -1,4 +1,4 @@ -using CommandLine; +using CommandLine; using TwitchDownloaderCore.Tools; namespace TwitchDownloaderCLI.Modes.Arguments @@ -15,10 +15,10 @@ public class ChatDownloadArgs : ITwitchDownloaderArgs [Option("compression", Default = ChatCompression.None, HelpText = "Compresses an output json chat file using a specified compression, usually resulting in 40-90% size reductions. Valid values are: None, Gzip.")] public ChatCompression Compression { get; set; } - [Option('b', "beginning", HelpText = "Time in seconds to crop beginning.")] + [Option('b', "beginning", HelpText = "Time in seconds where the crop begins.")] public double CropBeginningTime { get; set; } - [Option('e', "ending", HelpText = "Time in seconds to crop ending.")] + [Option('e', "ending", HelpText = "Time in seconds where the crop ends.")] public double CropEndingTime { get; set; } [Option('E', "embed-images", Default = false, HelpText = "Embed first party emotes, badges, and cheermotes into the chat download for offline rendering.")] @@ -48,4 +48,4 @@ public class ChatDownloadArgs : ITwitchDownloaderArgs [Option("banner", Default = true, HelpText = "Displays a banner containing version and copyright information.")] public bool? ShowBanner { get; set; } } -} \ No newline at end of file +} diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs index b49a1e7d..3e789732 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs @@ -1,4 +1,4 @@ -using CommandLine; +using CommandLine; namespace TwitchDownloaderCLI.Modes.Arguments { @@ -26,10 +26,10 @@ public class ChatRenderArgs : ITwitchDownloaderArgs [Option('h', "chat-height", Default = 600, HelpText = "Height of chat render.")] public int ChatHeight { get; set; } - [Option('b', "beginning", Default = -1, HelpText = "Time in seconds to crop beginning of the render.")] + [Option('b', "beginning", Default = -1, HelpText = "Time in seconds where the crop begins.")] public int CropBeginningTime { get; set; } - [Option('e', "ending", Default = -1, HelpText = "Time in seconds to crop ending of the render.")] + [Option('e', "ending", Default = -1, HelpText = "Time in seconds where the crop ends.")] public int CropEndingTime { get; set; } [Option("bttv", Default = true, HelpText = "Enable BTTV emotes.")] @@ -155,4 +155,4 @@ public class ChatRenderArgs : ITwitchDownloaderArgs [Option("banner", Default = true, HelpText = "Displays a banner containing version and copyright information.")] public bool? ShowBanner { get; set; } } -} \ No newline at end of file +} diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs index 507291cb..c73c31a6 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs @@ -1,4 +1,4 @@ -using CommandLine; +using CommandLine; using TwitchDownloaderCore.Tools; namespace TwitchDownloaderCLI.Modes.Arguments @@ -21,10 +21,10 @@ public class ChatUpdateArgs : ITwitchDownloaderArgs [Option('R', "replace-embeds", Default = false, HelpText = "Replace all embedded emotes, badges, and cheermotes in the file. All embedded images will be overwritten!")] public bool ReplaceEmbeds { get; set; } - [Option('b', "beginning", Default = -1, HelpText = "New time in seconds for chat beginning. Comments may be added but not removed. -1 = No crop.")] + [Option('b', "beginning", Default = -1, HelpText = "New time in seconds where the chat begins. Comments may be added but not removed. -1 = No crop.")] public int CropBeginningTime { get; set; } - [Option('e', "ending", Default = -1, HelpText = "New time in seconds for chat ending. Comments may be added but not removed. -1 = No crop.")] + [Option('e', "ending", Default = -1, HelpText = "New time in seconds where the chat ends. Comments may be added but not removed. -1 = No crop.")] public int CropEndingTime { get; set; } [Option("bttv", Default = true, HelpText = "Enable BTTV embedding in chat download.")] diff --git a/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs index bb88c7df..b45c7341 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs @@ -1,4 +1,4 @@ -using CommandLine; +using CommandLine; namespace TwitchDownloaderCLI.Modes.Arguments { @@ -20,6 +20,9 @@ public class ClipDownloadArgs : ITwitchDownloaderArgs [Option("encode-metadata", Default = true, HelpText = "Uses FFmpeg to add metadata to the clip output file.")] public bool? EncodeMetadata { get; set; } + [Option("tbn", HelpText = "Set specific TBN (time base in AVStream) for output.")] + public int SetTbnValue { get; set; } + [Option("ffmpeg-path", HelpText = "Path to FFmpeg executable.")] public string FfmpegPath { get; set; } @@ -29,4 +32,4 @@ public class ClipDownloadArgs : ITwitchDownloaderArgs [Option("banner", Default = true, HelpText = "Displays a banner containing version and copyright information.")] public bool? ShowBanner { get; set; } } -} \ No newline at end of file +} diff --git a/TwitchDownloaderCLI/Modes/Arguments/TsMergeArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/TsMergeArgs.cs index 1b7fffbe..6d67c397 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/TsMergeArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/TsMergeArgs.cs @@ -1,4 +1,4 @@ -using CommandLine; +using CommandLine; namespace TwitchDownloaderCLI.Modes.Arguments { @@ -11,6 +11,9 @@ public class TsMergeArgs : ITwitchDownloaderArgs [Option('o', "output", Required = true, HelpText = "Path to output file.")] public string OutputFile { get; set; } + [Option('f', "ignore-missing", HelpText = "Ignore missing files listed inside input. Useful when the stream was trimmed.")] + public bool IgnoreMissingParts { get; set; } + [Option("banner", Default = true, HelpText = "Displays a banner containing version and copyright information.")] public bool? ShowBanner { get; set; } } diff --git a/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs index 9acd96d1..a8e9a667 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs @@ -1,4 +1,4 @@ -using CommandLine; +using CommandLine; namespace TwitchDownloaderCLI.Modes.Arguments { @@ -8,31 +8,46 @@ public class VideoDownloadArgs : ITwitchDownloaderArgs [Option('u', "id", Required = true, HelpText = "The ID or URL of the VOD to download.")] public string Id { get; set; } - [Option('o', "output", Required = true, HelpText = "Path to output file. File extension will be used to determine download type. Valid extensions are: .mp4 and .m4a.")] + [Option('o', "output", HelpText = "Path to output file. File extension will be used to determine download type. Valid extensions are: .mp4, .m4a.")] public string OutputFile { get; set; } - [Option('q', "quality", HelpText = "The quality the program will attempt to download.")] + [Option('q', "quality", HelpText = "The quality the program will attempt to download, like '1080p60'. If '-o' and '-q' are missing will be 'best'.")] public string Quality { get; set; } - [Option('b', "beginning", HelpText = "Time in seconds to crop beginning.")] + [Option('p', "parts", HelpText = "Only download playlist.m3u8, metadata.txt and .ts parts to cache folder, and exit. Overrides '-k', '-K', '-o'.")] + public bool TsPartsOnly { get; set; } + + [Option('K', "cache", HelpText = "Keep entire cache folder. Overrides '-k'.")] + public bool KeepCache { get; set; } + + [Option('k', "cache-noparts", HelpText = "Keep cache folder except .ts parts. Merged 'output.ts' is not considered a part.")] + public bool KeepCacheNoParts { get; set; } + + [Option('F', "skip-storagecheck", HelpText = "Skip checking for free storage space.")] + public bool SkipStorageCheck { get; set; } + + [Option('b', "beginning", HelpText = "Time in seconds where the crop of the ID begins. May break first GOP.")] public int CropBeginningTime { get; set; } - [Option('e', "ending", HelpText = "Time in seconds to crop ending.")] + [Option('e', "ending", HelpText = "Time in seconds where the crop of the ID ends. May break last GOP.")] public int CropEndingTime { get; set; } - [Option('t', "threads", Default = 4, HelpText = "Number of download threads.")] + [Option("tbn", HelpText = "Set specific TBN (time base in AVStream) for output.")] + public int SetTbnValue { get; set; } + + [Option('t', "threads", Default = 4, HelpText = "Number of simultaneous download threads.")] public int DownloadThreads { get; set; } [Option("bandwidth", Default = -1, HelpText = "The maximum bandwidth a thread will be allowed to use in kibibytes per second (KiB/s), or -1 for no maximum.")] public int ThrottleKib { get; set; } - [Option("oauth", HelpText = "OAuth access token to download subscriber only VODs. DO NOT SHARE THIS WITH ANYONE.")] + [Option('a', "oauth", HelpText = "OAuth access token to download subscriber only VODs. DO NOT SHARE THIS WITH ANYONE.")] public string Oauth { get; set; } [Option("ffmpeg-path", HelpText = "Path to FFmpeg executable.")] public string FfmpegPath { get; set; } - [Option("temp-path", Default = "", HelpText = "Path to temporary caching folder.")] + [Option('c', "temp-path", Default = "", HelpText = "Set custom path to temp/cache folder instead of provided by system. Recommended for '-k', '-K', '-p'.")] public string TempFolder { get; set; } [Option("banner", Default = true, HelpText = "Displays a banner containing version and copyright information.")] diff --git a/TwitchDownloaderCLI/Modes/DownloadClip.cs b/TwitchDownloaderCLI/Modes/DownloadClip.cs index 321b1b52..214108e4 100644 --- a/TwitchDownloaderCLI/Modes/DownloadClip.cs +++ b/TwitchDownloaderCLI/Modes/DownloadClip.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Threading; using TwitchDownloaderCLI.Modes.Arguments; @@ -50,6 +50,8 @@ private static ClipDownloadOptions GetDownloadOptions(ClipDownloadArgs inputOpti ThrottleKib = inputOptions.ThrottleKib, FfmpegPath = string.IsNullOrWhiteSpace(inputOptions.FfmpegPath) ? FfmpegHandler.FfmpegExecutableName : Path.GetFullPath(inputOptions.FfmpegPath), EncodeMetadata = inputOptions.EncodeMetadata!.Value, + SetTbn = inputOptions.SetTbnValue > 0.0, + SetTbnValue = inputOptions.SetTbnValue, TempFolder = inputOptions.TempFolder }; diff --git a/TwitchDownloaderCLI/Modes/DownloadVideo.cs b/TwitchDownloaderCLI/Modes/DownloadVideo.cs index 0a5bf4c0..8d03280a 100644 --- a/TwitchDownloaderCLI/Modes/DownloadVideo.cs +++ b/TwitchDownloaderCLI/Modes/DownloadVideo.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Threading; using TwitchDownloaderCLI.Modes.Arguments; @@ -47,38 +47,55 @@ private static VideoDownloadOptions GetDownloadOptions(VideoDownloadArgs inputOp Environment.Exit(1); } - if (!Path.HasExtension(inputOptions.OutputFile) && inputOptions.Quality is { Length: > 0 }) - { - if (inputOptions.Quality.Contains("audio", StringComparison.OrdinalIgnoreCase)) - inputOptions.OutputFile += ".m4a"; - else if (char.IsDigit(inputOptions.Quality[0]) - || inputOptions.Quality.Contains("source", StringComparison.OrdinalIgnoreCase) - || inputOptions.Quality.Contains("chunked", StringComparison.OrdinalIgnoreCase)) - inputOptions.OutputFile += ".mp4"; - } - VideoDownloadOptions downloadOptions = new() { DownloadThreads = inputOptions.DownloadThreads, ThrottleKib = inputOptions.ThrottleKib, Id = int.Parse(vodIdMatch.ValueSpan), Oauth = inputOptions.Oauth, - Filename = inputOptions.OutputFile, - Quality = Path.GetExtension(inputOptions.OutputFile)!.ToLower() switch - { - ".mp4" => inputOptions.Quality, - ".m4a" => "Audio", - _ => throw new ArgumentException("Only MP4 and M4A audio files are supported.") - }, + TsPartsOnly = inputOptions.TsPartsOnly, + KeepCache = inputOptions.KeepCache, + KeepCacheNoParts = inputOptions.KeepCacheNoParts, + SkipStorageCheck = inputOptions.SkipStorageCheck, CropBeginning = inputOptions.CropBeginningTime > 0.0, CropBeginningTime = inputOptions.CropBeginningTime, CropEnding = inputOptions.CropEndingTime > 0.0, CropEndingTime = inputOptions.CropEndingTime, + SetTbn = inputOptions.SetTbnValue > 0.0, + SetTbnValue = inputOptions.SetTbnValue, FfmpegPath = string.IsNullOrWhiteSpace(inputOptions.FfmpegPath) ? FfmpegHandler.FfmpegExecutableName : Path.GetFullPath(inputOptions.FfmpegPath), TempFolder = inputOptions.TempFolder }; + if (!string.IsNullOrWhiteSpace(inputOptions.OutputFile)) + { + if (!Path.HasExtension(inputOptions.OutputFile) && inputOptions.Quality is { Length: > 0 }) + { + if (inputOptions.Quality.Contains("audio", StringComparison.OrdinalIgnoreCase)) + inputOptions.OutputFile += ".m4a"; + else if (char.IsDigit(inputOptions.Quality[0]) + || inputOptions.Quality.Contains("source", StringComparison.OrdinalIgnoreCase) + || inputOptions.Quality.Contains("chunked", StringComparison.OrdinalIgnoreCase)) + inputOptions.OutputFile += ".mp4"; + } + + downloadOptions.Filename = inputOptions.OutputFile; + + downloadOptions.Quality = Path.GetExtension(inputOptions.OutputFile)!.ToLower() switch + { + ".mp4" => inputOptions.Quality, + ".m4a" => "Audio", + _ => throw new ArgumentException("Only MP4 and M4A audio files are supported.") + }; + } + else if (string.IsNullOrWhiteSpace(inputOptions.Quality)) + downloadOptions.Quality = "best"; + else if (inputOptions.Quality.Contains("audio", StringComparison.OrdinalIgnoreCase)) + downloadOptions.Quality = "Audio"; + else + downloadOptions.Quality = inputOptions.Quality; + return downloadOptions; } } -} \ No newline at end of file +} diff --git a/TwitchDownloaderCLI/Modes/MergeTs.cs b/TwitchDownloaderCLI/Modes/MergeTs.cs index 13c41c65..92ffb883 100644 --- a/TwitchDownloaderCLI/Modes/MergeTs.cs +++ b/TwitchDownloaderCLI/Modes/MergeTs.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using TwitchDownloaderCLI.Modes.Arguments; using TwitchDownloaderCLI.Tools; @@ -26,7 +26,8 @@ private static TsMergeOptions GetMergeOptions(TsMergeArgs inputOptions) TsMergeOptions mergeOptions = new() { OutputFile = inputOptions.OutputFile, - InputFile = inputOptions.InputList + InputFile = inputOptions.InputList, + IgnoreMissingParts = inputOptions.IgnoreMissingParts }; return mergeOptions; diff --git a/TwitchDownloaderCLI/README.md b/TwitchDownloaderCLI/README.md index 5d4b5ebe..0bbabee4 100644 --- a/TwitchDownloaderCLI/README.md +++ b/TwitchDownloaderCLI/README.md @@ -14,6 +14,16 @@ Also can concatenate/combine/merge Transport Stream files, either those parts do - [Example Commands](#example-commands) - [Additional Notes](#additional-notes) +### Global arguments + +The mode in use must support also the global arguments which are passed to the CLI. + +**--banner** +(Default: `true`) Displays a banner containing version and copyright information. + +**--silent** +(Default: `false`) Suppresses progress console output. `--silent true` implies `--banner false`. + --- ## Arguments for mode videodownload @@ -22,34 +32,49 @@ Also can concatenate/combine/merge Transport Stream files, either those parts do **-u / --id (REQUIRED)** The ID or URL of the VOD to download. -**-o / --output (REQUIRED)** -File the program will output to. File extension will be used to determine download type. Valid extensions are: `.mp4` and `.m4a`. +**-o / --output** +Path to output file. File extension will be used to determine download type. Valid extensions are: .mp4, .m4a. **-q / --quality** -The quality the program will attempt to download, for example "1080p60", if not found will download highest quality stream. +The quality the program will attempt to download, like '1080p60'. If '-o' and '-q' are missing will be 'best'. + +**-p / --parts** +Only download playlist.m3u8, metadata.txt and .ts parts to cache folder, and exit. Overrides '-k', '-K', '-o'. + +**-K / --cache** +Keep entire cache folder. Overrides '-k'. + +**-k / --cache-noparts** +Keep cache folder except .ts parts. Merged 'output.ts' is not considered a part. + +**-F / --skip-storagecheck** +Skip checking for free storage space. **-b / --beginning** -Time in seconds to crop beginning. For example if I had a 10 second stream but only wanted the last 7 seconds of it I would use `-b 3` to skip the first 3 seconds. +Time in seconds where the crop of the ID begins. May break first GOP. For example if I had a 10 second stream but only wanted the last 7 seconds of it I would use `-b 3` to skip the first 3 seconds. **-e / --ending** -Time in seconds to crop ending. For example if I had a 10 second stream but only wanted the first 4 seconds of it I would use `-e 4` to end on the 4th second. +Time in seconds where the crop of the ID ends. May break last GOP. For example if I had a 10 second stream but only wanted the first 4 seconds of it I would use `-e 4` to end on the 4th second. Extra example, if I wanted only seconds 3-6 in a 10 second stream I would do `-b 3 -e 6` +**--tbn** +Set specific TBN (time base in AVStream) for output. + **-t / --threads** -(Default: `4`) Number of download threads. +(Default: `4`) Number of simultaneous download threads. **--bandwidth** (Default: `-1`) The maximum bandwidth a thread will be allowed to use in kibibytes per second (KiB/s), or `-1` for no maximum. -**--oauth** +**-a / --oauth** OAuth access token to download subscriber only VODs. **DO NOT SHARE YOUR OAUTH TOKEN WITH ANYONE.** **--ffmpeg-path** Path to FFmpeg executable. -**--temp-path** -Path to temporary folder for cache. +**-c / --temp-path** +Set custom path to temp/cache folder instead of provided by system. Recommended for '-k', '-K', '-p'. **--banner** (Default: `true`) Displays a banner containing version and copyright information. @@ -72,6 +97,9 @@ The quality the program will attempt to download, for example "1080p60", if not **--encode-metadata** (Default: `true`) Uses FFmpeg to add metadata to the clip output file. +**--tbn** +Set specific TBN (time base in AVStream) for output. + **--ffmpeg-path** Path to FFmpeg executable. @@ -94,10 +122,10 @@ File the program will output to. File extension will be used to determine downlo (Default: `None`) Compresses an output json chat file using a specified compression, usually resulting in 40-90% size reductions. Valid values are: `None`, `Gzip`. More formats will be supported in the future. **-b / --beginning** -Time in seconds to crop beginning. For example if I had a 10 second stream but only wanted the last 7 seconds of it I would use `-b 3` to skip the first 3 seconds. +Time in seconds where the crop begins. For example if I had a 10 second stream but only wanted the last 7 seconds of it I would use `-b 3` to skip the first 3 seconds. **-e / --ending** -Time in seconds to crop ending. For example if I had a 10 second stream but only wanted the first 4 seconds of it I would use `-e 4` to end on the 4th second. +Time in seconds where the crop ends. For example if I had a 10 second stream but only wanted the first 4 seconds of it I would use `-e 4` to end on the 4th second. **-E / --embed-images** (Default: `false`) Embed first party emotes, badges, and cheermotes into the download file for offline rendering. Useful for archival purposes, file size will be larger. @@ -145,10 +173,10 @@ Path to output file. File extension will be used to determine new chat type. Val (Default: `false`) Replace all embedded emotes, badges, and cheermotes in the file. All embedded data will be overwritten! **b / --beginning** -(Default: `-1`) New time in seconds for chat beginning. Comments may be added but not removed. -1 = No crop. +(Default: `-1`) New time in seconds where the chat begins. Comments may be added but not removed. -1 = No crop. **-e / --ending** -(Default: `-1`) New time in seconds for chat beginning. Comments may be added but not removed. -1 = No crop. +(Default: `-1`) New time in seconds where chat ends. Comments may be added but not removed. -1 = No crop. **--bttv** (Default: `true`) Enable embedding BTTV emotes. @@ -193,10 +221,10 @@ File the program will output to. (Default: `600`) Height of chat render. **-b / --beginning** -(Default: `-1`) Time in seconds to crop the beginning of the render. +(Default: `-1`) Time in seconds where the crop begins. **-e / --ending** -(Default: `-1`) Time in seconds to crop the ending of the render. +(Default: `-1`) Time in seconds where the crop ends. **--bttv** (Default: `true`) Enable BTTV emotes. @@ -354,6 +382,9 @@ Path a text file containing the absolute paths of the files to concatenate, sepa **-o / --output (REQUIRED)** File the program will output to. +**-f / --ignore-missing** +(Default: `false`) Ignore missing files listed inside input. Useful when the stream was trimmed. + **--banner** (Default: `true`) Displays a banner containing version and copyright information. @@ -423,8 +454,9 @@ Print the available options for the VOD downloader --- ## Additional Notes +#### General -All `--id` inputs will accept either video/clip IDs or full video/clip URLs. i.e. `--id 612942303` or `--id https://twitch.tv/videos/612942303`. +All `--id` inputs will accept either video/clip IDs or full video/clip URLs. i.e. `--id 612942303`, `--id https://twitch.tv/videos/612942303` or `--id https://www.twitch.tv/videos/799499623?filter=all&sort=views`. String arguments that contain spaces should be wrapped in either single quotes ' or double quotes ". i.e. `--output 'my output file.mp4'` or `--output "my output file.mp4"` @@ -434,5 +466,43 @@ For Linux users, ensure both `fontconfig` and `libfontconfig1` are installed. `a Some distros, like Linux Alpine, lack fonts for some languages (Arabic, Persian, Thai, etc.) If this is the case for you, install additional fonts families such as [Noto](https://fonts.google.com/noto/specimen/Noto+Sans) or check your distro's wiki page on fonts as it may have an install command for this specific scenario, such as the [Linux Alpine](https://wiki.alpinelinux.org/wiki/Fonts) font page. +When cropping, the part of the file to be retained is the one after the crop starts and before the crop ends. The rest is discarded. So in this context 'crop' means 'crop in', while in others could mean 'crop out' and that's the opposite. + +The location of the `temp`, `temporary` or `cache` folder, is automatically assigned by the operating system by default. In some operating systems can be difficult or impossible to access by a program different than `TwitchDownloader`, if it's in a private or encrypted area, or if the permissions are limited. Also could cause excessive wear of the internal flash storage. This is why it's recommended to manually set it to a specific place. + The list file for `tsmerge` may contain relative or absolute paths, with one path per line. -Alternatively, the list file may also be an M3U8 playlist file. \ No newline at end of file +Alternatively, the list file may also be an M3U8 playlist file. + +The `Quality` keywords available for the `videodownload` mode are: + +- best, source, chunked +- 1080, 1080p, 1080p60, 1920x1080, 1920x1080p, 1920x1080p60 +- 900, 900p, 900p60, 1600x900, 1600x900p, 1600x900p60 +- 720, 720p, 720p60, 1280x720, 1280x720p, 1280x720p60 +- 480, 480p, 480p60, 640x480, 640x480p, 640x480p60 +- 360, 360p, 360p60, 480x360, 480x360p, 480x360p60 +- 160, 160p, 160p60, 284x160, 284x160p, 284x160p60 +- 144, 144p, 144p60. 256x144, 256x144p, 256x144p60 +- worst +- audio + +The framerate is detected based on the resolution, which is prioritized. If the framerate (like 30, 50, 60, 120 or any other) is manually set, will look for a stream that matches the provided parameters. If that stream does not exist, will default to best. +The audio stream is also the same audio stream for all video tracks. Twitch does not allow different audio tracks with different qualities for the same VOD ID. But there are different audio qualities to choose from before starting the stream. + +#### Trimming and chapters + +The mode `videodownload` has the options `-b` and `-e` to trim the output file at the beginning and/or at the end. The values are in whole seconds and relative to the full ID duration. The mode `clipdownload` doesn't have these options. + +This trim splits the compressed video outside of I-frames (in copy mode) most of the time, and doesn't recode the affected GOPs, for the sake of speed and simplicity. Twitch streams don't always have I-frames placed at whole second marks (can be like 2.9899 seconds). Sometimes the GOPs can be spaced more than 10 seconds from each other, but usually it's 1.5-2. + +As a result of this, the GOP at the beginning and/or the end will be split, leaving that part of it unplayable, until the next I-frame (Intra-Frame or key-Frame) is reached. The audio is not affected, either if it is alone or with video. + +This is because Twitch streams use the H.264 (AVC) codec, which is a consumer-only video format designed for efficiency. + +So when it's not cut by I-frame, although the audio and the video have the same duration, the playable video is less than the audio, and starts to play after the audio has been already reproducing for a while. Depending on the video player and its version: + +- If the output file is only .m4a there's no issue, plays from beginning to end. +- Some video players play from the very start of the whole file, if at least one of the tracks is playable. They put a black frame on screen while the audio plays until everything is back in sync. `Quick Look` (Mac Spacebar Preview) does this. This is the most conversative approach and it's when people realize their video is broken. +- Other video players start playing from the timestamp when all tracks can be decoded properly, so they wait until the video can be played, and skip that portion of audio. Some people may never know that there's more audio inside. Forcing to play audio only, like `mpv --no-video output.mp4`, forces to play the entire audio stream. + +To avoid losing video content, trim the beginning earlier, and the end later, so all the desired content falls into full GOPs. Chapters are not adjusted relative to trimmed file, `ffmpeg` can't do that automatically. diff --git a/TwitchDownloaderCLI/Tools/ProgressHandler.cs b/TwitchDownloaderCLI/Tools/ProgressHandler.cs index a51991e7..da42a5b0 100644 --- a/TwitchDownloaderCLI/Tools/ProgressHandler.cs +++ b/TwitchDownloaderCLI/Tools/ProgressHandler.cs @@ -1,4 +1,4 @@ -using System; +using System; using TwitchDownloaderCore; namespace TwitchDownloaderCLI.Tools @@ -14,6 +14,9 @@ internal static void Progress_ProgressChanged(object sender, ProgressReport e) case ReportType.Log: ReportLog(e); break; + case ReportType.LogWithoutNewlineFirst: + LogWithoutNewlineFirst(e); + break; case ReportType.NewLineStatus: ReportNewLineStatus(e); break; @@ -33,6 +36,13 @@ private static void ReportLog(ProgressReport e) Console.Write(currentStatus); } + private static void LogWithoutNewlineFirst(ProgressReport e) + { + var currentMessage = "[LOG] - " + e.Data; + Console.Write(currentMessage + Environment.NewLine); + _previousMessage = currentMessage; + } + private static void ReportNewLineStatus(ProgressReport e) { var currentStatus = Environment.NewLine + "[STATUS] - " + e.Data; diff --git a/TwitchDownloaderCore/ChatDownloader.cs b/TwitchDownloaderCore/ChatDownloader.cs index d74df7b8..d55e8e30 100644 --- a/TwitchDownloaderCore/ChatDownloader.cs +++ b/TwitchDownloaderCore/ChatDownloader.cs @@ -530,6 +530,8 @@ public async Task DownloadAsync(IProgress progress, Cancellation default: throw new NotSupportedException($"{downloadOptions.DownloadFormat} is not a supported output format."); } + + Console.WriteLine(); } } -} \ No newline at end of file +} diff --git a/TwitchDownloaderCore/ClipDownloader.cs b/TwitchDownloaderCore/ClipDownloader.cs index 428a990b..11afb391 100644 --- a/TwitchDownloaderCore/ClipDownloader.cs +++ b/TwitchDownloaderCore/ClipDownloader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.IO; using System.Linq; @@ -82,6 +82,7 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress) _progress.Report(new ProgressReport(ReportType.SameLineStatus, "Encoding Clip Metadata 100%")); _progress.Report(new ProgressReport(100)); + Console.WriteLine(); } finally { @@ -160,7 +161,7 @@ await FfmpegMetadata.SerializeAsync(metadataFile, clipMetadata.broadcaster.displ StartInfo = { FileName = downloadOptions.FfmpegPath, - Arguments = $"-i \"{inputFile}\" -i \"{metadataFile}\" -map_metadata 1 -y -c copy \"{destinationFile}\"", + Arguments = $"-i \"{inputFile}\" -i \"{metadataFile}\" -map_metadata 1 -y -c copy {(downloadOptions.SetTbn ? $"-video_track_timescale {downloadOptions.SetTbnValue} " : "")}\"{destinationFile}\"", UseShellExecute = false, CreateNoWindow = true, RedirectStandardInput = false, diff --git a/TwitchDownloaderCore/Options/ClipDownloadOptions.cs b/TwitchDownloaderCore/Options/ClipDownloadOptions.cs index 9f8c6ce9..fc4d39b8 100644 --- a/TwitchDownloaderCore/Options/ClipDownloadOptions.cs +++ b/TwitchDownloaderCore/Options/ClipDownloadOptions.cs @@ -1,4 +1,4 @@ -namespace TwitchDownloaderCore.Options +namespace TwitchDownloaderCore.Options { public class ClipDownloadOptions { @@ -8,6 +8,8 @@ public class ClipDownloadOptions public int ThrottleKib { get; set; } public string TempFolder { get; set; } public bool EncodeMetadata { get; set; } + public bool SetTbn { get; set; } + public int SetTbnValue { get; set; } public string FfmpegPath { get; set; } } -} \ No newline at end of file +} diff --git a/TwitchDownloaderCore/Options/TsMergeOptions.cs b/TwitchDownloaderCore/Options/TsMergeOptions.cs index 8dd41e29..065ab5b5 100644 --- a/TwitchDownloaderCore/Options/TsMergeOptions.cs +++ b/TwitchDownloaderCore/Options/TsMergeOptions.cs @@ -1,8 +1,9 @@ -namespace TwitchDownloaderCore.Options +namespace TwitchDownloaderCore.Options { public class TsMergeOptions { public string OutputFile { get; set; } public string InputFile { get; set; } + public bool IgnoreMissingParts { get; set; } } } diff --git a/TwitchDownloaderCore/Options/VideoDownloadOptions.cs b/TwitchDownloaderCore/Options/VideoDownloadOptions.cs index 89c1c22f..c199b471 100644 --- a/TwitchDownloaderCore/Options/VideoDownloadOptions.cs +++ b/TwitchDownloaderCore/Options/VideoDownloadOptions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; namespace TwitchDownloaderCore.Options @@ -8,10 +8,16 @@ public class VideoDownloadOptions public int Id { get; set; } public string Quality { get; set; } public string Filename { get; set; } + public bool TsPartsOnly { get; set; } + public bool KeepCache { get; set; } + public bool KeepCacheNoParts { get; set; } + public bool SkipStorageCheck { get; set; } public bool CropBeginning { get; set; } public double CropBeginningTime { get; set; } public bool CropEnding { get; set; } public double CropEndingTime { get; set; } + public bool SetTbn { get; set; } + public int SetTbnValue { get; set; } public int DownloadThreads { get; set; } public int ThrottleKib { get; set; } public string Oauth { get; set; } diff --git a/TwitchDownloaderCore/ProgressReport.cs b/TwitchDownloaderCore/ProgressReport.cs index 75d22e7b..24b6af14 100644 --- a/TwitchDownloaderCore/ProgressReport.cs +++ b/TwitchDownloaderCore/ProgressReport.cs @@ -1,8 +1,9 @@ -namespace TwitchDownloaderCore +namespace TwitchDownloaderCore { public enum ReportType { Log, + LogWithoutNewlineFirst, Percent, NewLineStatus, SameLineStatus, @@ -28,4 +29,4 @@ public ProgressReport(ReportType reportType, string message) Data = message; } } -} \ No newline at end of file +} diff --git a/TwitchDownloaderCore/TsMerger.cs b/TwitchDownloaderCore/TsMerger.cs index e3e54128..ffffa152 100644 --- a/TwitchDownloaderCore/TsMerger.cs +++ b/TwitchDownloaderCore/TsMerger.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Threading; @@ -26,28 +26,37 @@ public async Task MergeAsync(CancellationToken cancellationToken) throw new FileNotFoundException("Input file does not exist"); } - var isM3U8 = false; var fileList = new List(); + bool anyFilesMissing = false; 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 (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) continue; - if (isM3U8) + var lineFilePath = Path.IsPathRooted(line) ? line : Path.Combine(Path.GetDirectoryName(mergeOptions.InputFile), line); + + if (File.Exists(lineFilePath)) { - if (line.StartsWith('#')) continue; + fileList.Add(lineFilePath); } else { - if (line.StartsWith("#EXTM3U")) isM3U8 = true; + anyFilesMissing = true; + if (!mergeOptions.IgnoreMissingParts) + { + throw new FileNotFoundException($"File does not exist: {lineFilePath}"); + } } - - fileList.Add(line); } } + if (anyFilesMissing && mergeOptions.IgnoreMissingParts) + { + _progress.Report(new ProgressReport(ReportType.LogWithoutNewlineFirst, "One or more files listed in the playlist do not exist and were skipped.")); + } + _progress.Report(new ProgressReport(ReportType.SameLineStatus, "Verifying Parts 0% [1/2]")); await VerifyVideoParts(fileList, cancellationToken); @@ -57,6 +66,7 @@ public async Task MergeAsync(CancellationToken cancellationToken) await CombineVideoParts(fileList, cancellationToken); _progress.Report(new ProgressReport(100)); + Console.WriteLine(); } private async Task VerifyVideoParts(IReadOnlyCollection fileList, CancellationToken cancellationToken) diff --git a/TwitchDownloaderCore/VideoDownloader.cs b/TwitchDownloaderCore/VideoDownloader.cs index a9d6e2c9..d6a04948 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; @@ -24,6 +24,10 @@ public sealed class VideoDownloader private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; private readonly IProgress _progress; private bool _shouldClearCache = true; + private readonly bool _shouldGenerateOutputFile = false; + private readonly bool _shouldSkipStorageCheck = false; + private readonly bool _shouldDownloadOnlyTsParts = false; + private readonly short _totalProgressSteps = 0; public VideoDownloader(VideoDownloadOptions videoDownloadOptions, IProgress progress) { @@ -32,6 +36,23 @@ public VideoDownloader(VideoDownloadOptions videoDownloadOptions, IProgress x.PartInfo.Duration); @@ -91,38 +102,63 @@ public async Task DownloadAsync(CancellationToken cancellationToken) startOffsetSeconds = downloadOptions.CropBeginningTime - startOffsetSeconds; double seekDuration = Math.Round(downloadOptions.CropEndingTime - downloadOptions.CropBeginningTime); + string playlistFilePath = Path.Combine(downloadFolder, "playlist.m3u8"); + await File.WriteAllTextAsync(playlistFilePath, playlistString, cancellationToken); + string metadataPath = Path.Combine(downloadFolder, "metadata.txt"); VideoInfo videoInfo = videoInfoResponse.data.video; var chaptersOffset = downloadOptions.CropBeginning ? downloadOptions.CropBeginningTime : 0; 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(), chaptersOffset, videoChapterResponse.data.video.moments.edges, cancellationToken); - var finalizedFileDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!; - if (!finalizedFileDirectory.Exists) + _progress.Report(new ProgressReport(ReportType.NewLineStatus, $"Downloading 0% [2/{_totalProgressSteps}]")); + + await DownloadVideoPartsAsync(playlist.Streams, videoListCrop, baseUrl, downloadFolder, vodAge, cancellationToken); + + _progress.Report(new ProgressReport() { ReportType = ReportType.NewLineStatus, Data = $"Verifying Parts 0% [3/{_totalProgressSteps}]" }); + + await VerifyDownloadedParts(playlist.Streams, videoListCrop, baseUrl, downloadFolder, vodAge, cancellationToken); + + if (!_shouldDownloadOnlyTsParts) { - TwitchHelper.CreateDirectory(finalizedFileDirectory.FullName); + _progress.Report(new ProgressReport() { ReportType = ReportType.NewLineStatus, Data = $"Combining Parts 0% [4/{_totalProgressSteps}]" }); + + await CombineVideoParts(downloadFolder, playlist.Streams, videoListCrop, cancellationToken); } - int ffmpegExitCode; - var ffmpegRetries = 0; - do + if (_shouldGenerateOutputFile && !_shouldDownloadOnlyTsParts) { - ffmpegExitCode = await Task.Run(() => RunFfmpegVideoCopy(downloadFolder, metadataPath, startOffsetSeconds, seekDuration), cancellationToken); - if (ffmpegExitCode != 0) + _progress.Report(new ProgressReport() { ReportType = ReportType.NewLineStatus, Data = $"Finalizing Video 0% [5/{_totalProgressSteps}]" }); + + var finalizedFileDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!; + if (!finalizedFileDirectory.Exists) { - _progress.Report(new ProgressReport(ReportType.Log, $"Failed to finalize video (code {ffmpegExitCode}), retrying in 10 seconds...")); - await Task.Delay(10_000, cancellationToken); + TwitchHelper.CreateDirectory(finalizedFileDirectory.FullName); } - } while (ffmpegExitCode != 0 && ffmpegRetries++ < 1); - if (ffmpegExitCode != 0 || !File.Exists(downloadOptions.Filename)) - { - _shouldClearCache = false; - throw new Exception($"Failed to finalize video. The download cache has not been cleared and can be found at {downloadFolder} along with a log file."); + int ffmpegExitCode; + var ffmpegRetries = 0; + do + { + ffmpegExitCode = await Task.Run(() => RunFfmpegVideoCopy(downloadFolder, metadataPath, startOffsetSeconds, seekDuration), cancellationToken); + if (ffmpegExitCode != 0) + { + _progress.Report(new ProgressReport(ReportType.Log, $"Failed to finalize video (code {ffmpegExitCode}), retrying in 10 seconds...")); + await Task.Delay(10_000, cancellationToken); + } + } while (ffmpegExitCode != 0 && ffmpegRetries++ < 1); + + if (ffmpegExitCode != 0 || !File.Exists(downloadOptions.Filename)) + { + _shouldClearCache = false; + throw new Exception($"Failed to finalize video. The download cache has not been cleared and can be found at {downloadFolder} along with a log file."); + } + + _progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Finalizing Video 100% [5/{_totalProgressSteps}]")); + _progress.Report(new ProgressReport(100)); } - _progress.Report(new ProgressReport(ReportType.SameLineStatus, "Finalizing Video 100% [5/5]")); - _progress.Report(new ProgressReport(100)); + Console.WriteLine(); } finally { @@ -135,30 +171,87 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, d private void CheckAvailableStorageSpace(int bandwidth, TimeSpan videoLength) { + /* + Real size of output.ts is higher than videoSizeInBytes the smaller is the duration and the crop percentage, + but it's at least 100% of it (above 2.5 hours of duration can be considered the same size). + Real size of output.mp4 is generally 98% of videoSizeInBytes but can be up to 105% for 1 second crop. + Percentages for output.m4a can be different. + + The sum of parts.ts always has the same size as output.ts + videoSizeInBytes is the reference for calculations (100%) + + Case 1 (crop 100%, no crop): + videoSizeInBytes 226785953 + output.ts 227712744; 227712744/226785953=1.00408663318 ~101% + output.mp4 220774770; 220774770/226785953=0.973494024121 ~98% + Duration: 3:51 + + Case 2 (crop 100%, no crop): + videoSizeInBytes 6928957075 + output.ts 6928318880; 6928318880/6928957075=0.999907894508 ~100% + output.mp4 6650715123; 6650715123/6928957075=0.95984360287 ~96% + Duration: 2:24:10 + + Case 3 (crop 71%): + 71% of 231 seconds = 164 seconds. To remove 67 seconds. + videoSizeInBytes 161008209; + output.ts 170569956; 170569956/161008209=1.05938670494 ~106% + output.mp4 156901116; 156901116/161008209=0.974491406211 ~98% + Duration: 2:44 + + Case 4 (crop 71%): + 71% of 8650 seconds = 6142 seconds. To remove 2508 seconds. + videoSizeInBytes 4919960041 + output.ts 4934806360; 4934806360/4919960041=1.00301756902 ~101% + output.mp4 4723516881; 4723516881/4919960041=0.960072204172 ~97% + Duration: 1:42:22 + + Case 5 (crop 1 second): + videoSizeInBytes 981757 + output.ts 9863608; 9863608/981757=10.0468934777 ~1005% + output.mp4 1022497; 1022497/981757=1.04149703032 ~105% + Duration: 1 + */ + var videoSizeInBytes = VideoSizeEstimator.EstimateVideoSize(bandwidth, downloadOptions.CropBeginning ? TimeSpan.FromSeconds(downloadOptions.CropBeginningTime) : TimeSpan.Zero, downloadOptions.CropEnding ? TimeSpan.FromSeconds(downloadOptions.CropEndingTime) : videoLength); - var tempFolderDrive = DriveHelper.GetOutputDrive(downloadOptions.TempFolder); - var destinationDrive = DriveHelper.GetOutputDrive(downloadOptions.Filename); - if (tempFolderDrive.Name == destinationDrive.Name) + DriveInfo tempFolderDrive = DriveHelper.GetOutputDrive(downloadOptions.TempFolder); + DriveInfo destinationDrive = _shouldGenerateOutputFile ? DriveHelper.GetOutputDrive(downloadOptions.Filename) : null; + long requiredSpaceOnTempDrive = videoSizeInBytes; + long requiredSpaceOnDestinationDrive = 0; + + if (!_shouldDownloadOnlyTsParts) { - if (tempFolderDrive.AvailableFreeSpace < videoSizeInBytes * 2) + if (downloadOptions.KeepCache) { - _progress.Report(new ProgressReport(ReportType.Log, $"The drive '{tempFolderDrive.Name}' may not have enough free space to complete the download.")); + requiredSpaceOnTempDrive += videoSizeInBytes; } - } - else - { - if (tempFolderDrive.AvailableFreeSpace < videoSizeInBytes) + + if (_shouldGenerateOutputFile) { - // More drive space is needed by the raw ts files due to repeat metadata, but the amount of metadata packets can vary between files so we won't bother. - _progress.Report(new ProgressReport(ReportType.Log, $"The drive '{tempFolderDrive.Name}' may not have enough free space to complete the download.")); + requiredSpaceOnDestinationDrive = videoSizeInBytes; + + if (tempFolderDrive.Name == destinationDrive?.Name) + { + requiredSpaceOnTempDrive += requiredSpaceOnDestinationDrive; + requiredSpaceOnDestinationDrive = 0; + } } + } - if (destinationDrive.AvailableFreeSpace < videoSizeInBytes) + if (tempFolderDrive.AvailableFreeSpace < requiredSpaceOnTempDrive) + { + // More drive space is needed by the raw ts files due to repeat metadata, but the amount of metadata packets can vary between files so we won't bother. + _progress.Report(new ProgressReport(ReportType.Log, $"Insufficient space on temp drive '{tempFolderDrive.Name}'. Required: {requiredSpaceOnTempDrive / (1024.0 * 1024.0):F2} MB.")); + } + + if (requiredSpaceOnDestinationDrive > 0 && destinationDrive != null) + { + if (destinationDrive.AvailableFreeSpace < requiredSpaceOnDestinationDrive) { - _progress.Report(new ProgressReport(ReportType.Log, $"The drive '{destinationDrive.Name}' may not have enough free space to complete finalization.")); + _progress.Report(new ProgressReport(ReportType.Log, $"Insufficient space on destination drive '{destinationDrive.Name}'. Required: {requiredSpaceOnDestinationDrive / (1024.0 * 1024.0):F2} MB.")); } } } @@ -248,7 +341,7 @@ private async Task> WaitForDownloadThreads(Task[] { previousDoneCount = videoPartsQueue.Count; var percent = (int)((partCount - previousDoneCount) / (double)partCount * 100); - _progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Downloading {percent}% [2/5]")); + _progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Downloading {percent}% [2/{_totalProgressSteps}]")); _progress.Report(new ProgressReport(percent)); } @@ -338,7 +431,7 @@ private async Task VerifyDownloadedParts(ICollection playlist, Rang doneCount++; var percent = (int)(doneCount / (double)partCount * 100); - _progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Verifying Parts {percent}% [3/5]")); + _progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Verifying Parts {percent}% [3/{_totalProgressSteps}]")); _progress.Report(new ProgressReport(percent)); cancellationToken.ThrowIfCancellationRequested(); @@ -391,7 +484,7 @@ private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, doubl { FileName = downloadOptions.FfmpegPath, Arguments = string.Format( - "-hide_banner -stats -y -avoid_negative_ts make_zero " + (downloadOptions.CropBeginning ? "-ss {2} " : "") + "-i \"{0}\" -i \"{1}\" -map_metadata 1 -analyzeduration {3} -probesize {3} " + (downloadOptions.CropEnding ? "-t {4} " : "") + "-c:v copy \"{5}\"", + "-hide_banner -stats -y -avoid_negative_ts make_zero " + (downloadOptions.CropBeginning ? "-ss {2} " : "") + "-i \"{0}\" -i \"{1}\" -map_metadata 1 -analyzeduration {3} -probesize {3} " + (downloadOptions.CropEnding ? "-t {4} " : "") + (downloadOptions.SetTbn ? $"-video_track_timescale {downloadOptions.SetTbnValue} " : "") + "-c:v copy \"{5}\"", Path.Combine(downloadFolder, "output.ts"), metadataPath, startOffset.ToString(CultureInfo.InvariantCulture), int.MaxValue, seekDuration.ToString(CultureInfo.InvariantCulture), Path.GetFullPath(downloadOptions.Filename)), UseShellExecute = false, CreateNoWindow = true, @@ -411,7 +504,7 @@ private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, doubl logQueue.Enqueue(e.Data); // We cannot use -report ffmpeg arg because it redirects stderr - HandleFfmpegOutput(e.Data, encodingTimeRegex, seekDuration, _progress); + HandleFfmpegOutput(e.Data, encodingTimeRegex, seekDuration, _progress, _totalProgressSteps); }; process.Start(); @@ -430,7 +523,7 @@ private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, doubl return process.ExitCode; } - private static void HandleFfmpegOutput(string output, Regex encodingTimeRegex, double videoLength, IProgress progress) + private static void HandleFfmpegOutput(string output, Regex encodingTimeRegex, double videoLength, IProgress progress, short totalProgressSteps) { var encodingTimeMatch = encodingTimeRegex.Match(output); if (!encodingTimeMatch.Success) @@ -448,12 +541,12 @@ private static void HandleFfmpegOutput(string output, Regex encodingTimeRegex, d // Apparently it is possible for the percent to not be within the range of 0-100. lay295#716 if (percent is < 0 or > 100) { - progress.Report(new ProgressReport(ReportType.SameLineStatus, "Finalizing Video... [5/5]")); + progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Finalizing Video... [5/{totalProgressSteps}]")); progress.Report(new ProgressReport(0)); } else { - progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Finalizing Video {percent}% [5/5]")); + progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Finalizing Video {percent}% [5/{totalProgressSteps}]")); progress.Report(new ProgressReport(percent)); } } @@ -508,7 +601,7 @@ private static async Task DownloadVideoPartAsync(HttpClient httpClient, Uri base } } - private async Task<(M3U8 playlist, Range cropRange, double vodAge)> GetVideoPlaylist(string playlistUrl, CancellationToken cancellationToken) + private async Task<(M3U8 playlist, Range cropRange, double vodAge, string playlistString)> GetVideoPlaylist(string playlistUrl, CancellationToken cancellationToken) { var playlistString = await _httpClient.GetStringAsync(playlistUrl, cancellationToken); var playlist = M3U8.Parse(playlistString); @@ -522,7 +615,7 @@ private static async Task DownloadVideoPartAsync(HttpClient httpClient, Uri base var videoListCrop = GetStreamListCrop(playlist.Streams, downloadOptions); - return (playlist, videoListCrop, vodAge); + return (playlist, videoListCrop, vodAge, playlistString); } private static Range GetStreamListCrop(IList streamList, VideoDownloadOptions downloadOptions) @@ -670,16 +763,19 @@ private async Task CombineVideoParts(string downloadFolder, IEnumerable