diff --git a/TwitchDownloaderCLI/Models/LogLevel.cs b/TwitchDownloaderCLI/Models/Enums.cs similarity index 71% rename from TwitchDownloaderCLI/Models/LogLevel.cs rename to TwitchDownloaderCLI/Models/Enums.cs index e472e17a..6b6cd172 100644 --- a/TwitchDownloaderCLI/Models/LogLevel.cs +++ b/TwitchDownloaderCLI/Models/Enums.cs @@ -13,4 +13,12 @@ internal enum LogLevel Error = 1 << 5, Ffmpeg = 1 << 6, } + + public enum OverwriteBehavior + { + Overwrite, + Exit, + Rename, + Prompt, + } } \ No newline at end of file diff --git a/TwitchDownloaderCLI/Modes/Arguments/CacheArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/CacheArgs.cs index 1f9c89bc..92c66843 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/CacheArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/CacheArgs.cs @@ -1,14 +1,19 @@ using CommandLine; +using TwitchDownloaderCLI.Models; namespace TwitchDownloaderCLI.Modes.Arguments { [Verb("cache", HelpText = "Manage the working cache")] - internal sealed class CacheArgs : TwitchDownloaderArgs + internal sealed class CacheArgs : ITwitchDownloaderArgs { [Option('c', "clear", Default = false, Required = false, HelpText = "Clears the default cache folder.")] public bool ClearCache { get; set; } [Option("force-clear", Default = false, Required = false, HelpText = "Clears the default cache folder, bypassing the confirmation prompt")] public bool ForceClearCache { get; set; } + + // Interface args + public bool? ShowBanner { get; set; } + public LogLevel LogLevel { get; set; } } } diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs index 2d4eaadb..f062be49 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs @@ -5,7 +5,7 @@ namespace TwitchDownloaderCLI.Modes.Arguments { [Verb("chatdownload", HelpText = "Downloads the chat from a VOD or clip")] - internal sealed class ChatDownloadArgs : TwitchDownloaderArgs + internal sealed class ChatDownloadArgs : IFileCollisionArgs, ITwitchDownloaderArgs { [Option('u', "id", Required = true, HelpText = "The ID or URL of the VOD or clip to download that chat of.")] public string Id { get; set; } @@ -45,5 +45,10 @@ internal sealed class ChatDownloadArgs : TwitchDownloaderArgs [Option("temp-path", Default = "", HelpText = "Path to temporary folder to use for cache.")] public string TempFolder { get; set; } + + // Interface args + public OverwriteBehavior OverwriteBehavior { get; set; } + public bool? ShowBanner { get; set; } + public LogLevel LogLevel { get; set; } } } \ No newline at end of file diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs index f8afe990..188554b8 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs @@ -4,7 +4,7 @@ namespace TwitchDownloaderCLI.Modes.Arguments { [Verb("chatrender", HelpText = "Renders a chat JSON as a video")] - internal sealed class ChatRenderArgs : TwitchDownloaderArgs + internal sealed class ChatRenderArgs : IFileCollisionArgs, ITwitchDownloaderArgs { [Option('i', "input", Required = true, HelpText = "Path to JSON chat file input.")] public string InputFile { get; set; } @@ -152,5 +152,10 @@ internal sealed class ChatRenderArgs : TwitchDownloaderArgs [Option("scale-highlight-indent", Default = 1.0, HelpText = "Number to scale highlight indent size (sub messages).")] public double ScaleAccentIndent { get; set; } + + // Interface args + public OverwriteBehavior OverwriteBehavior { get; set; } + public bool? ShowBanner { get; set; } + public LogLevel LogLevel { get; set; } } } \ No newline at end of file diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs index 44d242d9..5f5f9275 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs @@ -5,7 +5,7 @@ namespace TwitchDownloaderCLI.Modes.Arguments { [Verb("chatupdate", HelpText = "Updates the embedded emotes, badges, bits, and trims a chat JSON and/or converts a JSON chat to another format.")] - internal sealed class ChatUpdateArgs : TwitchDownloaderArgs + internal sealed class ChatUpdateArgs : IFileCollisionArgs, ITwitchDownloaderArgs { [Option('i', "input", Required = true, HelpText = "Path to input file. Valid extensions are: .json, .json.gz.")] public string InputFile { get; set; } @@ -42,5 +42,10 @@ internal sealed class ChatUpdateArgs : TwitchDownloaderArgs [Option("temp-path", Default = "", HelpText = "Path to temporary folder to use for cache.")] public string TempFolder { get; set; } + + // Interface args + public OverwriteBehavior OverwriteBehavior { get; set; } + public bool? ShowBanner { get; set; } + public LogLevel LogLevel { get; set; } } } diff --git a/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs index ebbdf978..1b901f60 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs @@ -1,9 +1,10 @@ using CommandLine; +using TwitchDownloaderCLI.Models; namespace TwitchDownloaderCLI.Modes.Arguments { [Verb("clipdownload", HelpText = "Downloads a clip from Twitch")] - internal sealed class ClipDownloadArgs : TwitchDownloaderArgs + internal sealed class ClipDownloadArgs : IFileCollisionArgs, ITwitchDownloaderArgs { [Option('u', "id", Required = true, HelpText = "The ID or URL of the clip to download.")] public string Id { get; set; } @@ -25,5 +26,10 @@ internal sealed class ClipDownloadArgs : TwitchDownloaderArgs [Option("temp-path", Default = "", HelpText = "Path to temporary caching folder.")] public string TempFolder { get; set; } + + // Interface args + public OverwriteBehavior OverwriteBehavior { get; set; } + public bool? ShowBanner { get; set; } + public LogLevel LogLevel { get; set; } } } \ No newline at end of file diff --git a/TwitchDownloaderCLI/Modes/Arguments/FfmpegArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/FfmpegArgs.cs index c3988428..2d2ff505 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/FfmpegArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/FfmpegArgs.cs @@ -1,11 +1,16 @@ using CommandLine; +using TwitchDownloaderCLI.Models; namespace TwitchDownloaderCLI.Modes.Arguments { [Verb("ffmpeg", HelpText = "Manage standalone ffmpeg")] - internal sealed class FfmpegArgs : TwitchDownloaderArgs + internal sealed class FfmpegArgs : ITwitchDownloaderArgs { [Option('d', "download", Default = false, Required = false, HelpText = "Downloads FFmpeg as a standalone file.")] public bool DownloadFfmpeg { get; set; } + + // Interface args + public bool? ShowBanner { get; set; } + public LogLevel LogLevel { get; set; } } } diff --git a/TwitchDownloaderCLI/Modes/Arguments/IFileCollisionArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/IFileCollisionArgs.cs new file mode 100644 index 00000000..9fa70774 --- /dev/null +++ b/TwitchDownloaderCLI/Modes/Arguments/IFileCollisionArgs.cs @@ -0,0 +1,11 @@ +using CommandLine; +using TwitchDownloaderCLI.Models; + +namespace TwitchDownloaderCLI.Modes.Arguments +{ + internal interface IFileCollisionArgs + { + [Option("collision", Default = OverwriteBehavior.Prompt, HelpText = "Sets the handling of output file name collisions. Valid values are: Overwrite, Exit, Rename, Prompt.")] + public OverwriteBehavior OverwriteBehavior { get; set; } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCLI/Modes/Arguments/TwitchDownloaderArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ITwitchDownloaderArgs.cs similarity index 91% rename from TwitchDownloaderCLI/Modes/Arguments/TwitchDownloaderArgs.cs rename to TwitchDownloaderCLI/Modes/Arguments/ITwitchDownloaderArgs.cs index 07945f8e..21b09a3d 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/TwitchDownloaderArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ITwitchDownloaderArgs.cs @@ -3,7 +3,7 @@ namespace TwitchDownloaderCLI.Modes.Arguments { - internal abstract class TwitchDownloaderArgs + internal interface ITwitchDownloaderArgs { [Option("banner", Default = true, HelpText = "Displays a banner containing version and copyright information.")] public bool? ShowBanner { get; set; } diff --git a/TwitchDownloaderCLI/Modes/Arguments/TsMergeArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/TsMergeArgs.cs index a19bdf0b..96270d75 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/TsMergeArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/TsMergeArgs.cs @@ -1,14 +1,20 @@ using CommandLine; +using TwitchDownloaderCLI.Models; namespace TwitchDownloaderCLI.Modes.Arguments { [Verb("tsmerge", HelpText = "Concatenates multiple .ts/.tsv/.tsa/.m2t/.m2ts (MPEG Transport Stream) files into a single file")] - internal sealed class TsMergeArgs : TwitchDownloaderArgs + internal sealed class TsMergeArgs : IFileCollisionArgs, ITwitchDownloaderArgs { [Option('i', "input", Required = true, HelpText = "Path a text file containing the absolute paths of the files to concatenate, separated by newlines. M3U/M3U8 is also supported.")] public string InputList { get; set; } [Option('o', "output", Required = true, HelpText = "Path to output file.")] public string OutputFile { get; set; } + + // Interface args + public OverwriteBehavior OverwriteBehavior { get; set; } + public bool? ShowBanner { get; set; } + public LogLevel LogLevel { get; set; } } } diff --git a/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs index d0b93b6c..2e2e6f0c 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs @@ -4,7 +4,7 @@ namespace TwitchDownloaderCLI.Modes.Arguments { [Verb("videodownload", HelpText = "Downloads a stream VOD from Twitch")] - internal sealed class VideoDownloadArgs : TwitchDownloaderArgs + internal sealed class VideoDownloadArgs : IFileCollisionArgs, ITwitchDownloaderArgs { [Option('u', "id", Required = true, HelpText = "The ID or URL of the VOD to download.")] public string Id { get; set; } @@ -35,5 +35,10 @@ internal sealed class VideoDownloadArgs : TwitchDownloaderArgs [Option("temp-path", Default = "", HelpText = "Path to temporary caching folder.")] public string TempFolder { get; set; } + + // Interface args + public OverwriteBehavior OverwriteBehavior { get; set; } + public bool? ShowBanner { get; set; } + public LogLevel LogLevel { get; set; } } } diff --git a/TwitchDownloaderCLI/Modes/DownloadChat.cs b/TwitchDownloaderCLI/Modes/DownloadChat.cs index f0eb9fb8..c799ee01 100644 --- a/TwitchDownloaderCLI/Modes/DownloadChat.cs +++ b/TwitchDownloaderCLI/Modes/DownloadChat.cs @@ -16,13 +16,14 @@ internal static void Download(ChatDownloadArgs inputOptions) { var progress = new CliTaskProgress(inputOptions.LogLevel); - var downloadOptions = GetDownloadOptions(inputOptions, progress); + var collisionHandler = new FileCollisionHandler(inputOptions); + var downloadOptions = GetDownloadOptions(inputOptions, collisionHandler, progress); var chatDownloader = new ChatDownloader(downloadOptions, progress); chatDownloader.DownloadAsync(CancellationToken.None).Wait(); } - private static ChatDownloadOptions GetDownloadOptions(ChatDownloadArgs inputOptions, ITaskLogger logger) + private static ChatDownloadOptions GetDownloadOptions(ChatDownloadArgs inputOptions, FileCollisionHandler collisionHandler, ITaskLogger logger) { if (inputOptions.Id is null) { @@ -64,7 +65,8 @@ private static ChatDownloadOptions GetDownloadOptions(ChatDownloadArgs inputOpti BttvEmotes = (bool)inputOptions.BttvEmotes!, FfzEmotes = (bool)inputOptions.FfzEmotes!, StvEmotes = (bool)inputOptions.StvEmotes!, - TempFolder = inputOptions.TempFolder + TempFolder = inputOptions.TempFolder, + FileCollisionCallback = collisionHandler.HandleCollisionCallback, }; return downloadOptions; diff --git a/TwitchDownloaderCLI/Modes/DownloadClip.cs b/TwitchDownloaderCLI/Modes/DownloadClip.cs index 8f2f3a28..04da80e7 100644 --- a/TwitchDownloaderCLI/Modes/DownloadClip.cs +++ b/TwitchDownloaderCLI/Modes/DownloadClip.cs @@ -21,13 +21,14 @@ internal static void Download(ClipDownloadArgs inputOptions) FfmpegHandler.DetectFfmpeg(inputOptions.FfmpegPath, progress); } - var downloadOptions = GetDownloadOptions(inputOptions, progress); + var collisionHandler = new FileCollisionHandler(inputOptions); + var downloadOptions = GetDownloadOptions(inputOptions, collisionHandler, progress); var clipDownloader = new ClipDownloader(downloadOptions, progress); clipDownloader.DownloadAsync(new CancellationToken()).Wait(); } - private static ClipDownloadOptions GetDownloadOptions(ClipDownloadArgs inputOptions, ITaskLogger logger) + private static ClipDownloadOptions GetDownloadOptions(ClipDownloadArgs inputOptions, FileCollisionHandler collisionHandler, ITaskLogger logger) { if (inputOptions.Id is null) { @@ -50,7 +51,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, - TempFolder = inputOptions.TempFolder + TempFolder = inputOptions.TempFolder, + FileCollisionCallback = collisionHandler.HandleCollisionCallback, }; return downloadOptions; diff --git a/TwitchDownloaderCLI/Modes/DownloadVideo.cs b/TwitchDownloaderCLI/Modes/DownloadVideo.cs index 9a9bc596..0e3bc722 100644 --- a/TwitchDownloaderCLI/Modes/DownloadVideo.cs +++ b/TwitchDownloaderCLI/Modes/DownloadVideo.cs @@ -19,13 +19,14 @@ internal static void Download(VideoDownloadArgs inputOptions) FfmpegHandler.DetectFfmpeg(inputOptions.FfmpegPath, progress); - var downloadOptions = GetDownloadOptions(inputOptions, progress); + var collisionHandler = new FileCollisionHandler(inputOptions); + var downloadOptions = GetDownloadOptions(inputOptions, collisionHandler, progress); var videoDownloader = new VideoDownloader(downloadOptions, progress); videoDownloader.DownloadAsync(new CancellationToken()).Wait(); } - private static VideoDownloadOptions GetDownloadOptions(VideoDownloadArgs inputOptions, ITaskLogger logger) + private static VideoDownloadOptions GetDownloadOptions(VideoDownloadArgs inputOptions, FileCollisionHandler collisionHandler, ITaskLogger logger) { if (inputOptions.Id is null) { @@ -76,7 +77,8 @@ private static VideoDownloadOptions GetDownloadOptions(VideoDownloadArgs inputOp "Run 'TwitchDownloaderCLI cache help' for more information."); return Array.Empty(); - } + }, + FileCollisionCallback = collisionHandler.HandleCollisionCallback, }; return downloadOptions; diff --git a/TwitchDownloaderCLI/Modes/MergeTs.cs b/TwitchDownloaderCLI/Modes/MergeTs.cs index 2f717304..24e1f502 100644 --- a/TwitchDownloaderCLI/Modes/MergeTs.cs +++ b/TwitchDownloaderCLI/Modes/MergeTs.cs @@ -14,18 +14,20 @@ internal static void Merge(TsMergeArgs inputOptions) progress.LogInfo("The TS merger is experimental and is subject to change without notice in future releases."); - var mergeOptions = GetMergeOptions(inputOptions); + var collisionHandler = new FileCollisionHandler(inputOptions); + var mergeOptions = GetMergeOptions(inputOptions, collisionHandler); var tsMerger = new TsMerger(mergeOptions, progress); tsMerger.MergeAsync(new CancellationToken()).Wait(); } - private static TsMergeOptions GetMergeOptions(TsMergeArgs inputOptions) + private static TsMergeOptions GetMergeOptions(TsMergeArgs inputOptions, FileCollisionHandler collisionHandler) { TsMergeOptions mergeOptions = new() { OutputFile = inputOptions.OutputFile, - InputFile = inputOptions.InputList + InputFile = inputOptions.InputList, + FileCollisionCallback = collisionHandler.HandleCollisionCallback, }; return mergeOptions; diff --git a/TwitchDownloaderCLI/Modes/RenderChat.cs b/TwitchDownloaderCLI/Modes/RenderChat.cs index 6ea11ac7..957f8878 100644 --- a/TwitchDownloaderCLI/Modes/RenderChat.cs +++ b/TwitchDownloaderCLI/Modes/RenderChat.cs @@ -19,13 +19,15 @@ internal static void Render(ChatRenderArgs inputOptions) FfmpegHandler.DetectFfmpeg(inputOptions.FfmpegPath, progress); - var renderOptions = GetRenderOptions(inputOptions, progress); + var collisionHandler = new FileCollisionHandler(inputOptions); + var renderOptions = GetRenderOptions(inputOptions, collisionHandler, progress); + using var chatRenderer = new ChatRenderer(renderOptions, progress); chatRenderer.ParseJsonAsync().Wait(); chatRenderer.RenderVideoAsync(new CancellationToken()).Wait(); } - private static ChatRenderOptions GetRenderOptions(ChatRenderArgs inputOptions, ITaskLogger logger) + private static ChatRenderOptions GetRenderOptions(ChatRenderArgs inputOptions, FileCollisionHandler collisionHandler, ITaskLogger logger) { ChatRenderOptions renderOptions = new() { @@ -93,6 +95,7 @@ private static ChatRenderOptions GetRenderOptions(ChatRenderArgs inputOptions, I DisperseCommentOffsets = inputOptions.DisperseCommentOffsets, AlternateMessageBackgrounds = inputOptions.AlternateMessageBackgrounds, AdjustUsernameVisibility = inputOptions.AdjustUsernameVisibility, + FileCollisionCallback = collisionHandler.HandleCollisionCallback, }; if (renderOptions.GenerateMask && renderOptions.BackgroundColor.Alpha == 255 && !(renderOptions.AlternateMessageBackgrounds! && renderOptions.AlternateBackgroundColor.Alpha != 255)) diff --git a/TwitchDownloaderCLI/Modes/UpdateChat.cs b/TwitchDownloaderCLI/Modes/UpdateChat.cs index 2dd7b0a0..dd17007d 100644 --- a/TwitchDownloaderCLI/Modes/UpdateChat.cs +++ b/TwitchDownloaderCLI/Modes/UpdateChat.cs @@ -16,14 +16,15 @@ internal static void Update(ChatUpdateArgs inputOptions) { var progress = new CliTaskProgress(inputOptions.LogLevel); - var updateOptions = GetUpdateOptions(inputOptions, progress); + var collisionHandler = new FileCollisionHandler(inputOptions); + var updateOptions = GetUpdateOptions(inputOptions, collisionHandler, progress); var chatUpdater = new ChatUpdater(updateOptions, progress); chatUpdater.ParseJsonAsync().Wait(); chatUpdater.UpdateAsync(new CancellationToken()).Wait(); } - private static ChatUpdateOptions GetUpdateOptions(ChatUpdateArgs inputOptions, ITaskLogger logger) + private static ChatUpdateOptions GetUpdateOptions(ChatUpdateArgs inputOptions, FileCollisionHandler collisionHandler, ITaskLogger logger) { if (!File.Exists(inputOptions.InputFile)) { @@ -76,7 +77,8 @@ private static ChatUpdateOptions GetUpdateOptions(ChatUpdateArgs inputOptions, I FfzEmotes = (bool)inputOptions.FfzEmotes!, StvEmotes = (bool)inputOptions.StvEmotes!, TextTimestampFormat = inputOptions.TimeFormat, - TempFolder = inputOptions.TempFolder + TempFolder = inputOptions.TempFolder, + FileCollisionCallback = collisionHandler.HandleCollisionCallback, }; return updateOptions; diff --git a/TwitchDownloaderCLI/Program.cs b/TwitchDownloaderCLI/Program.cs index 3149b62d..070da004 100644 --- a/TwitchDownloaderCLI/Program.cs +++ b/TwitchDownloaderCLI/Program.cs @@ -29,7 +29,7 @@ private static void Main(string[] args) parserResult.WithNotParsed(errors => WriteHelpText(errors, parserResult, parser.Settings)); CoreLicensor.EnsureFilesExist(AppContext.BaseDirectory); - WriteApplicationBanner((TwitchDownloaderArgs)parserResult.Value); + WriteApplicationBanner((ITwitchDownloaderArgs)parserResult.Value); parserResult .WithParsed(DownloadVideo.Download) @@ -74,7 +74,7 @@ private static void WriteHelpText(IEnumerable errors, ParserResult fileInfo, + OverwriteBehavior.Exit => null, + OverwriteBehavior.Rename => FilenameService.GetNonCollidingName(fileInfo), + OverwriteBehavior.Prompt => PromptUser(fileInfo), + _ => throw new ArgumentOutOfRangeException(nameof(_collisionArgs.OverwriteBehavior), _collisionArgs.OverwriteBehavior, null) + }; + } + + [return: MaybeNull] + private static FileInfo PromptUser(FileInfo fileInfo) + { + Console.WriteLine($"The file '{fileInfo.FullName}' already exists."); + + while (true) + { + Console.Write("[O] Overwrite / [R] Rename / [E] Exit: "); + var userInput = Console.ReadLine()!.Trim().ToLower(); + switch (userInput) + { + case "o" or "overwrite": + return fileInfo; + case "e" or "exit": + return null; + case "r" or "rename": + return FilenameService.GetNonCollidingName(fileInfo); + } + } + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore.Tests/ToolTests/FilenameServiceTests.cs b/TwitchDownloaderCore.Tests/ToolTests/FilenameServiceTests.cs index 18084153..94626cfe 100644 --- a/TwitchDownloaderCore.Tests/ToolTests/FilenameServiceTests.cs +++ b/TwitchDownloaderCore.Tests/ToolTests/FilenameServiceTests.cs @@ -151,5 +151,45 @@ public void DoesNotInterpretBogusTemplateParameter() Assert.Equal(EXPECTED, result); } + + [Fact] + public void GetNonCollidingNameWorks_WhenNoCollisionExists() + { + var expected = Path.Combine(Path.GetTempPath(), "foo.txt"); + var path = Path.Combine(Path.GetTempPath(), "foo.txt"); + var fileInfo = new FileInfo(path); + + try + { + var actual = FilenameService.GetNonCollidingName(fileInfo); + + Assert.Equal(expected, actual.FullName); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void GetNonCollidingNameWorks_WhenCollisionExists() + { + var expected = Path.Combine(Path.GetTempPath(), "foo (1).txt"); + var path = Path.Combine(Path.GetTempPath(), "foo.txt"); + var fileInfo = new FileInfo(path); + + try + { + fileInfo.Create().Close(); + + var actual = FilenameService.GetNonCollidingName(fileInfo); + + Assert.Equal(expected, actual.FullName); + } + finally + { + File.Delete(path); + } + } } } \ No newline at end of file diff --git a/TwitchDownloaderCore/Chat/ChatHtml.cs b/TwitchDownloaderCore/Chat/ChatHtml.cs index f00d51d0..96272477 100644 --- a/TwitchDownloaderCore/Chat/ChatHtml.cs +++ b/TwitchDownloaderCore/Chat/ChatHtml.cs @@ -18,10 +18,8 @@ public static class ChatHtml /// /// Serializes a chat Html file. /// - public static async Task SerializeAsync(string filePath, ChatRoot chatRoot, ITaskLogger logger, bool embedData = true, CancellationToken cancellationToken = default) + public static async Task SerializeAsync(FileStream fileStream, string filePath, ChatRoot chatRoot, ITaskLogger logger, bool embedData = true, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(filePath, nameof(filePath)); - Dictionary thirdEmoteData = new(); await BuildThirdPartyDictionary(chatRoot, embedData, thirdEmoteData, logger, cancellationToken); @@ -35,14 +33,7 @@ public static async Task SerializeAsync(string filePath, ChatRoot chatRoot, ITas using var templateStream = new MemoryStream(Properties.Resources.chat_template); using var templateReader = new StreamReader(templateStream); - var outputDirectory = Directory.GetParent(Path.GetFullPath(filePath))!; - if (!outputDirectory.Exists) - { - TwitchHelper.CreateDirectory(outputDirectory.FullName); - } - - await using var fs = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.Read); - await using var sw = new StreamWriter(fs); + await using var sw = new StreamWriter(fileStream); while (!templateReader.EndOfStream) { diff --git a/TwitchDownloaderCore/Chat/ChatJson.cs b/TwitchDownloaderCore/Chat/ChatJson.cs index af80a634..137f67b4 100644 --- a/TwitchDownloaderCore/Chat/ChatJson.cs +++ b/TwitchDownloaderCore/Chat/ChatJson.cs @@ -244,25 +244,16 @@ private static async Task UpgradeChatJson(ChatRoot chatRoot) /// /// Asynchronously serializes a chat json file. /// - public static async Task SerializeAsync(string filePath, ChatRoot chatRoot, ChatCompression compression, CancellationToken cancellationToken) + public static async Task SerializeAsync(FileStream fileStream, ChatRoot chatRoot, ChatCompression compression, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(chatRoot, nameof(chatRoot)); - - var outputDirectory = Directory.GetParent(Path.GetFullPath(filePath))!; - if (!outputDirectory.Exists) - { - TwitchHelper.CreateDirectory(outputDirectory.FullName); - } - - await using var fs = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.Read); switch (compression) { case ChatCompression.None: - await JsonSerializer.SerializeAsync(fs, chatRoot, _jsonSerializerOptions, cancellationToken); + await JsonSerializer.SerializeAsync(fileStream, chatRoot, _jsonSerializerOptions, cancellationToken); break; case ChatCompression.Gzip: { - await using var gs = new GZipStream(fs, CompressionLevel.SmallestSize); + await using var gs = new GZipStream(fileStream, CompressionLevel.SmallestSize); await JsonSerializer.SerializeAsync(gs, chatRoot, _jsonSerializerOptions, cancellationToken); break; } diff --git a/TwitchDownloaderCore/Chat/ChatText.cs b/TwitchDownloaderCore/Chat/ChatText.cs index 31930e81..9b6f66ad 100644 --- a/TwitchDownloaderCore/Chat/ChatText.cs +++ b/TwitchDownloaderCore/Chat/ChatText.cs @@ -11,17 +11,9 @@ public static class ChatText /// /// Serializes a chat plain text file. /// - public static async Task SerializeAsync(string filePath, ChatRoot chatRoot, TimestampFormat timeFormat) + public static async Task SerializeAsync(FileStream fileStream, ChatRoot chatRoot, TimestampFormat timeFormat) { - ArgumentNullException.ThrowIfNull(filePath, nameof(filePath)); - - var outputDirectory = Directory.GetParent(Path.GetFullPath(filePath))!; - if (!outputDirectory.Exists) - { - TwitchHelper.CreateDirectory(outputDirectory.FullName); - } - - await using var sw = new StreamWriter(filePath); + await using var sw = new StreamWriter(fileStream); foreach (var comment in chatRoot.comments) { var username = comment.commenter.display_name; diff --git a/TwitchDownloaderCore/ChatDownloader.cs b/TwitchDownloaderCore/ChatDownloader.cs index d2cffeb4..ef94a768 100644 --- a/TwitchDownloaderCore/ChatDownloader.cs +++ b/TwitchDownloaderCore/ChatDownloader.cs @@ -251,6 +251,12 @@ public async Task DownloadAsync(CancellationToken cancellationToken) throw new NullReferenceException("Null or empty video/clip ID"); } + var outputFileInfo = TwitchHelper.ClaimFile(downloadOptions.Filename, downloadOptions.FileCollisionCallback, _progress); + downloadOptions.Filename = 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); + DownloadType downloadType = downloadOptions.Id.All(char.IsDigit) ? DownloadType.Video : DownloadType.Clip; ChatRoot chatRoot = new() @@ -519,13 +525,13 @@ public async Task DownloadAsync(CancellationToken cancellationToken) switch (downloadOptions.DownloadFormat) { case ChatFormat.Json: - await ChatJson.SerializeAsync(downloadOptions.Filename, chatRoot, downloadOptions.Compression, cancellationToken); + await ChatJson.SerializeAsync(outputFs, chatRoot, downloadOptions.Compression, cancellationToken); break; case ChatFormat.Html: - await ChatHtml.SerializeAsync(downloadOptions.Filename, chatRoot, _progress, downloadOptions.EmbedData, cancellationToken); + await ChatHtml.SerializeAsync(outputFs, outputFileInfo.FullName, chatRoot, _progress, downloadOptions.EmbedData, cancellationToken); break; case ChatFormat.Text: - await ChatText.SerializeAsync(downloadOptions.Filename, chatRoot, downloadOptions.TimeFormat); + await ChatText.SerializeAsync(outputFs, chatRoot, downloadOptions.TimeFormat); break; default: throw new NotSupportedException($"{downloadOptions.DownloadFormat} is not a supported output format."); diff --git a/TwitchDownloaderCore/ChatRenderer.cs b/TwitchDownloaderCore/ChatRenderer.cs index 99850a26..62bb68f6 100644 --- a/TwitchDownloaderCore/ChatRenderer.cs +++ b/TwitchDownloaderCore/ChatRenderer.cs @@ -71,6 +71,14 @@ public ChatRenderer(ChatRenderOptions chatRenderOptions, ITaskProgress progress) public async Task RenderVideoAsync(CancellationToken cancellationToken) { + var outputFileInfo = TwitchHelper.ClaimFile(renderOptions.OutputFile, renderOptions.FileCollisionCallback, _progress); + renderOptions.OutputFile = outputFileInfo.FullName; + var maskFileInfo = renderOptions.GenerateMask ? TwitchHelper.ClaimFile(renderOptions.MaskFile, renderOptions.FileCollisionCallback, _progress) : null; + + // Open the destination files so that they exist in the filesystem. + await using var outputFs = outputFileInfo.Open(FileMode.Create, FileAccess.Write, FileShare.Read); + await using var maskFs = maskFileInfo?.Open(FileMode.Create, FileAccess.Write, FileShare.Read); + _progress.SetStatus("Fetching Images [1/2]"); await Task.Run(() => FetchScaledImages(cancellationToken), cancellationToken); @@ -107,20 +115,19 @@ public async Task RenderVideoAsync(CancellationToken cancellationToken) (int startTick, int totalTicks) = GetVideoTicks(); - var renderFileDirectory = Directory.GetParent(Path.GetFullPath(renderOptions.OutputFile))!; - if (!renderFileDirectory.Exists) - { - TwitchHelper.CreateDirectory(renderFileDirectory.FullName); - } - - if (File.Exists(renderOptions.OutputFile)) - File.Delete(renderOptions.OutputFile); + // Delete the files as it is not guaranteed that the overwrite flag is passed in the FFmpeg args. + outputFs.Close(); + outputFileInfo.Refresh(); + if (outputFileInfo.Exists) + outputFileInfo.Delete(); - if (renderOptions.GenerateMask && File.Exists(renderOptions.MaskFile)) - File.Delete(renderOptions.MaskFile); + maskFs?.Close(); + maskFileInfo?.Refresh(); + if (renderOptions.GenerateMask && maskFileInfo!.Exists) + maskFileInfo.Delete(); - FfmpegProcess ffmpegProcess = GetFfmpegProcess(0, false); - FfmpegProcess maskProcess = renderOptions.GenerateMask ? GetFfmpegProcess(0, true) : null; + FfmpegProcess ffmpegProcess = GetFfmpegProcess(outputFileInfo); + FfmpegProcess maskProcess = renderOptions.GenerateMask ? GetFfmpegProcess(maskFileInfo) : null; _progress.SetTemplateStatus(@"Rendering Video {0}% ({1:h\hm\ms\s} Elapsed | {2:h\hm\ms\s} Remaining)", 0, TimeSpan.Zero, TimeSpan.Zero); try @@ -323,22 +330,9 @@ private static void SetFrameMask(SKBitmap frame) } } - private FfmpegProcess GetFfmpegProcess(int partNumber, bool isMask) + private FfmpegProcess GetFfmpegProcess(FileInfo fileInfo) { - string savePath; - if (partNumber == 0) - { - if (isMask) - savePath = renderOptions.MaskFile; - else - savePath = renderOptions.OutputFile; - } - else - { - savePath = Path.Combine(renderOptions.TempFolder, Path.GetRandomFileName() + (isMask ? "_mask" : "") + Path.GetExtension(renderOptions.OutputFile)); - } - - savePath = Path.GetFullPath(savePath); + string savePath = fileInfo.FullName; string inputArgs = new StringBuilder(renderOptions.InputArgs) .Replace("{fps}", renderOptions.Framerate.ToString()) diff --git a/TwitchDownloaderCore/ChatUpdater.cs b/TwitchDownloaderCore/ChatUpdater.cs index 8e99366b..d2587d47 100644 --- a/TwitchDownloaderCore/ChatUpdater.cs +++ b/TwitchDownloaderCore/ChatUpdater.cs @@ -32,6 +32,12 @@ public ChatUpdater(ChatUpdateOptions updateOptions, ITaskProgress progress) public async Task UpdateAsync(CancellationToken cancellationToken) { + var outputFileInfo = TwitchHelper.ClaimFile(_updateOptions.OutputFile, _updateOptions.FileCollisionCallback, _progress); + _updateOptions.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); + chatRoot.FileInfo = new() { Version = ChatRootVersion.CurrentVersion, CreatedAt = chatRoot.FileInfo.CreatedAt, UpdatedAt = DateTime.Now }; if (!Path.GetExtension(_updateOptions.InputFile.Replace(".gz", ""))!.Equals(".json", StringComparison.OrdinalIgnoreCase)) { @@ -70,13 +76,13 @@ public async Task UpdateAsync(CancellationToken cancellationToken) switch (_updateOptions.OutputFormat) { case ChatFormat.Json: - await ChatJson.SerializeAsync(_updateOptions.OutputFile, chatRoot, _updateOptions.Compression, cancellationToken); + await ChatJson.SerializeAsync(outputFs, chatRoot, _updateOptions.Compression, cancellationToken); break; case ChatFormat.Html: - await ChatHtml.SerializeAsync(_updateOptions.OutputFile, chatRoot, _progress, chatRoot.embeddedData != null && (chatRoot.embeddedData.firstParty?.Count > 0 || chatRoot.embeddedData.twitchBadges?.Count > 0), cancellationToken); + await ChatHtml.SerializeAsync(outputFs, outputFileInfo.FullName, chatRoot, _progress, chatRoot.embeddedData != null && (chatRoot.embeddedData.firstParty?.Count > 0 || chatRoot.embeddedData.twitchBadges?.Count > 0), cancellationToken); break; // If there is embedded data, it's almost guaranteed to be first party emotes or badges. case ChatFormat.Text: - await ChatText.SerializeAsync(_updateOptions.OutputFile, chatRoot, _updateOptions.TextTimestampFormat); + await ChatText.SerializeAsync(outputFs, chatRoot, _updateOptions.TextTimestampFormat); break; default: throw new NotSupportedException($"{_updateOptions.OutputFormat} is not a supported output format."); diff --git a/TwitchDownloaderCore/ClipDownloader.cs b/TwitchDownloaderCore/ClipDownloader.cs index a6171eda..72755acc 100644 --- a/TwitchDownloaderCore/ClipDownloader.cs +++ b/TwitchDownloaderCore/ClipDownloader.cs @@ -31,6 +31,12 @@ public ClipDownloader(ClipDownloadOptions clipDownloadOptions, ITaskProgress pro public async Task DownloadAsync(CancellationToken cancellationToken) { + var outputFileInfo = TwitchHelper.ClaimFile(downloadOptions.Filename, downloadOptions.FileCollisionCallback, _progress); + downloadOptions.Filename = 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); + _progress.SetStatus("Fetching Clip Info"); var downloadUrl = await GetDownloadUrl(); @@ -38,23 +44,11 @@ public async Task DownloadAsync(CancellationToken cancellationToken) cancellationToken.ThrowIfCancellationRequested(); - var clipDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!; - if (!clipDirectory.Exists) - { - TwitchHelper.CreateDirectory(clipDirectory.FullName); - } - _progress.SetTemplateStatus("Downloading Clip {0}%", 0); - void DownloadProgressHandler(StreamCopyProgress streamProgress) - { - var percent = (int)(streamProgress.BytesCopied / (double)streamProgress.SourceLength * 100); - _progress.ReportProgress(percent); - } - if (!downloadOptions.EncodeMetadata) { - await DownloadFileTaskAsync(downloadUrl, downloadOptions.Filename, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken); + await DownloadFileTaskAsync(downloadUrl, outputFs, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken); return; } @@ -66,16 +60,22 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress) var tempFile = Path.Combine(downloadOptions.TempFolder, $"{downloadOptions.Id}_{DateTimeOffset.UtcNow.Ticks}.mp4"); try { - await DownloadFileTaskAsync(downloadUrl, tempFile, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken); + await using (var tempFileStream = File.Open(tempFile, FileMode.Create, FileAccess.Write, FileShare.Read)) + { + await DownloadFileTaskAsync(downloadUrl, tempFileStream, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken); + } + + outputFs.Close(); _progress.SetTemplateStatus("Encoding Clip Metadata {0}%", 0); var clipChapter = TwitchHelper.GenerateClipChapter(clipInfo.data.clip); - await EncodeClipWithMetadata(tempFile, downloadOptions.Filename, clipInfo.data.clip, clipChapter, cancellationToken); + await EncodeClipWithMetadata(tempFile, outputFileInfo.FullName, clipInfo.data.clip, clipChapter, cancellationToken); - if (!File.Exists(downloadOptions.Filename)) + outputFileInfo.Refresh(); + if (!outputFileInfo.Exists) { - File.Move(tempFile, downloadOptions.Filename); + File.Move(tempFile, outputFileInfo.FullName); _progress.LogError("Unable to serialize metadata. The download has been completed without custom metadata."); } @@ -85,6 +85,12 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress) { File.Delete(tempFile); } + + void DownloadProgressHandler(StreamCopyProgress streamProgress) + { + var percent = (int)(streamProgress.BytesCopied / (double)streamProgress.SourceLength * 100); + _progress.ReportProgress(percent); + } } private async Task GetDownloadUrl() @@ -120,7 +126,7 @@ private async Task GetDownloadUrl() return downloadUrl + "?sig=" + clip.playbackAccessToken.signature + "&token=" + HttpUtility.UrlEncode(clip.playbackAccessToken.value); } - private static async Task DownloadFileTaskAsync(string url, string destinationFile, int throttleKib, IProgress progress, CancellationToken cancellationToken) + private static async Task DownloadFileTaskAsync(string url, FileStream fs, int throttleKib, IProgress progress, CancellationToken cancellationToken) { var request = new HttpRequestMessage(HttpMethod.Get, url); using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); @@ -130,13 +136,11 @@ private static async Task DownloadFileTaskAsync(string url, string destinationFi if (throttleKib == -1) { - await using var fs = new FileStream(destinationFile, FileMode.Create, FileAccess.Write, FileShare.Read); await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); await contentStream.ProgressCopyToAsync(fs, contentLength, progress, cancellationToken).ConfigureAwait(false); } else { - await using var fs = new FileStream(destinationFile, FileMode.Create, FileAccess.Write, FileShare.Read); await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); await using var throttledStream = new ThrottledStream(contentStream, throttleKib); await throttledStream.ProgressCopyToAsync(fs, contentLength, progress, cancellationToken).ConfigureAwait(false); diff --git a/TwitchDownloaderCore/Options/ChatDownloadOptions.cs b/TwitchDownloaderCore/Options/ChatDownloadOptions.cs index bf6ee5ca..208db106 100644 --- a/TwitchDownloaderCore/Options/ChatDownloadOptions.cs +++ b/TwitchDownloaderCore/Options/ChatDownloadOptions.cs @@ -1,4 +1,6 @@ -using TwitchDownloaderCore.Tools; +using System; +using System.IO; +using TwitchDownloaderCore.Tools; namespace TwitchDownloaderCore.Options { @@ -34,5 +36,6 @@ public string FileExtension } } public string TempFolder { get; set; } + public Func FileCollisionCallback { get; set; } = info => info; } } diff --git a/TwitchDownloaderCore/Options/ChatRenderOptions.cs b/TwitchDownloaderCore/Options/ChatRenderOptions.cs index bdfc1883..eb1456c7 100644 --- a/TwitchDownloaderCore/Options/ChatRenderOptions.cs +++ b/TwitchDownloaderCore/Options/ChatRenderOptions.cs @@ -92,5 +92,6 @@ public string MaskFile public EmojiVendor EmojiVendor { get; set; } = EmojiVendor.GoogleNotoColor; public int[] TimestampWidths { get; set; } public bool AdjustUsernameVisibility { get; set; } + public Func FileCollisionCallback { get; set; } = info => info; } } diff --git a/TwitchDownloaderCore/Options/ChatUpdateOptions.cs b/TwitchDownloaderCore/Options/ChatUpdateOptions.cs index 13333990..c96932f9 100644 --- a/TwitchDownloaderCore/Options/ChatUpdateOptions.cs +++ b/TwitchDownloaderCore/Options/ChatUpdateOptions.cs @@ -1,4 +1,6 @@ -using TwitchDownloaderCore.Tools; +using System; +using System.IO; +using TwitchDownloaderCore.Tools; namespace TwitchDownloaderCore.Options { @@ -33,5 +35,6 @@ public string FileExtension } } public string TempFolder { get; set; } + public Func FileCollisionCallback { get; set; } = info => info; } } diff --git a/TwitchDownloaderCore/Options/ClipDownloadOptions.cs b/TwitchDownloaderCore/Options/ClipDownloadOptions.cs index 9f8c6ce9..f91542b3 100644 --- a/TwitchDownloaderCore/Options/ClipDownloadOptions.cs +++ b/TwitchDownloaderCore/Options/ClipDownloadOptions.cs @@ -1,4 +1,7 @@ -namespace TwitchDownloaderCore.Options +using System; +using System.IO; + +namespace TwitchDownloaderCore.Options { public class ClipDownloadOptions { @@ -9,5 +12,6 @@ public class ClipDownloadOptions public string TempFolder { get; set; } public bool EncodeMetadata { get; set; } public string FfmpegPath { get; set; } + public Func FileCollisionCallback { get; set; } = info => info; } } \ No newline at end of file diff --git a/TwitchDownloaderCore/Options/TsMergeOptions.cs b/TwitchDownloaderCore/Options/TsMergeOptions.cs index 8dd41e29..f73ba626 100644 --- a/TwitchDownloaderCore/Options/TsMergeOptions.cs +++ b/TwitchDownloaderCore/Options/TsMergeOptions.cs @@ -1,8 +1,12 @@ -namespace TwitchDownloaderCore.Options +using System; +using System.IO; + +namespace TwitchDownloaderCore.Options { public class TsMergeOptions { public string OutputFile { get; set; } public string InputFile { get; set; } + public Func FileCollisionCallback { get; set; } = info => info; } } diff --git a/TwitchDownloaderCore/Options/VideoDownloadOptions.cs b/TwitchDownloaderCore/Options/VideoDownloadOptions.cs index 19151a58..6f76881f 100644 --- a/TwitchDownloaderCore/Options/VideoDownloadOptions.cs +++ b/TwitchDownloaderCore/Options/VideoDownloadOptions.cs @@ -18,5 +18,6 @@ public class VideoDownloadOptions public string FfmpegPath { get; set; } public string TempFolder { get; set; } public Func CacheCleanerCallback { get; set; } + public Func FileCollisionCallback { get; set; } = info => info; } -} +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Tools/FilenameService.cs b/TwitchDownloaderCore/Tools/FilenameService.cs index 46dd0245..2f8a1c0e 100644 --- a/TwitchDownloaderCore/Tools/FilenameService.cs +++ b/TwitchDownloaderCore/Tools/FilenameService.cs @@ -86,5 +86,25 @@ private static string[] GetTemplateSubfolders(ref string fullPath) private static readonly char[] FilenameInvalidChars = Path.GetInvalidFileNameChars(); private static string RemoveInvalidFilenameChars(string filename) => filename.ReplaceAny(FilenameInvalidChars, '_'); + + public static FileInfo GetNonCollidingName(FileInfo fileInfo) + { + fileInfo.Refresh(); + var fi = fileInfo; + + var parentDir = Path.GetDirectoryName(fi.FullName)!; + var oldName = Path.GetFileNameWithoutExtension(fi.Name.AsSpan()); + var extension = Path.GetExtension(fi.Name.AsSpan()); + + var i = 1; + while (fi.Exists) + { + var newName = Path.Combine(parentDir, $"{oldName} ({i}){extension}"); + fi = new FileInfo(newName); + i++; + } + + return fi; + } } } \ No newline at end of file diff --git a/TwitchDownloaderCore/TsMerger.cs b/TwitchDownloaderCore/TsMerger.cs index d4310a55..8c44cdba 100644 --- a/TwitchDownloaderCore/TsMerger.cs +++ b/TwitchDownloaderCore/TsMerger.cs @@ -26,6 +26,12 @@ public async Task MergeAsync(CancellationToken cancellationToken) 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); + var isM3U8 = false; var isFirst = true; var fileList = new List(); @@ -52,7 +58,7 @@ public async Task MergeAsync(CancellationToken cancellationToken) _progress.SetTemplateStatus("Combining Parts {0}% [2/2]", 0); - await CombineVideoParts(fileList, cancellationToken); + await CombineVideoParts(fileList, outputFs, cancellationToken); _progress.ReportProgress(100); } @@ -109,7 +115,7 @@ private static async Task VerifyVideoPart(string filePath) return true; } - private async Task CombineVideoParts(IReadOnlyCollection fileList, CancellationToken cancellationToken) + private async Task CombineVideoParts(IReadOnlyCollection fileList, FileStream outputStream, CancellationToken cancellationToken) { DriveInfo outputDrive = DriveHelper.GetOutputDrive(mergeOptions.OutputFile); string outputFile = mergeOptions.OutputFile; @@ -117,7 +123,6 @@ private async Task CombineVideoParts(IReadOnlyCollection fileList, Cance int partCount = fileList.Count; int doneCount = 0; - await using var outputStream = new FileStream(outputFile, FileMode.Create, FileAccess.Write, FileShare.Read); foreach (var partFile in fileList) { await DriveHelper.WaitForDrive(outputDrive, _progress, cancellationToken); diff --git a/TwitchDownloaderCore/TwitchHelper.cs b/TwitchDownloaderCore/TwitchHelper.cs index 03dbbf81..06b1d5e4 100644 --- a/TwitchDownloaderCore/TwitchHelper.cs +++ b/TwitchDownloaderCore/TwitchHelper.cs @@ -1,7 +1,7 @@ using SkiaSharp; using System; -using System.Buffers; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Linq; @@ -854,6 +854,38 @@ where comments return returnList; } + public static FileInfo ClaimFile(string path, Func fileAlreadyExistsCallback, ITaskLogger logger) + { + var fileInfo = new FileInfo(path); + if (fileInfo.Exists) + { + if (fileAlreadyExistsCallback is null) + { + logger.LogWarning($"{nameof(fileAlreadyExistsCallback)} was null."); + } + else + { + fileInfo = fileAlreadyExistsCallback(fileInfo); + + if (fileInfo is null) + { + // I would prefer to not throw here, but the alternative is refactoring the task queue :/ + throw new FileNotFoundException("No destination file was provided, aborting."); + } + + logger.LogVerbose($"{path} will be renamed to {fileInfo.FullName}."); + } + } + + var directory = fileInfo.Directory; + if (directory is not null && !directory.Exists) + { + CreateDirectory(directory.FullName); + } + + return fileInfo; + } + public static DirectoryInfo CreateDirectory(string path) { DirectoryInfo directoryInfo = Directory.CreateDirectory(path); diff --git a/TwitchDownloaderCore/VideoDownloader.cs b/TwitchDownloaderCore/VideoDownloader.cs index 0262f5dc..5a109a3a 100644 --- a/TwitchDownloaderCore/VideoDownloader.cs +++ b/TwitchDownloaderCore/VideoDownloader.cs @@ -39,6 +39,12 @@ public VideoDownloader(VideoDownloadOptions videoDownloadOptions, ITaskProgress public async Task DownloadAsync(CancellationToken cancellationToken) { + var outputFileInfo = TwitchHelper.ClaimFile(downloadOptions.Filename, downloadOptions.FileCollisionCallback, _progress); + downloadOptions.Filename = 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); + await TwitchHelper.CleanupAbandonedVideoCaches(downloadOptions.TempFolder, downloadOptions.CacheCleanerCallback, _progress); string downloadFolder = Path.Combine( @@ -49,8 +55,6 @@ public async Task DownloadAsync(CancellationToken cancellationToken) try { - ServicePointManager.DefaultConnectionLimit = downloadOptions.DownloadThreads; - GqlVideoResponse videoInfoResponse = await TwitchHelper.GetVideoInfo(downloadOptions.Id); if (videoInfoResponse.data.video == null) { @@ -100,17 +104,13 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, d videoInfo.description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd(), downloadOptions.TrimBeginning ? downloadOptions.TrimBeginningTime : TimeSpan.Zero, videoChapterResponse.data.video.moments.edges, cancellationToken); - var finalizedFileDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!; - if (!finalizedFileDirectory.Exists) - { - TwitchHelper.CreateDirectory(finalizedFileDirectory.FullName); - } + outputFs.Close(); int ffmpegExitCode; var ffmpegRetries = 0; do { - ffmpegExitCode = await Task.Run(() => RunFfmpegVideoCopy(downloadFolder, metadataPath, startOffset, seekDuration > TimeSpan.Zero ? seekDuration : videoLength), cancellationToken); + ffmpegExitCode = await Task.Run(() => RunFfmpegVideoCopy(downloadFolder, outputFileInfo, metadataPath, startOffset, seekDuration > TimeSpan.Zero ? seekDuration : videoLength), cancellationToken); if (ffmpegExitCode != 0) { _progress.LogError($"Failed to finalize video (code {ffmpegExitCode}), retrying in 10 seconds..."); @@ -118,7 +118,8 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, d } } while (ffmpegExitCode != 0 && ffmpegRetries++ < 1); - if (ffmpegExitCode != 0 || !File.Exists(downloadOptions.Filename)) + outputFileInfo.Refresh(); + if (ffmpegExitCode != 0 || !outputFileInfo.Exists) { _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."); @@ -329,7 +330,7 @@ private static bool VerifyVideoPart(string filePath) return true; } - private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, TimeSpan startOffset, TimeSpan seekDuration) + private int RunFfmpegVideoCopy(string tempFolder, FileInfo outputFile, string metadataPath, TimeSpan startOffset, TimeSpan seekDuration) { var process = new Process { @@ -338,7 +339,7 @@ private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, TimeS FileName = downloadOptions.FfmpegPath, Arguments = string.Format( "-hide_banner -stats -y -avoid_negative_ts make_zero " + (downloadOptions.TrimBeginning ? "-ss {2} " : "") + "-i \"{0}\" -i \"{1}\" -map_metadata 1 -analyzeduration {3} -probesize {3} " + (downloadOptions.TrimEnding ? "-t {4} " : "") + "-c:v copy \"{5}\"", - Path.Combine(downloadFolder, "output.ts"), metadataPath, startOffset.TotalSeconds.ToString(CultureInfo.InvariantCulture), int.MaxValue, seekDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture), Path.GetFullPath(downloadOptions.Filename)), + Path.Combine(tempFolder, "output.ts"), metadataPath, startOffset.TotalSeconds.ToString(CultureInfo.InvariantCulture), int.MaxValue, seekDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture), outputFile.FullName), UseShellExecute = false, CreateNoWindow = true, RedirectStandardInput = false, @@ -363,7 +364,7 @@ private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, TimeS process.Start(); process.BeginErrorReadLine(); - using var logWriter = File.AppendText(Path.Combine(downloadFolder, "ffmpegLog.txt")); + using var logWriter = File.AppendText(Path.Combine(tempFolder, "ffmpegLog.txt")); do // We cannot handle logging inside the ErrorDataReceived lambda because more than 1 can come in at once and cause a race condition. lay295#598 { Thread.Sleep(100); diff --git a/TwitchDownloaderWPF/PageQueue.xaml.cs b/TwitchDownloaderWPF/PageQueue.xaml.cs index d44da4a0..5f1baf84 100644 --- a/TwitchDownloaderWPF/PageQueue.xaml.cs +++ b/TwitchDownloaderWPF/PageQueue.xaml.cs @@ -8,6 +8,7 @@ using TwitchDownloaderWPF.Properties; using System.Diagnostics; using System.IO; +using TwitchDownloaderWPF.Services; namespace TwitchDownloaderWPF { @@ -308,23 +309,7 @@ private void MenuItemOpenTaskFolder_Click(object sender, RoutedEventArgs e) return; } - var outputFolder = Path.GetDirectoryName(task.OutputFile); - if (!Directory.Exists(outputFolder)) - { - return; - } - - var args = File.Exists(task.OutputFile) - ? $"/select,\"{task.OutputFile}\"" - : $"\"{outputFolder}\""; - - Process.Start(new ProcessStartInfo - { - FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), - Arguments = args, - UseShellExecute = true, - WorkingDirectory = outputFolder - }); + FileService.OpenExplorerForFile(new FileInfo(task.OutputFile)); } } } diff --git a/TwitchDownloaderWPF/Services/FileCollisionService.cs b/TwitchDownloaderWPF/Services/FileCollisionService.cs new file mode 100644 index 00000000..f74924b3 --- /dev/null +++ b/TwitchDownloaderWPF/Services/FileCollisionService.cs @@ -0,0 +1,102 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Windows; +using Ookii.Dialogs.Wpf; +using TwitchDownloaderCore.Tools; + +namespace TwitchDownloaderWPF.Services +{ + public static class FileCollisionService + { + private enum CollisionCommand + { + Prompt, + Overwrite, + Rename, + Cancel + } + + private static CollisionCommand _collisionCommand = CollisionCommand.Prompt; + + [return: MaybeNull] + public static FileInfo HandleCollisionCallback(FileInfo fileInfo, Window owner) + { + if (_collisionCommand is not CollisionCommand.Prompt) + { + return GetResult(fileInfo, _collisionCommand); + } + + var result = ShowDialog(fileInfo, owner, out var rememberChoice); + + if (rememberChoice) + { + _collisionCommand = result; + } + + return GetResult(fileInfo, result); + } + + private static CollisionCommand ShowDialog(FileInfo fileInfo, Window owner, out bool rememberChoice) + { + using var dialog = new TaskDialog(); + dialog.WindowTitle = Translations.Strings.TitleFileAlreadyExists; + dialog.MainInstruction = string.Format(Translations.Strings.FileAlreadyExistsHeader, fileInfo.Name); + dialog.Content = string.Format(Translations.Strings.FileAlreadyExistsBody, $"{fileInfo.FullName}"); + dialog.MainIcon = TaskDialogIcon.Warning; + + dialog.EnableHyperlinks = true; + dialog.HyperlinkClicked += Hyperlink_OnClicked; + + dialog.ButtonStyle = TaskDialogButtonStyle.CommandLinks; + + var overwriteButton = new TaskDialogButton(Translations.Strings.FileAlreadyExistsOverwrite); + overwriteButton.CommandLinkNote = Translations.Strings.FileAlreadyExistsOverwriteDescription; + dialog.Buttons.Add(overwriteButton); + + var renameButton = new TaskDialogButton(Translations.Strings.FileAlreadyExistsRename); + renameButton.CommandLinkNote = Translations.Strings.FileAlreadyExistsRenameDescription; + dialog.Buttons.Add(renameButton); + + var cancelButton = new TaskDialogButton(Translations.Strings.FileAlreadyExistsCancel); + cancelButton.CommandLinkNote = Translations.Strings.FileAlreadyExistsCancelDescription; + dialog.Buttons.Add(cancelButton); + + dialog.VerificationText = Translations.Strings.FileAlreadyExistsRememberMyChoice; + dialog.IsVerificationChecked = false; + + var buttonResult = dialog.ShowDialog(owner); + + rememberChoice = dialog.IsVerificationChecked; + + if (buttonResult == overwriteButton) + return CollisionCommand.Overwrite; + + if (buttonResult == renameButton) + return CollisionCommand.Rename; + + if (buttonResult == cancelButton) + return CollisionCommand.Cancel; + + // This should never happen + throw new ArgumentOutOfRangeException(); + } + + [return: MaybeNull] + private static FileInfo GetResult(FileInfo fileInfo, CollisionCommand command) + { + return command switch + { + CollisionCommand.Overwrite => fileInfo, + CollisionCommand.Rename => FilenameService.GetNonCollidingName(fileInfo), + CollisionCommand.Cancel => null, + _ => throw new ArgumentOutOfRangeException(nameof(command), command, null) + }; + } + + private static void Hyperlink_OnClicked(object sender, HyperlinkClickedEventArgs e) + { + FileService.OpenExplorerForFile(new FileInfo(e.Href)); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderWPF/Services/FileService.cs b/TwitchDownloaderWPF/Services/FileService.cs new file mode 100644 index 00000000..829b661c --- /dev/null +++ b/TwitchDownloaderWPF/Services/FileService.cs @@ -0,0 +1,33 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.Versioning; + +namespace TwitchDownloaderWPF.Services +{ + public static class FileService + { + [SupportedOSPlatform("windows")] + public static void OpenExplorerForFile(FileInfo fileInfo) + { + var directoryInfo = fileInfo.Directory; + if (directoryInfo is null || !directoryInfo.Exists) + { + return; + } + + fileInfo.Refresh(); + var args = fileInfo.Exists + ? $"/select,\"{fileInfo.FullName}\"" + : $"\"{directoryInfo.FullName}\""; + + Process.Start(new ProcessStartInfo + { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), + Arguments = args, + UseShellExecute = true, + WorkingDirectory = directoryInfo.FullName + }); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderWPF/Translations/Strings.Designer.cs b/TwitchDownloaderWPF/Translations/Strings.Designer.cs index a61b3835..e777c05e 100644 --- a/TwitchDownloaderWPF/Translations/Strings.Designer.cs +++ b/TwitchDownloaderWPF/Translations/Strings.Designer.cs @@ -896,6 +896,87 @@ public static string FileAgeInDays { } } + /// + /// Looks up a localized string similar to The file {0} already exists. Do you want to overwrite it?. + /// + public static string FileAlreadyExistsBody { + get { + return ResourceManager.GetString("FileAlreadyExistsBody", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cancel. + /// + public static string FileAlreadyExistsCancel { + get { + return ResourceManager.GetString("FileAlreadyExistsCancel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The task will be canceled.. + /// + public static string FileAlreadyExistsCancelDescription { + get { + return ResourceManager.GetString("FileAlreadyExistsCancelDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} already exists.. + /// + public static string FileAlreadyExistsHeader { + get { + return ResourceManager.GetString("FileAlreadyExistsHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Overwrite. + /// + public static string FileAlreadyExistsOverwrite { + get { + return ResourceManager.GetString("FileAlreadyExistsOverwrite", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The existing file will be overwritten.. + /// + public static string FileAlreadyExistsOverwriteDescription { + get { + return ResourceManager.GetString("FileAlreadyExistsOverwriteDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remember my choice for this session. + /// + public static string FileAlreadyExistsRememberMyChoice { + get { + return ResourceManager.GetString("FileAlreadyExistsRememberMyChoice", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rename. + /// + public static string FileAlreadyExistsRename { + get { + return ResourceManager.GetString("FileAlreadyExistsRename", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The new file will be renamed.. + /// + public static string FileAlreadyExistsRenameDescription { + get { + return ResourceManager.GetString("FileAlreadyExistsRenameDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to {title} {id} {date} {channel} {date_custom=""} {random_string} {trim_start} {trim_end} {trim_start_custom=""} {trim_end_custom=""} {length} {length_custom=""} {views} {game}. /// @@ -1940,6 +2021,15 @@ public static string TitleEnqueueOptions { } } + /// + /// Looks up a localized string similar to File already exists. + /// + public static string TitleFileAlreadyExists { + get { + return ResourceManager.GetString("TitleFileAlreadyExists", resourceCulture); + } + } + /// /// Looks up a localized string similar to Global Settings. /// diff --git a/TwitchDownloaderWPF/Translations/Strings.es.resx b/TwitchDownloaderWPF/Translations/Strings.es.resx index 61564b5f..20651baa 100644 --- a/TwitchDownloaderWPF/Translations/Strings.es.resx +++ b/TwitchDownloaderWPF/Translations/Strings.es.resx @@ -898,4 +898,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + diff --git a/TwitchDownloaderWPF/Translations/Strings.fr.resx b/TwitchDownloaderWPF/Translations/Strings.fr.resx index 8477fd56..4387c5cf 100644 --- a/TwitchDownloaderWPF/Translations/Strings.fr.resx +++ b/TwitchDownloaderWPF/Translations/Strings.fr.resx @@ -897,4 +897,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + \ No newline at end of file diff --git a/TwitchDownloaderWPF/Translations/Strings.it.resx b/TwitchDownloaderWPF/Translations/Strings.it.resx index 6a8981aa..4eefecd3 100644 --- a/TwitchDownloaderWPF/Translations/Strings.it.resx +++ b/TwitchDownloaderWPF/Translations/Strings.it.resx @@ -898,4 +898,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + diff --git a/TwitchDownloaderWPF/Translations/Strings.ja.resx b/TwitchDownloaderWPF/Translations/Strings.ja.resx index 94d29be0..98320801 100644 --- a/TwitchDownloaderWPF/Translations/Strings.ja.resx +++ b/TwitchDownloaderWPF/Translations/Strings.ja.resx @@ -896,4 +896,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + \ No newline at end of file diff --git a/TwitchDownloaderWPF/Translations/Strings.pl.resx b/TwitchDownloaderWPF/Translations/Strings.pl.resx index 819bde7a..06119d9a 100644 --- a/TwitchDownloaderWPF/Translations/Strings.pl.resx +++ b/TwitchDownloaderWPF/Translations/Strings.pl.resx @@ -897,4 +897,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + \ No newline at end of file diff --git a/TwitchDownloaderWPF/Translations/Strings.pt-br.resx b/TwitchDownloaderWPF/Translations/Strings.pt-br.resx index 33577e25..1ee7a5f5 100644 --- a/TwitchDownloaderWPF/Translations/Strings.pt-br.resx +++ b/TwitchDownloaderWPF/Translations/Strings.pt-br.resx @@ -896,4 +896,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + \ No newline at end of file diff --git a/TwitchDownloaderWPF/Translations/Strings.resx b/TwitchDownloaderWPF/Translations/Strings.resx index c3507a15..cf47bc92 100644 --- a/TwitchDownloaderWPF/Translations/Strings.resx +++ b/TwitchDownloaderWPF/Translations/Strings.resx @@ -896,4 +896,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + \ No newline at end of file diff --git a/TwitchDownloaderWPF/Translations/Strings.ru.resx b/TwitchDownloaderWPF/Translations/Strings.ru.resx index 5b299529..1445a5e2 100644 --- a/TwitchDownloaderWPF/Translations/Strings.ru.resx +++ b/TwitchDownloaderWPF/Translations/Strings.ru.resx @@ -897,4 +897,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + \ No newline at end of file diff --git a/TwitchDownloaderWPF/Translations/Strings.tr.resx b/TwitchDownloaderWPF/Translations/Strings.tr.resx index 9476b7c7..694d1a60 100644 --- a/TwitchDownloaderWPF/Translations/Strings.tr.resx +++ b/TwitchDownloaderWPF/Translations/Strings.tr.resx @@ -898,4 +898,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + \ No newline at end of file diff --git a/TwitchDownloaderWPF/Translations/Strings.uk.resx b/TwitchDownloaderWPF/Translations/Strings.uk.resx index 9b16c853..7608071c 100644 --- a/TwitchDownloaderWPF/Translations/Strings.uk.resx +++ b/TwitchDownloaderWPF/Translations/Strings.uk.resx @@ -897,4 +897,34 @@ Others + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + diff --git a/TwitchDownloaderWPF/Translations/Strings.zh-cn.resx b/TwitchDownloaderWPF/Translations/Strings.zh-cn.resx index 47753794..2e7caaa8 100644 --- a/TwitchDownloaderWPF/Translations/Strings.zh-cn.resx +++ b/TwitchDownloaderWPF/Translations/Strings.zh-cn.resx @@ -899,4 +899,34 @@ 其他 + + File already exists + + + {0} already exists. + + + The file {0} already exists. Do you want to overwrite it? + + + Overwrite + + + The existing file will be overwritten. + + + Rename + + + The new file will be renamed. + + + Cancel + + + The task will be canceled. + + + Remember my choice for this session + diff --git a/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs b/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs index 19ed1a9b..366265e7 100644 --- a/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs +++ b/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs @@ -100,6 +100,11 @@ public WindowQueueOptions(List dataList) textFolder.Text = queueFolder; } + private FileInfo HandleFileCollisionCallback(FileInfo fileInfo) + { + return Dispatcher.Invoke(() => FileCollisionService.HandleCollisionCallback(fileInfo, Application.Current.MainWindow)); + } + private void btnQueue_Click(object sender, RoutedEventArgs e) { if (_parentPage != null) @@ -114,6 +119,8 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) } VideoDownloadOptions downloadOptions = vodDownloadPage.GetOptions(null, textFolder.Text); + downloadOptions.FileCollisionCallback = HandleFileCollisionCallback; + VodDownloadTask downloadTask = new VodDownloadTask { DownloadOptions = downloadOptions, @@ -142,6 +149,7 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) chatOptions.DownloadFormat = ChatFormat.Text; chatOptions.EmbedData = checkEmbed.IsChecked.GetValueOrDefault(); chatOptions.Filename = Path.Combine(folderPath, Path.GetFileNameWithoutExtension(downloadOptions.Filename) + "." + chatOptions.DownloadFormat); + chatOptions.FileCollisionCallback = HandleFileCollisionCallback; if (downloadOptions.TrimBeginning) { @@ -180,6 +188,7 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) renderOptions.OutputFile = Path.ChangeExtension(chatOptions.Filename.Replace(".gz", ""), " - CHAT." + MainWindow.pageChatRender.comboFormat.Text.ToLower()); } renderOptions.InputFile = chatOptions.Filename; + renderOptions.FileCollisionCallback = HandleFileCollisionCallback; ChatRenderTask renderTask = new ChatRenderTask { @@ -224,7 +233,8 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) : -1, TempFolder = Settings.Default.TempPath, EncodeMetadata = clipDownloadPage.CheckMetadata.IsChecked!.Value, - FfmpegPath = "ffmpeg" + FfmpegPath = "ffmpeg", + FileCollisionCallback = HandleFileCollisionCallback, }; ClipDownloadTask downloadTask = new ClipDownloadTask @@ -258,6 +268,7 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) chatOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateChat, downloadTask.Info.Title, chatOptions.Id, clipDownloadPage.currentVideoTime, clipDownloadPage.textStreamer.Text, TimeSpan.Zero, clipDownloadPage.clipLength, clipDownloadPage.viewCount.ToString(), clipDownloadPage.game) + "." + chatOptions.FileExtension); + chatOptions.FileCollisionCallback = HandleFileCollisionCallback; ChatDownloadTask chatTask = new ChatDownloadTask { @@ -284,6 +295,7 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) renderOptions.OutputFile = Path.ChangeExtension(chatOptions.Filename.Replace(".gz", ""), " - CHAT." + MainWindow.pageChatRender.comboFormat.Text.ToLower()); } renderOptions.InputFile = chatOptions.Filename; + renderOptions.FileCollisionCallback = HandleFileCollisionCallback; ChatRenderTask renderTask = new ChatRenderTask { @@ -322,6 +334,7 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) chatOptions.TrimBeginning ? TimeSpan.FromSeconds(chatOptions.TrimBeginningTime) : TimeSpan.Zero, chatOptions.TrimEnding ? TimeSpan.FromSeconds(chatOptions.TrimEndingTime) : chatDownloadPage.vodLength, chatDownloadPage.viewCount.ToString(), chatDownloadPage.game) + "." + chatOptions.FileExtension); + chatOptions.FileCollisionCallback = HandleFileCollisionCallback; ChatDownloadTask chatTask = new ChatDownloadTask { @@ -343,6 +356,7 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) { ChatRenderOptions renderOptions = MainWindow.pageChatRender.GetOptions(Path.ChangeExtension(chatOptions.Filename.Replace(".gz", ""), '.' + MainWindow.pageChatRender.comboFormat.Text.ToLower())); renderOptions.InputFile = chatOptions.Filename; + renderOptions.FileCollisionCallback = HandleFileCollisionCallback; ChatRenderTask renderTask = new ChatRenderTask { @@ -380,6 +394,7 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) chatOptions.TrimBeginning ? TimeSpan.FromSeconds(chatOptions.TrimBeginningTime) : TimeSpan.Zero, chatOptions.TrimEnding ? TimeSpan.FromSeconds(chatOptions.TrimEndingTime) : chatUpdatePage.VideoLength, chatUpdatePage.ViewCount.ToString(), chatUpdatePage.Game) + "." + chatOptions.FileExtension); + chatOptions.FileCollisionCallback = HandleFileCollisionCallback; ChatUpdateTask chatTask = new ChatUpdateTask { @@ -415,6 +430,8 @@ private void btnQueue_Click(object sender, RoutedEventArgs e) string filePath = Path.Combine(folderPath, Path.GetFileNameWithoutExtension(fileName) + "." + fileFormat.ToLower()); ChatRenderOptions renderOptions = MainWindow.pageChatRender.GetOptions(filePath); renderOptions.InputFile = fileName; + renderOptions.FileCollisionCallback = HandleFileCollisionCallback; + ChatRenderTask renderTask = new ChatRenderTask { DownloadOptions = renderOptions, @@ -472,7 +489,8 @@ private void EnqueueDataList() DownloadThreads = Settings.Default.VodDownloadThreads, ThrottleKib = Settings.Default.DownloadThrottleEnabled ? Settings.Default.MaximumBandwidthKib - : -1 + : -1, + FileCollisionCallback = HandleFileCollisionCallback, }; downloadOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateVod, taskData.Title, taskData.Id, taskData.Time, taskData.Streamer, downloadOptions.TrimBeginning ? downloadOptions.TrimBeginningTime : TimeSpan.Zero, @@ -508,7 +526,8 @@ private void EnqueueDataList() : -1, TempFolder = Settings.Default.TempPath, EncodeMetadata = Settings.Default.EncodeClipMetadata, - FfmpegPath = "ffmpeg" + FfmpegPath = "ffmpeg", + FileCollisionCallback = HandleFileCollisionCallback, }; ClipDownloadTask downloadTask = new ClipDownloadTask @@ -538,7 +557,8 @@ private void EnqueueDataList() TimeFormat = TimestampFormat.Relative, Id = taskData.Id, TrimBeginning = false, - TrimEnding = false + TrimEnding = false, + FileCollisionCallback = HandleFileCollisionCallback, }; if (radioJson.IsChecked == true) downloadOptions.DownloadFormat = ChatFormat.Json; @@ -577,6 +597,7 @@ private void EnqueueDataList() renderOptions.OutputFile = Path.ChangeExtension(downloadOptions.Filename.Replace(".gz", ""), " - CHAT." + MainWindow.pageChatRender.comboFormat.Text.ToLower()); } renderOptions.InputFile = downloadOptions.Filename; + renderOptions.FileCollisionCallback = HandleFileCollisionCallback; ChatRenderTask renderTask = new ChatRenderTask {