From 6813d05d9707a9f0f31f488c47bca2c1e5419e4b Mon Sep 17 00:00:00 2001 From: Chris Peterson Date: Sun, 22 Feb 2026 14:06:35 -0800 Subject: [PATCH] Add preview mode, error diagnostics, auto-detection, and concurrent encoding - --preview: show rip plan table and confirm before proceeding - --error prompt|ignore: retry/continue/abort on failures (default: prompt) - Error prompts now display stderr details and log file paths for both ffmpeg and MakeMKV failures (previously just "encoding failed") - ffmpeg stderr captured to log files in temp directory for post-mortem - EncodeAsync returns EncodeOutcome (exit code, error lines, log path) instead of bare bool - TV mode auto-detects season from output directory or disc name - TV mode auto-detects episode start from existing files in output dir - --concurrency N: run multiple encode workers in parallel (1-8) - Extract TitlePlan to shared model --- .claude/settings.json | 7 + src/RipSharp.Tests/Core/RipOptionsTests.cs | 188 ++++- .../MakeMkv/MakeMkvServiceTests.cs | 61 ++ .../Models/ProcessResultTests.cs | 57 ++ src/RipSharp.Tests/Models/TitlePlanTests.cs | 28 + .../DiscRipperOverallProgressTests.cs | 20 +- .../Services/DiscRipperTitleSuffixTests.cs | 45 +- .../Services/EpisodeAutoDetectTests.cs | 169 ++++ .../Services/SeasonAutoDetectTests.cs | 108 +++ .../Utilities/SpectreProgressDisplayTests.cs | 70 ++ src/RipSharp/Abstractions/IDiscRipper.cs | 4 +- src/RipSharp/Abstractions/IEncoderService.cs | 4 +- src/RipSharp/Abstractions/IMakeMkvService.cs | 4 +- src/RipSharp/Abstractions/IProgressDisplay.cs | 28 + src/RipSharp/Abstractions/IUserPrompt.cs | 35 + src/RipSharp/Core/Program.cs | 13 +- src/RipSharp/Core/RipOptions.cs | 47 +- src/RipSharp/MakeMkv/MakeMkvService.cs | 17 +- src/RipSharp/Metadata/MetadataService.cs | 6 +- src/RipSharp/Models/ContentMetadata.cs | 4 + src/RipSharp/Models/DiscProcessingResult.cs | 10 + src/RipSharp/Models/ProcessResult.cs | 43 + src/RipSharp/Models/TitleOutcome.cs | 19 + src/RipSharp/Models/TitlePlan.cs | 12 + src/RipSharp/Services/DiscRipper.cs | 794 +++++++++++++----- src/RipSharp/Services/EncoderService.cs | 233 ++++- src/RipSharp/Services/ProcessRunner.cs | 19 +- src/RipSharp/Utilities/ConsoleUserPrompt.cs | 365 +++++++- .../Utilities/SpectreProgressDisplay.cs | 157 +++- 29 files changed, 2215 insertions(+), 352 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/RipSharp.Tests/MakeMkv/MakeMkvServiceTests.cs create mode 100644 src/RipSharp.Tests/Models/ProcessResultTests.cs create mode 100644 src/RipSharp.Tests/Models/TitlePlanTests.cs create mode 100644 src/RipSharp.Tests/Services/EpisodeAutoDetectTests.cs create mode 100644 src/RipSharp.Tests/Services/SeasonAutoDetectTests.cs create mode 100644 src/RipSharp/Models/DiscProcessingResult.cs create mode 100644 src/RipSharp/Models/ProcessResult.cs create mode 100644 src/RipSharp/Models/TitleOutcome.cs create mode 100644 src/RipSharp/Models/TitlePlan.cs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..296e592 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test:*)" + ] + } +} diff --git a/src/RipSharp.Tests/Core/RipOptionsTests.cs b/src/RipSharp.Tests/Core/RipOptionsTests.cs index 52c2e20..acb9552 100644 --- a/src/RipSharp.Tests/Core/RipOptionsTests.cs +++ b/src/RipSharp.Tests/Core/RipOptionsTests.cs @@ -441,23 +441,23 @@ public void ParseArgs_WithSeason_ParsesSeasonCorrectly() } [Fact] - public void ParseArgs_WithInvalidSeason_KeepsDefault() + public void ParseArgs_WithInvalidSeason_RemainsNull() { var args = new[] { "--output", "/tmp/movies", "--season", "not-a-number" }; var result = RipOptions.ParseArgs(args); - result.Season.Should().Be(1); + result.Season.Should().BeNull(); } [Fact] - public void ParseArgs_WithoutSeason_DefaultsTo1() + public void ParseArgs_WithoutSeason_DefaultsToNull() { var args = new[] { "--output", "/tmp/movies" }; var result = RipOptions.ParseArgs(args); - result.Season.Should().Be(1); + result.Season.Should().BeNull(); } [Fact] @@ -471,23 +471,23 @@ public void ParseArgs_WithEpisodeStart_ParsesCorrectly() } [Fact] - public void ParseArgs_WithInvalidEpisodeStart_KeepsDefault() + public void ParseArgs_WithInvalidEpisodeStart_RemainsNull() { var args = new[] { "--output", "/tmp/movies", "--episode-start", "invalid" }; var result = RipOptions.ParseArgs(args); - result.EpisodeStart.Should().Be(1); + result.EpisodeStart.Should().BeNull(); } [Fact] - public void ParseArgs_WithoutEpisodeStart_DefaultsTo1() + public void ParseArgs_WithoutEpisodeStart_DefaultsToNull() { var args = new[] { "--output", "/tmp/movies" }; var result = RipOptions.ParseArgs(args); - result.EpisodeStart.Should().Be(1); + result.EpisodeStart.Should().BeNull(); } [Fact] @@ -632,7 +632,7 @@ public void ParseArgs_TvScenario_ParsesCorrectly() result.Tv.Should().BeTrue(); result.Title.Should().Be("Breaking Bad"); result.Season.Should().Be(1); - result.EpisodeStart.Should().Be(1); + result.EpisodeStart.Should().BeNull(); } [Fact] @@ -654,4 +654,174 @@ public void ParseArgs_WithSequentialFlag_DisablesParallelProcessing() result.EnableParallelProcessing.Should().BeFalse(); } + + [Fact] + public void ParseArgs_WithPreviewFlag_SetsPreviewTrue() + { + var args = new[] { "--output", "/tmp/movies", "--preview" }; + + var result = RipOptions.ParseArgs(args); + + result.Preview.Should().BeTrue(); + } + + [Fact] + public void ParseArgs_WithoutPreviewFlag_DefaultsToFalse() + { + var args = new[] { "--output", "/tmp/movies" }; + + var result = RipOptions.ParseArgs(args); + + result.Preview.Should().BeFalse(); + } + + [Fact] + public void ParseArgs_WithErrorIgnore_SetsErrorModeIgnore() + { + var args = new[] { "--output", "/tmp/movies", "--error", "ignore" }; + + var result = RipOptions.ParseArgs(args); + + result.ErrorMode.Should().Be(ErrorMode.Ignore); + } + + [Fact] + public void ParseArgs_WithErrorPrompt_SetsErrorModePrompt() + { + var args = new[] { "--output", "/tmp/movies", "--error", "prompt" }; + + var result = RipOptions.ParseArgs(args); + + result.ErrorMode.Should().Be(ErrorMode.Prompt); + } + + [Fact] + public void ParseArgs_WithoutErrorFlag_DefaultsToPrompt() + { + var args = new[] { "--output", "/tmp/movies" }; + + var result = RipOptions.ParseArgs(args); + + result.ErrorMode.Should().Be(ErrorMode.Prompt); + } + + [Fact] + public void ParseArgs_WithInvalidErrorMode_ThrowsArgumentException() + { + var args = new[] { "--output", "/tmp/movies", "--error", "invalid" }; + + Action act = () => RipOptions.ParseArgs(args); + + act.Should().Throw().WithMessage("--error must be 'prompt' or 'ignore'"); + } + + [Fact] + public void ParseArgs_WithConcurrency_SetsConcurrencyValue() + { + var args = new[] { "--output", "/tmp/movies", "--concurrency", "4" }; + + var result = RipOptions.ParseArgs(args); + + result.Concurrency.Should().Be(4); + } + + [Fact] + public void ParseArgs_WithoutConcurrency_DefaultsTo1() + { + var args = new[] { "--output", "/tmp/movies" }; + + var result = RipOptions.ParseArgs(args); + + result.Concurrency.Should().Be(1); + } + + [Fact] + public void ParseArgs_WithConcurrencyTooHigh_ThrowsArgumentException() + { + var args = new[] { "--output", "/tmp/movies", "--concurrency", "9" }; + + Action act = () => RipOptions.ParseArgs(args); + + act.Should().Throw().WithMessage("--concurrency must be between 1 and 8"); + } + + [Fact] + public void ParseArgs_WithConcurrencyTooLow_ThrowsArgumentException() + { + var args = new[] { "--output", "/tmp/movies", "--concurrency", "0" }; + + Action act = () => RipOptions.ParseArgs(args); + + act.Should().Throw().WithMessage("--concurrency must be between 1 and 8"); + } + + [Fact] + public void ParseArgs_WithEpisodeStart_SetsValue() + { + var args = new[] { "--output", "/tmp/movies", "--episode-start", "5" }; + + var result = RipOptions.ParseArgs(args); + + result.EpisodeStart.Should().Be(5); + } + + [Fact] + public void ParseArgs_WithoutEpisodeStart_IsNull() + { + var args = new[] { "--output", "/tmp/movies" }; + + var result = RipOptions.ParseArgs(args); + + result.EpisodeStart.Should().BeNull(); + } + + [Fact] + public void ParseArgs_WithConcurrencyMin_Succeeds() + { + var args = new[] { "--output", "/tmp/movies", "--concurrency", "1" }; + + var result = RipOptions.ParseArgs(args); + + result.Concurrency.Should().Be(1); + } + + [Fact] + public void ParseArgs_WithConcurrencyMax_Succeeds() + { + var args = new[] { "--output", "/tmp/movies", "--concurrency", "8" }; + + var result = RipOptions.ParseArgs(args); + + result.Concurrency.Should().Be(8); + } + + [Fact] + public void ParseArgs_WithConcurrencyNonNumeric_KeepsDefault() + { + var args = new[] { "--output", "/tmp/movies", "--concurrency", "abc" }; + + var result = RipOptions.ParseArgs(args); + + result.Concurrency.Should().Be(1); + } + + [Fact] + public void ParseArgs_WithErrorIgnoreUpperCase_IsCaseInsensitive() + { + var args = new[] { "--output", "/tmp/movies", "--error", "IGNORE" }; + + var result = RipOptions.ParseArgs(args); + + result.ErrorMode.Should().Be(ErrorMode.Ignore); + } + + [Fact] + public void ParseArgs_WithErrorMissingValue_ThrowsArgumentException() + { + var args = new[] { "--output", "/tmp/movies", "--error" }; + + Action act = () => RipOptions.ParseArgs(args); + + act.Should().Throw().WithMessage("--error must be 'prompt' or 'ignore'"); + } } diff --git a/src/RipSharp.Tests/MakeMkv/MakeMkvServiceTests.cs b/src/RipSharp.Tests/MakeMkv/MakeMkvServiceTests.cs new file mode 100644 index 0000000..c5ca453 --- /dev/null +++ b/src/RipSharp.Tests/MakeMkv/MakeMkvServiceTests.cs @@ -0,0 +1,61 @@ +namespace RipSharp.Tests.MakeMkv; + +public class MakeMkvServiceTests +{ + [Fact] + public async Task RipTitleAsync_FiltersProgressLines_FromCallbackAndErrorSummary() + { + var runner = Substitute.For(); + runner.RunAsync( + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any?>(), + Arg.Any()) + .Returns(callInfo => + { + var onError = callInfo.ArgAt?>(3); + onError?.Invoke("PRGV:100,200,300"); + onError?.Invoke("PRGC:1,2,3"); + onError?.Invoke("real error line"); + return Task.FromResult(2); + }); + + var service = new MakeMkvService(runner); + var callbackLines = new List(); + + var result = await service.RipTitleAsync("disc:0", 7, "/tmp/rips", onError: callbackLines.Add); + + result.Success.Should().BeFalse(); + result.ExitCode.Should().Be(2); + result.ErrorLines.Should().BeEquivalentTo(["real error line"]); + callbackLines.Should().BeEquivalentTo(["real error line"]); + } + + [Fact] + public async Task RipTitleAsync_PassesExpectedCommandAndArguments_ToRunner() + { + var runner = Substitute.For(); + runner.RunAsync( + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any?>(), + Arg.Any()) + .Returns(Task.FromResult(0)); + + var service = new MakeMkvService(runner); + + var result = await service.RipTitleAsync("disc:1", 3, "/tmp/output"); + + await runner.Received(1).RunAsync( + "makemkvcon", + "-r --robot mkv disc:1 3 \"/tmp/output\"", + Arg.Any?>(), + Arg.Any?>(), + Arg.Any()); + + result.Success.Should().BeTrue(); + result.Command.Should().Be("makemkvcon -r --robot mkv disc:1 3 \"/tmp/output\""); + } +} diff --git a/src/RipSharp.Tests/Models/ProcessResultTests.cs b/src/RipSharp.Tests/Models/ProcessResultTests.cs new file mode 100644 index 0000000..5a676d8 --- /dev/null +++ b/src/RipSharp.Tests/Models/ProcessResultTests.cs @@ -0,0 +1,57 @@ +namespace RipSharp.Tests.Models; + +public class ProcessResultTests +{ + [Fact] + public void ErrorSummary_WithErrorLines_IncludesExitCodeAndLastTenLines() + { + var lines = Enumerable.Range(1, 12).Select(i => $"Error {i}").ToList(); + var result = new ProcessResult(false, 2, lines); + + result.ErrorSummary.Should().Contain("exited with code 2"); + result.ErrorSummary.Should().Contain("Error 3"); + result.ErrorSummary.Should().Contain("Error 12"); + result.ErrorSummary.Should().NotContain("Error 2" + Environment.NewLine); + } + + [Fact] + public void ErrorSummary_WithoutErrorLines_UsesDefaultProcessName() + { + var result = new ProcessResult(false, 42, Array.Empty()); + + result.ErrorSummary.Should().Contain("Process exited with code 42"); + result.ErrorSummary.Should().Contain("No error details captured"); + } + + [Fact] + public void ErrorSummary_WithoutErrorLines_UsesFirstWordFromCommand() + { + var result = new ProcessResult(false, 5, Array.Empty(), "ffmpeg -i input.mkv output.mp4"); + + result.ErrorSummary.Should().Contain("ffmpeg exited with code 5"); + } + + [Fact] + public void ErrorSummary_WithoutErrorLines_UsesQuotedFirstWordFromCommand() + { + var result = new ProcessResult(false, 7, Array.Empty(), "\"/usr/local/bin/ffmpeg tool\" -i input.mkv output.mp4"); + + result.ErrorSummary.Should().Contain("/usr/local/bin/ffmpeg tool exited with code 7"); + } + + [Fact] + public void ErrorSummary_WithoutErrorLines_IgnoresLeadingWhitespace() + { + var result = new ProcessResult(false, 9, Array.Empty(), " ffprobe -v error file.mkv"); + + result.ErrorSummary.Should().Contain("ffprobe exited with code 9"); + } + + [Fact] + public void ErrorSummary_IncludesLogPath_WhenPresent() + { + var result = new ProcessResult(false, 1, Array.Empty(), "ffmpeg", "/tmp/ffmpeg.log"); + + result.ErrorSummary.Should().Contain("log: /tmp/ffmpeg.log"); + } +} diff --git a/src/RipSharp.Tests/Models/TitlePlanTests.cs b/src/RipSharp.Tests/Models/TitlePlanTests.cs new file mode 100644 index 0000000..e6ffdd2 --- /dev/null +++ b/src/RipSharp.Tests/Models/TitlePlanTests.cs @@ -0,0 +1,28 @@ +namespace RipSharp.Tests.Models; + +public class TitlePlanTests +{ + [Fact] + public void TitlePlan_DurationSeconds_DefaultsToZero() + { + var plan = new TitlePlan( + TitleId: 1, Index: 0, EpisodeNum: null, EpisodeTitle: null, + TempOutputPath: "/tmp/out.mkv", FinalFileName: "Movie.mkv", + VersionSuffix: null, DisplayName: "Movie"); + + plan.DurationSeconds.Should().Be(0); + } + + [Fact] + public void TitlePlan_WithDurationSeconds_StoresValue() + { + var plan = new TitlePlan( + TitleId: 1, Index: 0, EpisodeNum: 3, EpisodeTitle: "Pilot", + TempOutputPath: "/tmp/out.mkv", FinalFileName: "Show - S01E03.mkv", + VersionSuffix: null, DisplayName: "Show S01E03", DurationSeconds: 2700); + + plan.DurationSeconds.Should().Be(2700); + plan.EpisodeNum.Should().Be(3); + plan.EpisodeTitle.Should().Be("Pilot"); + } +} diff --git a/src/RipSharp.Tests/Services/DiscRipperOverallProgressTests.cs b/src/RipSharp.Tests/Services/DiscRipperOverallProgressTests.cs index 8c142b9..98611ee 100644 --- a/src/RipSharp.Tests/Services/DiscRipperOverallProgressTests.cs +++ b/src/RipSharp.Tests/Services/DiscRipperOverallProgressTests.cs @@ -16,18 +16,6 @@ public void OverallProgressTracker_UpdatesValueForRipAndEncode() task.Value.Should().Be(2); } - [Fact] - public void OverallProgressTracker_MarkAllComplete_SetsMaxValue() - { - var task = new TestProgressTask(maxValue: 4); - var tracker = CreateTracker(task); - - Invoke(tracker, "MarkRipComplete"); - Invoke(tracker, "MarkAllComplete"); - - task.Value.Should().Be(task.MaxValue); - } - private static object CreateTracker(IProgressTask task) { var trackerType = typeof(DiscRipper) @@ -70,16 +58,24 @@ public long Value public bool IsStopped => false; + public bool IsFailed => false; + public TimeSpan GetElapsed() => TimeSpan.Zero; public void Increment(long value) => _value += value; public string Description { get; set; } = string.Empty; + public void StartTracking() { } + public void StopTask() { } + public void FailTask() + { + } + public void AddMessage(string message) { } diff --git a/src/RipSharp.Tests/Services/DiscRipperTitleSuffixTests.cs b/src/RipSharp.Tests/Services/DiscRipperTitleSuffixTests.cs index 2bee07c..4663660 100644 --- a/src/RipSharp.Tests/Services/DiscRipperTitleSuffixTests.cs +++ b/src/RipSharp.Tests/Services/DiscRipperTitleSuffixTests.cs @@ -19,7 +19,7 @@ public async Task BuildTitlePlansAsync_SingleMovieTitle_DoesNotAppendSuffix() }; var titleIds = new List { 0 }; var metadata = new ContentMetadata { Title = "Control", Year = 2007, Type = "movie" }; - var options = new RipOptions { Output = "/tmp" }; + var options = new RipOptions { Output = "/tmp", Season = 1, EpisodeStart = 1 }; var plans = await InvokeBuildTitlePlansAsync(ripper, discInfo, titleIds, metadata, options); plans.Should().HaveCount(1); @@ -49,33 +49,34 @@ public async Task EncodeAndRenameAsync_SingleMovieTitle_DoesNotAppendSuffix() Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns(callInfo => { var outputPath = callInfo.ArgAt(1); Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); File.WriteAllText(outputPath, "data"); - return Task.FromResult(true); + return Task.FromResult(new ProcessResult(true, 0, Array.Empty())); }); var ripper = CreateRipper(encoder); - var discInfo = new DiscInfo - { - Titles = new List - { - new() { Id = 0, Name = "Control" } - } - }; - var titleIds = new List { 0 }; var rippedFilesMap = new Dictionary { { 0, sourceFile } }; var metadata = new ContentMetadata { Title = "Control", Year = 2007, Type = "movie" }; - var options = new RipOptions { Output = outputRoot }; + var options = new RipOptions { Output = outputRoot, Season = 1, EpisodeStart = 1 }; + var titlePlans = new List + { + new(TitleId: 0, Index: 0, EpisodeNum: null, EpisodeTitle: null, + TempOutputPath: Path.Combine(outputRoot, "Control.mkv"), + FinalFileName: "Control (2007).mkv", VersionSuffix: null, DisplayName: "Control") + }; - var finalFiles = await InvokeEncodeAndRenameAsync(ripper, discInfo, titleIds, rippedFilesMap, metadata, options); + var (successes, failures) = await InvokeEncodeAndRenameAsync(ripper, rippedFilesMap, titlePlans, metadata, options); - finalFiles.Should().HaveCount(1); - finalFiles[0].Should().Be(Path.Combine(outputRoot, "Control (2007).mkv")); - finalFiles[0].Should().NotContain("title01"); + failures.Should().BeEmpty(); + successes.Should().HaveCount(1); + var finalPath = successes[0].FinalPath; + finalPath.Should().Be(Path.Combine(outputRoot, "Control (2007).mkv")); + finalPath.Should().NotContain("title01"); } finally { @@ -114,21 +115,23 @@ private static async Task> InvokeBuildTitlePlansAsync( return ((IEnumerable)result).Cast().ToList(); } - private static async Task> InvokeEncodeAndRenameAsync( + private static async Task<(List Successes, List Failures)> InvokeEncodeAndRenameAsync( DiscRipper ripper, - DiscInfo discInfo, - List titleIds, Dictionary rippedFilesMap, + IReadOnlyList titlePlans, ContentMetadata metadata, RipOptions options) { var method = typeof(DiscRipper).GetMethod("EncodeAndRenameAsync", BindingFlags.NonPublic | BindingFlags.Instance); method.Should().NotBeNull(); - var task = (Task)method!.Invoke(ripper, new object[] { discInfo, titleIds, rippedFilesMap, metadata, options })!; + var task = (Task)method!.Invoke(ripper, new object[] { rippedFilesMap, titlePlans, metadata, options })!; await task.ConfigureAwait(false); - return (List)task.GetType().GetProperty("Result")!.GetValue(task)!; + var result = task.GetType().GetProperty("Result")!.GetValue(task)!; + var successes = (List)result.GetType().GetField("Item1")!.GetValue(result)!; + var failures = (List)result.GetType().GetField("Item2")!.GetValue(result)!; + return (successes, failures); } private static string? GetStringProperty(object target, string propertyName) diff --git a/src/RipSharp.Tests/Services/EpisodeAutoDetectTests.cs b/src/RipSharp.Tests/Services/EpisodeAutoDetectTests.cs new file mode 100644 index 0000000..5e4df0c --- /dev/null +++ b/src/RipSharp.Tests/Services/EpisodeAutoDetectTests.cs @@ -0,0 +1,169 @@ +using System.Reflection; + +namespace RipSharp.Tests.Services; + +public class EpisodeAutoDetectTests +{ + [Fact] + public void AutoDetectEpisodeStart_EmptyDirectory_RemainsNull() + { + var outputDir = CreateTempDir(); + try + { + var options = new RipOptions { Output = outputDir, Season = 1 }; + InvokeAutoDetect(options); + options.EpisodeStart.Should().BeNull(); + } + finally { Directory.Delete(outputDir, true); } + } + + [Fact] + public void AutoDetectEpisodeStart_WithExistingEpisodes_SetsNextEpisode() + { + var outputDir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(outputDir, "Show - S01E01 - Pilot.mkv"), ""); + File.WriteAllText(Path.Combine(outputDir, "Show - S01E02 - Second.mkv"), ""); + File.WriteAllText(Path.Combine(outputDir, "Show - S01E03 - Third.mkv"), ""); + + var options = new RipOptions { Output = outputDir, Season = 1 }; + InvokeAutoDetect(options); + options.EpisodeStart.Should().Be(4); + } + finally { Directory.Delete(outputDir, true); } + } + + [Fact] + public void AutoDetectEpisodeStart_MatchesCorrectSeason() + { + var outputDir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(outputDir, "Show - S01E01.mkv"), ""); + File.WriteAllText(Path.Combine(outputDir, "Show - S01E02.mkv"), ""); + File.WriteAllText(Path.Combine(outputDir, "Show - S02E01.mkv"), ""); + + var options = new RipOptions { Output = outputDir, Season = 2 }; + InvokeAutoDetect(options); + options.EpisodeStart.Should().Be(2); + } + finally { Directory.Delete(outputDir, true); } + } + + [Fact] + public void AutoDetectEpisodeStart_IgnoresNonMkvFiles() + { + var outputDir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(outputDir, "Show - S01E05.txt"), ""); + File.WriteAllText(Path.Combine(outputDir, "Show - S01E01.mkv"), ""); + + var options = new RipOptions { Output = outputDir, Season = 1 }; + InvokeAutoDetect(options); + options.EpisodeStart.Should().Be(2); + } + finally { Directory.Delete(outputDir, true); } + } + + [Fact] + public void AutoDetectEpisodeStart_NonexistentDirectory_RemainsNull() + { + var options = new RipOptions { Output = "/nonexistent/path/12345", Season = 1 }; + InvokeAutoDetect(options); + options.EpisodeStart.Should().BeNull(); + } + + [Fact] + public void AutoDetectEpisodeStart_CaseInsensitiveMatching() + { + var outputDir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(outputDir, "Show - s01e04 - Episode.mkv"), ""); + + var options = new RipOptions { Output = outputDir, Season = 1 }; + InvokeAutoDetect(options); + options.EpisodeStart.Should().Be(5); + } + finally { Directory.Delete(outputDir, true); } + } + + [Fact] + public void AutoDetectEpisodeStart_GapInNumbering_UsesMaxEpisode() + { + var outputDir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(outputDir, "Show - S01E01.mkv"), ""); + File.WriteAllText(Path.Combine(outputDir, "Show - S01E03.mkv"), ""); + File.WriteAllText(Path.Combine(outputDir, "Show - S01E05.mkv"), ""); + + var options = new RipOptions { Output = outputDir, Season = 1 }; + InvokeAutoDetect(options); + options.EpisodeStart.Should().Be(6); + } + finally { Directory.Delete(outputDir, true); } + } + + [Fact] + public void AutoDetectEpisodeStart_HighEpisodeNumbers() + { + var outputDir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(outputDir, "Show - S01E12.mkv"), ""); + File.WriteAllText(Path.Combine(outputDir, "Show - S01E13.mkv"), ""); + + var options = new RipOptions { Output = outputDir, Season = 1 }; + InvokeAutoDetect(options); + options.EpisodeStart.Should().Be(14); + } + finally { Directory.Delete(outputDir, true); } + } + + [Fact] + public void AutoDetectEpisodeStart_DoesNotModifyExplicitValue() + { + var outputDir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(outputDir, "Show - S01E05.mkv"), ""); + + var options = new RipOptions { Output = outputDir, Season = 1, EpisodeStart = 1 }; + InvokeAutoDetect(options); + // AutoDetectEpisodeStart still runs and finds E05, setting to 6. + // The guard that prevents calling it when EpisodeStart is explicit + // lives in ProcessDiscAsync, not in the method itself. + options.EpisodeStart.Should().Be(6); + } + finally { Directory.Delete(outputDir, true); } + } + + private static string CreateTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), $"ripsharp-test-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(dir); + return dir; + } + + private static void InvokeAutoDetect(RipOptions options) + { + var scanner = Substitute.For(); + var encoder = Substitute.For(); + var metadata = Substitute.For(); + var makeMkv = Substitute.For(); + var notifier = Substitute.For(); + var userPrompt = Substitute.For(); + var episodeTitles = Substitute.For(); + var progressDisplay = Substitute.For(); + var theme = ThemeProvider.CreateDefault(); + + var ripper = new DiscRipper(scanner, encoder, metadata, makeMkv, notifier, userPrompt, episodeTitles, progressDisplay, theme); + + var method = typeof(DiscRipper).GetMethod("AutoDetectEpisodeStart", BindingFlags.NonPublic | BindingFlags.Instance); + method.Should().NotBeNull("AutoDetectEpisodeStart should exist as a private method"); + method!.Invoke(ripper, new object[] { options }); + } +} diff --git a/src/RipSharp.Tests/Services/SeasonAutoDetectTests.cs b/src/RipSharp.Tests/Services/SeasonAutoDetectTests.cs new file mode 100644 index 0000000..5d09ec3 --- /dev/null +++ b/src/RipSharp.Tests/Services/SeasonAutoDetectTests.cs @@ -0,0 +1,108 @@ +using System.Reflection; + +namespace RipSharp.Tests.Services; + +public class SeasonAutoDetectTests +{ + [Theory] + [InlineData("/Volumes/data/TV/Veep/Season 2", 2)] + [InlineData("/Volumes/data/TV/Veep/Season 1", 1)] + [InlineData("/Volumes/data/TV/Veep/Season 10", 10)] + [InlineData("/media/tv/The Office/Season 3", 3)] + public void AutoDetectSeason_SeasonInDirName_DetectsSeason(string outputPath, int expectedSeason) + { + var options = new RipOptions { Output = outputPath }; + InvokeAutoDetect(options); + options.Season.Should().Be(expectedSeason); + } + + [Theory] + [InlineData("/Volumes/data/TV/Veep/season 2", 2)] + [InlineData("/Volumes/data/TV/Veep/SEASON 5", 5)] + [InlineData("/Volumes/data/TV/Veep/Season2", 2)] + public void AutoDetectSeason_CaseInsensitive(string outputPath, int expectedSeason) + { + var options = new RipOptions { Output = outputPath }; + InvokeAutoDetect(options); + options.Season.Should().Be(expectedSeason); + } + + [Theory] + [InlineData("/Volumes/data/TV/Veep/S02", 2)] + [InlineData("/Volumes/data/TV/Veep/S1", 1)] + [InlineData("/Volumes/data/TV/Veep/s03", 3)] + public void AutoDetectSeason_ShortForm_DetectsSeason(string outputPath, int expectedSeason) + { + var options = new RipOptions { Output = outputPath }; + InvokeAutoDetect(options); + options.Season.Should().Be(expectedSeason); + } + + [Theory] + [InlineData("/Volumes/data/TV/Veep")] + [InlineData("/Volumes/data/TV/Veep/Extras")] + [InlineData("/tmp/movies")] + public void AutoDetectSeason_NoSeasonInDirName_RemainsNull(string outputPath) + { + var options = new RipOptions { Output = outputPath }; + InvokeAutoDetect(options); + options.Season.Should().BeNull(); + } + + [Theory] + [InlineData("VEEP_S2_D1", 2)] + [InlineData("BREAKING_BAD_SEASON_3_DISC_1", 3)] + [InlineData("THE_OFFICE_S04_D2", 4)] + [InlineData("SHOW_Season1", 1)] + public void AutoDetectSeason_FromDiscName_DetectsSeason(string discName, int expectedSeason) + { + var options = new RipOptions { Output = "/tmp/output" }; + InvokeAutoDetect(options, discName); + options.Season.Should().Be(expectedSeason); + } + + [Fact] + public void AutoDetectSeason_DirNameTakesPriorityOverDiscName() + { + var options = new RipOptions { Output = "/Volumes/data/TV/Veep/Season 3" }; + InvokeAutoDetect(options, "VEEP_S2_D1"); + options.Season.Should().Be(3); + } + + [Fact] + public void AutoDetectSeason_FallsBackToDiscNameWhenDirHasNoSeason() + { + var options = new RipOptions { Output = "/Volumes/data/TV/Veep" }; + InvokeAutoDetect(options, "VEEP_S2_D1"); + options.Season.Should().Be(2); + } + + [Fact] + public void AutoDetectSeason_NeitherSource_RemainsNull() + { + var options = new RipOptions { Output = "/Volumes/data/TV/Veep" }; + InvokeAutoDetect(options, "VEEP_DISC_1"); + options.Season.Should().BeNull(); + } + + private static void InvokeAutoDetect(RipOptions options, string? discName = null) + { + var scanner = Substitute.For(); + var encoder = Substitute.For(); + var metadata = Substitute.For(); + var makeMkv = Substitute.For(); + var notifier = Substitute.For(); + var userPrompt = Substitute.For(); + var episodeTitles = Substitute.For(); + var progressDisplay = Substitute.For(); + var theme = ThemeProvider.CreateDefault(); + + var ripper = new DiscRipper(scanner, encoder, metadata, makeMkv, notifier, userPrompt, episodeTitles, progressDisplay, theme); + + var discInfo = new DiscInfo { DiscName = discName ?? "" }; + + var method = typeof(DiscRipper).GetMethod("AutoDetectSeason", BindingFlags.NonPublic | BindingFlags.Instance); + method.Should().NotBeNull("AutoDetectSeason should exist as a private method"); + method!.Invoke(ripper, new object[] { options, discInfo }); + } +} diff --git a/src/RipSharp.Tests/Utilities/SpectreProgressDisplayTests.cs b/src/RipSharp.Tests/Utilities/SpectreProgressDisplayTests.cs index ae3c1f0..9da437d 100644 --- a/src/RipSharp.Tests/Utilities/SpectreProgressDisplayTests.cs +++ b/src/RipSharp.Tests/Utilities/SpectreProgressDisplayTests.cs @@ -46,6 +46,76 @@ public void StopTask_FreezesElapsedTimer() GetElapsed(task).Should().BeCloseTo(stopTime!.Value - startTime, precision: TimeSpan.FromSeconds(0.25)); } + [Fact] + public void FailTask_SetsIsFailedAndFreezesTimer() + { + var task = CreateLiveTask("Test", 100); + var startTime = DateTime.UtcNow - TimeSpan.FromSeconds(7); + + SetTaskValue(task, 50); + SetPrivateField(task, "_startTime", startTime); + + Invoke(task, "FailTask"); + + var progressTask = (IProgressTask)task; + progressTask.IsFailed.Should().BeTrue(); + progressTask.IsStopped.Should().BeTrue(); + + var stopTime = (DateTime?)GetPrivateField(task, "_stopTime"); + stopTime.Should().NotBeNull(); + GetElapsed(task).Should().BeCloseTo(stopTime!.Value - startTime, precision: TimeSpan.FromSeconds(0.25)); + } + + [Fact] + public void FailTask_DoesNotAdvanceValueToMax() + { + var task = CreateLiveTask("Test", 100); + + SetTaskValue(task, 42); + + Invoke(task, "FailTask"); + + var progressTask = (IProgressTask)task; + progressTask.Value.Should().Be(42); + progressTask.MaxValue.Should().Be(100); + } + + [Fact] + public void FailTask_IsIdempotent() + { + var task = CreateLiveTask("Test", 100); + var startTime = DateTime.UtcNow - TimeSpan.FromSeconds(5); + var firstStopTime = DateTime.UtcNow - TimeSpan.FromSeconds(2); + + SetTaskValue(task, 30); + SetPrivateField(task, "_startTime", startTime); + SetPrivateField(task, "_stopTime", firstStopTime); + SetPrivateField(task, "_isFailed", true); + SetPrivateField(task, "_isStopped", true); + + Invoke(task, "FailTask"); + + var stopTime = (DateTime?)GetPrivateField(task, "_stopTime"); + stopTime.Should().Be(firstStopTime); + } + + [Fact] + public void TaskValueReset_ClearsFailedState() + { + var task = CreateLiveTask("Test", 100); + + SetTaskValue(task, 50); + Invoke(task, "FailTask"); + + var progressTask = (IProgressTask)task; + progressTask.IsFailed.Should().BeTrue(); + + SetTaskValue(task, 0); + + progressTask.IsFailed.Should().BeFalse(); + progressTask.IsStopped.Should().BeFalse(); + } + [Fact] public void StopTask_DoesNotOverwriteStopTime() { diff --git a/src/RipSharp/Abstractions/IDiscRipper.cs b/src/RipSharp/Abstractions/IDiscRipper.cs index b52005b..f7d2398 100644 --- a/src/RipSharp/Abstractions/IDiscRipper.cs +++ b/src/RipSharp/Abstractions/IDiscRipper.cs @@ -1,6 +1,8 @@ +using BugZapperLabs.RipSharp.Models; + namespace BugZapperLabs.RipSharp.Abstractions; public interface IDiscRipper { - Task> ProcessDiscAsync(RipOptions options, CancellationToken cancellationToken = default); + Task ProcessDiscAsync(RipOptions options, CancellationToken cancellationToken = default); } diff --git a/src/RipSharp/Abstractions/IEncoderService.cs b/src/RipSharp/Abstractions/IEncoderService.cs index f0c59db..9f3d6ff 100644 --- a/src/RipSharp/Abstractions/IEncoderService.cs +++ b/src/RipSharp/Abstractions/IEncoderService.cs @@ -1,7 +1,9 @@ +using BugZapperLabs.RipSharp.Models; + namespace BugZapperLabs.RipSharp.Abstractions; public interface IEncoderService { Task AnalyzeAsync(string filePath); - Task EncodeAsync(string inputFile, string outputFile, bool includeEnglishSubtitles, int ordinal, int total, IProgressTask? progressTask = null); + Task EncodeAsync(string inputFile, string outputFile, bool includeEnglishSubtitles, int ordinal, int total, IProgressTask? progressTask = null, string? logDirectory = null); } diff --git a/src/RipSharp/Abstractions/IMakeMkvService.cs b/src/RipSharp/Abstractions/IMakeMkvService.cs index 6419dae..60720b3 100644 --- a/src/RipSharp/Abstractions/IMakeMkvService.cs +++ b/src/RipSharp/Abstractions/IMakeMkvService.cs @@ -1,8 +1,10 @@ +using BugZapperLabs.RipSharp.Models; + namespace BugZapperLabs.RipSharp.Abstractions; public interface IMakeMkvService { - Task RipTitleAsync(string discPath, int titleId, string tempDir, + Task RipTitleAsync(string discPath, int titleId, string tempDir, Action? onOutput = null, Action? onError = null, CancellationToken ct = default); diff --git a/src/RipSharp/Abstractions/IProgressDisplay.cs b/src/RipSharp/Abstractions/IProgressDisplay.cs index aa3f292..1c95b5c 100644 --- a/src/RipSharp/Abstractions/IProgressDisplay.cs +++ b/src/RipSharp/Abstractions/IProgressDisplay.cs @@ -1,3 +1,5 @@ +using BugZapperLabs.RipSharp.Models; + namespace BugZapperLabs.RipSharp.Abstractions; /// @@ -42,6 +44,11 @@ public interface IProgressTask /// bool IsStopped { get; } + /// + /// Gets whether the task has failed. + /// + bool IsFailed { get; } + /// /// Gets the elapsed time since the task started. /// @@ -57,11 +64,21 @@ public interface IProgressTask /// string Description { get; set; } + /// + /// Starts the elapsed-time clock without changing the progress value. + /// + void StartTracking(); + /// /// Stops the task, marking it as complete. /// void StopTask(); + /// + /// Marks the task as failed, freezing the elapsed timer without advancing to max value. + /// + void FailTask(); + /// /// Adds a message to be displayed in this task's panel. /// @@ -77,3 +94,14 @@ public interface IProgressTask /// List GetRecentMessages(int count); } + +public static class ProgressTaskExtensions +{ + public static void ReportFailure(this IProgressTask task, ProcessResult result) + { + task.AddMessage($"FAILED: {result.ErrorSummary}"); + if (result.LogPath != null) + task.AddMessage($"Log: {result.LogPath}"); + task.FailTask(); + } +} diff --git a/src/RipSharp/Abstractions/IUserPrompt.cs b/src/RipSharp/Abstractions/IUserPrompt.cs index 76bbf7b..d7087f3 100644 --- a/src/RipSharp/Abstractions/IUserPrompt.cs +++ b/src/RipSharp/Abstractions/IUserPrompt.cs @@ -1,3 +1,5 @@ +using BugZapperLabs.RipSharp.Models; + namespace BugZapperLabs.RipSharp.Abstractions; /// @@ -11,4 +13,37 @@ public interface IUserPrompt /// Optional hint about what was detected (e.g., "detected as movie with 65% confidence") /// True for TV series, false for movie bool PromptForContentType(string? detectionHint = null); + + /// + /// Displays the rip plan and asks the user to confirm, edit, or abort. + /// + PreviewResult ConfirmRipPlan(IReadOnlyList plans, ContentMetadata metadata, bool isTv, string? discName = null, int? season = null, int? episodeStart = null, IReadOnlySet? selectedTitleIds = null); + + /// + /// Displays an interactive review of failed titles and lets the user inspect, retry, or skip each one. + /// Returns the list of TitleOutcomes the user wants to retry. + /// + List ReviewFailures(IReadOnlyList failures); +} + +public enum PreviewAction +{ + Proceed, + Abort, + EditTitle, + EditEpisodeStart, + EditFilenames, + SelectTitles +} + +public class PreviewResult +{ + public PreviewAction Action { get; init; } + public string? NewTitle { get; init; } + public int? NewEpisodeStart { get; init; } + public Dictionary? RenamedFiles { get; init; } + public HashSet? SelectedTitleIds { get; init; } + + public static PreviewResult Proceed() => new() { Action = PreviewAction.Proceed }; + public static PreviewResult Abort() => new() { Action = PreviewAction.Abort }; } diff --git a/src/RipSharp/Core/Program.cs b/src/RipSharp/Core/Program.cs index 72080e8..7aa8262 100644 --- a/src/RipSharp/Core/Program.cs +++ b/src/RipSharp/Core/Program.cs @@ -179,10 +179,10 @@ private static async Task RunAsync(string[] args, CursorManager cursorManag var writer = host.Services.GetRequiredService(); var theme = host.Services.GetRequiredService(); - List files; + DiscProcessingResult result; try { - files = await ripper.ProcessDiscAsync(options, _cancellationTokenSource!.Token); + result = await ripper.ProcessDiscAsync(options, _cancellationTokenSource!.Token); } catch (OperationCanceledException) { @@ -190,15 +190,16 @@ private static async Task RunAsync(string[] args, CursorManager cursorManag return 130; } - if (files.Count > 0) + if (result.AllSucceeded) { return 0; } - else + if (result.AnySucceeded) { - writer.Error("Failed to process disc"); - return 1; + return 3; // partial failure } + writer.Error("Failed to process disc"); + return 1; } private static string GetVersion() diff --git a/src/RipSharp/Core/RipOptions.cs b/src/RipSharp/Core/RipOptions.cs index d112f21..3e71ae7 100644 --- a/src/RipSharp/Core/RipOptions.cs +++ b/src/RipSharp/Core/RipOptions.cs @@ -1,5 +1,11 @@ namespace BugZapperLabs.RipSharp.Core; +public enum ErrorMode +{ + Prompt, + Ignore +} + public class RipOptions { public string Disc { get; set; } = "disc:0"; @@ -9,14 +15,17 @@ public class RipOptions public bool AutoDetect { get; set; } = true; // Auto-detect content type by default public string? Title { get; set; } public int? Year { get; set; } - public int Season { get; set; } = 1; - public int EpisodeStart { get; set; } = 1; + public int? Season { get; set; } + public int? EpisodeStart { get; set; } public bool Debug { get; set; } public string? DiscType { get; set; } // dvd|bd|uhd + public bool Preview { get; set; } + public ErrorMode ErrorMode { get; set; } = ErrorMode.Prompt; public bool ShowHelp { get; set; } public bool ShowVersion { get; set; } public bool TempWasAutoGenerated { get; set; } public bool EnableParallelProcessing { get; set; } = true; // Enable parallel rip and encode by default + public int Concurrency { get; set; } = 1; public static RipOptions ParseArgs(string[] args) { @@ -69,10 +78,31 @@ public static RipOptions ParseArgs(string[] args) case "--title": opts.Title = next(); break; case "--year": if (int.TryParse(next(), out var y)) opts.Year = y; break; case "--season": if (int.TryParse(next(), out var s)) opts.Season = s; break; - case "--episode-start": if (int.TryParse(next(), out var e)) opts.EpisodeStart = e; break; + case "--episode-start": + if (int.TryParse(next(), out var e)) + opts.EpisodeStart = e; + break; case "--debug": opts.Debug = true; break; case "--disc-type": opts.DiscType = next(); break; case "--sequential": opts.EnableParallelProcessing = false; break; + case "--preview": opts.Preview = true; break; + case "--concurrency": + if (int.TryParse(next(), out var c)) + { + if (c < 1 || c > 8) + throw new ArgumentException("--concurrency must be between 1 and 8"); + opts.Concurrency = c; + } + break; + case "--error": + var errorMode = next()?.ToLowerInvariant(); + opts.ErrorMode = errorMode switch + { + "ignore" => ErrorMode.Ignore, + "prompt" => ErrorMode.Prompt, + _ => throw new ArgumentException("--error must be 'prompt' or 'ignore'") + }; + break; } } if (string.IsNullOrWhiteSpace(opts.Output)) @@ -125,12 +155,19 @@ void OptionDetail(string description) OptionDetail("Default: {output}/"); OptionLine("--title TEXT", "Custom title for file naming"); OptionLine("--year YYYY", "Release year (movies only)"); - OptionLine("--season N", "Season number (TV only, default: 1)"); - OptionLine("--episode-start N", "Starting episode number (TV only, default: 1)"); + OptionLine("--season N", "Season number (TV only)"); + OptionDetail("Default: auto-detect from output path, or 1"); + OptionLine("--episode-start N", "Starting episode number (TV only)"); + OptionDetail("Default: auto-detect from existing files, or 1"); OptionLine("--disc-type TYPE", "Override disc type: dvd|bd|uhd"); OptionDetail("Default: auto-detect"); OptionLine("--sequential", "Disable parallel processing"); OptionDetail("Rip all, then encode all"); + OptionLine("--concurrency N", "Number of concurrent encodes (1-8, default: 1)"); + OptionLine("--preview", "Show rip plan and confirm before starting"); + OptionLine("--error prompt|ignore", "Error handling mode (default: prompt)"); + OptionDetail("- prompt: Ask to retry/continue/abort on errors"); + OptionDetail("- ignore: Log errors and continue"); OptionLine("--debug", "Enable debug logging"); OptionLine("-h, --help", "Show this help message"); OptionLine("-v, --version", "Show the application version"); diff --git a/src/RipSharp/MakeMkv/MakeMkvService.cs b/src/RipSharp/MakeMkv/MakeMkvService.cs index 1cce01d..33f5c57 100644 --- a/src/RipSharp/MakeMkv/MakeMkvService.cs +++ b/src/RipSharp/MakeMkv/MakeMkvService.cs @@ -1,3 +1,5 @@ +using BugZapperLabs.RipSharp.Models; + namespace BugZapperLabs.RipSharp.MakeMkv; public class MakeMkvService : IMakeMkvService @@ -5,12 +7,23 @@ public class MakeMkvService : IMakeMkvService private readonly IProcessRunner _runner; public MakeMkvService(IProcessRunner runner) => _runner = runner; - public Task RipTitleAsync(string discPath, int titleId, string tempDir, + public async Task RipTitleAsync(string discPath, int titleId, string tempDir, Action? onOutput = null, Action? onError = null, CancellationToken ct = default) { var args = $"-r --robot mkv {discPath} {titleId} \"{tempDir}\""; - return _runner.RunAsync("makemkvcon", args, onOutput, onError, ct); + var command = $"makemkvcon {args}"; + var errorLines = new List(); + void wrappedOnError(string line) + { + if (line.StartsWith("PRGV:") || line.StartsWith("PRGC:")) + return; + + errorLines.Add(line); + onError?.Invoke(line); + } + var exitCode = await _runner.RunAsync("makemkvcon", args, onOutput, wrappedOnError, ct); + return new ProcessResult(exitCode == 0, exitCode, errorLines, command); } } diff --git a/src/RipSharp/Metadata/MetadataService.cs b/src/RipSharp/Metadata/MetadataService.cs index ed754dc..529ba27 100644 --- a/src/RipSharp/Metadata/MetadataService.cs +++ b/src/RipSharp/Metadata/MetadataService.cs @@ -24,6 +24,10 @@ public MetadataService(IEnumerable providers, IConsoleWriter var result = await provider.LookupAsync(titleVariation, isTv, year); if (result != null) { + result.Provider = provider.Name; + result.SearchTitle = titleVariation; + result.DiscTitle = title; + if (titleVariation != title) _notifier.Success($"{_theme.Emojis.Success} {provider.Name} {(isTv ? "TV" : "Movie")} lookup found using simplified title '{titleVariation}': '{result.Title}'" + (result.Year.HasValue ? $" ({result.Year.Value})" : "")); else @@ -34,6 +38,6 @@ public MetadataService(IEnumerable providers, IConsoleWriter } _notifier.Warning($"{_theme.Emojis.Warning} No metadata found from available providers for '{title}'. Using disc title as fallback."); - return new ContentMetadata { Title = title, Year = year, Type = isTv ? "tv" : "movie" }; + return new ContentMetadata { Title = title, Year = year, Type = isTv ? "tv" : "movie", DiscTitle = title }; } } diff --git a/src/RipSharp/Models/ContentMetadata.cs b/src/RipSharp/Models/ContentMetadata.cs index 92c275d..65e8e03 100644 --- a/src/RipSharp/Models/ContentMetadata.cs +++ b/src/RipSharp/Models/ContentMetadata.cs @@ -5,4 +5,8 @@ public class ContentMetadata public string Title { get; set; } = "Unknown"; public int? Year { get; set; } public string Type { get; set; } = "movie"; // movie|tv + + public string? Provider { get; set; } + public string? SearchTitle { get; set; } + public string? DiscTitle { get; set; } } diff --git a/src/RipSharp/Models/DiscProcessingResult.cs b/src/RipSharp/Models/DiscProcessingResult.cs new file mode 100644 index 0000000..1d5b021 --- /dev/null +++ b/src/RipSharp/Models/DiscProcessingResult.cs @@ -0,0 +1,10 @@ +namespace BugZapperLabs.RipSharp.Models; + +public record DiscProcessingResult( + List SuccessFiles, + List Failures, + int TotalTitles) +{ + public bool AllSucceeded => Failures.Count == 0; + public bool AnySucceeded => SuccessFiles.Count > 0; +} diff --git a/src/RipSharp/Models/ProcessResult.cs b/src/RipSharp/Models/ProcessResult.cs new file mode 100644 index 0000000..2c81fd5 --- /dev/null +++ b/src/RipSharp/Models/ProcessResult.cs @@ -0,0 +1,43 @@ +namespace BugZapperLabs.RipSharp.Models; + +public record ProcessResult( + bool Success, + int ExitCode, + IReadOnlyList ErrorLines, + string? Command = null, + string? LogPath = null) +{ + public string ErrorSummary + { + get + { + var summary = ErrorLines.Count > 0 + ? string.Join(Environment.NewLine, ErrorLines.TakeLast(10)) + : "No error details captured"; + return $"{GetProcessLabel()} exited with code {ExitCode}{(LogPath != null ? $" (log: {LogPath})" : "")}{Environment.NewLine}{summary}"; + } + } + + private string GetProcessLabel() + { + if (string.IsNullOrWhiteSpace(Command)) + return "Process"; + + var trimmed = Command.TrimStart(); + if (trimmed.Length == 0) + return "Process"; + + if (trimmed[0] is '"' or '\'') + { + var quote = trimmed[0]; + var endQuote = trimmed.IndexOf(quote, 1); + if (endQuote > 1) + return trimmed.Substring(1, endQuote - 1); + + return trimmed.Trim(quote); + } + + var firstWhitespace = trimmed.IndexOfAny(new[] { ' ', '\t', '\r', '\n' }); + return firstWhitespace > 0 ? trimmed[..firstWhitespace] : trimmed; + } +} diff --git a/src/RipSharp/Models/TitleOutcome.cs b/src/RipSharp/Models/TitleOutcome.cs new file mode 100644 index 0000000..17e27fa --- /dev/null +++ b/src/RipSharp/Models/TitleOutcome.cs @@ -0,0 +1,19 @@ +namespace BugZapperLabs.RipSharp.Models; + +public enum ProcessingPhase +{ + Rip, + Encode +} + +public record TitleOutcome( + TitlePlan Plan, + bool Success, + ProcessingPhase? FailedPhase = null, + string? FinalPath = null, + string? ErrorMessage = null, + IReadOnlyList? ErrorLines = null, + string? Command = null, + string? RipLogPath = null, + string? EncodeLogPath = null, + string? RippedFilePath = null); diff --git a/src/RipSharp/Models/TitlePlan.cs b/src/RipSharp/Models/TitlePlan.cs new file mode 100644 index 0000000..9cc0cd0 --- /dev/null +++ b/src/RipSharp/Models/TitlePlan.cs @@ -0,0 +1,12 @@ +namespace BugZapperLabs.RipSharp.Models; + +public record TitlePlan( + int TitleId, + int Index, + int? EpisodeNum, + string? EpisodeTitle, + string TempOutputPath, + string FinalFileName, + string? VersionSuffix, + string DisplayName, + int DurationSeconds = 0); diff --git a/src/RipSharp/Services/DiscRipper.cs b/src/RipSharp/Services/DiscRipper.cs index 4ba5c3d..abaf5c8 100644 --- a/src/RipSharp/Services/DiscRipper.cs +++ b/src/RipSharp/Services/DiscRipper.cs @@ -1,5 +1,9 @@ +using System.Text.RegularExpressions; using System.Threading.Channels; +using Spectre.Console; +using Spectre.Console.Rendering; + namespace BugZapperLabs.RipSharp.Services; // Job records for channel communication @@ -9,11 +13,15 @@ public record RipJob( string RippedFilePath, TitleInfo TitleInfo); -public record EncodeResult( +public record EncodeJobResult( int TitleId, bool Success, string? FinalPath = null, - string? ErrorMessage = null); + string? ErrorMessage = null, + IReadOnlyList? ErrorLines = null, + string? Command = null, + string? EncodeLogPath = null, + string? RippedFilePath = null); public class DiscRipper : IDiscRipper { @@ -28,16 +36,6 @@ public class DiscRipper : IDiscRipper private readonly IProgressDisplay _progressDisplay; private readonly IThemeProvider _theme; - private record TitlePlan( - int TitleId, - int Index, - int? EpisodeNum, - string? EpisodeTitle, - string TempOutputPath, - string FinalFileName, - string? VersionSuffix, - string DisplayName); - public DiscRipper(IDiscScanner scanner, IEncoderService encoder, IMetadataService metadata, IMakeMkvService makeMkv, IConsoleWriter notifier, IUserPrompt userPrompt, ITvEpisodeTitleProvider episodeTitles, IProgressDisplay progressDisplay, IThemeProvider theme) { _scanner = scanner; @@ -51,7 +49,7 @@ public DiscRipper(IDiscScanner scanner, IEncoderService encoder, IMetadataServic _theme = theme; } - public async Task> ProcessDiscAsync(RipOptions options, CancellationToken cancellationToken = default) + public async Task ProcessDiscAsync(RipOptions options, CancellationToken cancellationToken = default) { PrepareDirectories(options); var (discInfo, metadata) = await ScanDiscAndLookupMetadata(options); @@ -60,40 +58,195 @@ public async Task> ProcessDiscAsync(RipOptions options, Cancellatio if (titleIds.Count == 0) { _notifier.Error("No suitable titles found on disc"); - return new List(); + return new DiscProcessingResult(new List(), new List(), 0); } if (metadata is null) { _notifier.Error("ContentMetadata? lookup failed; unable to encode and rename titles."); - return new List(); + return new DiscProcessingResult(new List(), new List(), 0); } _notifier.Accent($"Found {titleIds.Count} title(s) to rip: [{string.Join(", ", titleIds)}]"); - // Use parallel processing by default - var finalFiles = options.EnableParallelProcessing - ? await ProcessDiscParallelAsync(discInfo, titleIds, metadata, options, cancellationToken) - : await ProcessDiscSequentialAsync(discInfo, titleIds, metadata, options, cancellationToken); + if (options.Tv && !options.Season.HasValue) + { + AutoDetectSeason(options, discInfo); + } + options.Season ??= 1; - if (finalFiles.Count > 0) + if (options.Tv && !options.EpisodeStart.HasValue) + { + AutoDetectEpisodeStart(options); + } + options.EpisodeStart ??= 1; + + var titlePlans = await BuildTitlePlansAsync(discInfo, titleIds, metadata, options); + + if (options.Preview) + { + var selectedTitleIds = new HashSet(titlePlans.Select(p => p.TitleId)); + + while (true) + { + var result = _userPrompt.ConfirmRipPlan(titlePlans, metadata, options.Tv, discInfo.DiscName, options.Season, options.EpisodeStart, selectedTitleIds); + + if (result.Action == PreviewAction.Abort) + { + _notifier.Warning("Rip plan declined by user"); + return new DiscProcessingResult(new List(), new List(), titleIds.Count); + } + + if (result.Action == PreviewAction.Proceed) + break; + + if (result.Action == PreviewAction.EditTitle && result.NewTitle != null) + { + metadata.Title = result.NewTitle; + titlePlans = await BuildTitlePlansAsync(discInfo, titleIds, metadata, options); + continue; + } + + if (result.Action == PreviewAction.EditEpisodeStart && result.NewEpisodeStart.HasValue) + { + options.EpisodeStart = result.NewEpisodeStart.Value; + titlePlans = await BuildTitlePlansAsync(discInfo, titleIds, metadata, options); + continue; + } + + if (result.Action == PreviewAction.EditFilenames && result.RenamedFiles is { Count: > 0 }) + { + titlePlans = titlePlans.Select(p => + result.RenamedFiles.TryGetValue(p.TitleId, out var newName) + ? p with { FinalFileName = newName } + : p).ToList(); + continue; + } + + if (result.Action == PreviewAction.SelectTitles && result.SelectedTitleIds != null) + { + selectedTitleIds = result.SelectedTitleIds; + continue; + } + + // Edit action with no actual change (e.g. user kept default) — re-show preview + } + + // Apply title selection — filter both the plan list and the ID list + if (selectedTitleIds.Count < titleIds.Count) + { + titlePlans = titlePlans.Where(p => selectedTitleIds.Contains(p.TitleId)).ToList(); + titleIds = titleIds.Where(id => selectedTitleIds.Contains(id)).ToList(); + } + } + + var (successes, failures) = options.EnableParallelProcessing + ? await ProcessDiscParallelAsync(discInfo, titleIds, metadata, options, titlePlans, cancellationToken) + : await ProcessDiscSequentialAsync(discInfo, titleIds, metadata, options, titlePlans, cancellationToken); + + // Interactive failure review with retry support + while (failures.Count > 0 && options.ErrorMode == ErrorMode.Prompt) + { + var retryRequested = _userPrompt.ReviewFailures(failures); + if (retryRequested.Count == 0) + break; + + var retryOutcomes = await RetryFailedTitlesAsync(retryRequested, metadata, options); + var succeededIds = new HashSet(retryOutcomes.Where(o => o.Success).Select(o => o.Plan.TitleId)); + + foreach (var outcome in retryOutcomes) + { + // Remove old failure entry for this title + failures.RemoveAll(f => f.Plan.TitleId == outcome.Plan.TitleId); + + if (outcome.Success) + successes.Add(outcome); + else + failures.Add(outcome); + } + } + + // Organize logs by status before cleanup + OrganizeLogsByStatus(successes, failures, options); + + // Cleanup temp directory + if (successes.Count > 0 || failures.Count == 0) { CleanupTempDirectory(options); } - else if (Directory.EnumerateFiles(options.Temp!, "*.mkv").Any()) + else if (!string.IsNullOrWhiteSpace(options.Temp) && Directory.Exists(options.Temp) && Directory.EnumerateFiles(options.Temp, "*.mkv").Any()) { _notifier.Error($"No files were successfully encoded; temporary files have been left in: {options.Temp}"); } - _notifier.Success($"Processing complete. Output files: {finalFiles.Count}"); - foreach (var f in finalFiles) _notifier.Plain(f); - return finalFiles; + // Final summary + var successFiles = successes.Where(o => o.FinalPath != null).Select(o => o.FinalPath!).ToList(); + if (failures.Count > 0) + { + _notifier.Error($"{failures.Count} title(s) failed:"); + foreach (var f in failures) + _notifier.Error($" {f.Plan.DisplayName} ({f.FailedPhase}): {TruncateError(f.ErrorMessage)}"); + } + + if (successes.Count > 0) + { + RenderCompletionSummary(successes, titleIds.Count); + } + + return new DiscProcessingResult(successFiles, failures, titleIds.Count); + } + + private static string TruncateError(string? message, int maxLength = 80) + { + if (string.IsNullOrWhiteSpace(message)) return "Unknown error"; + // Take just the first line + var firstLine = message.Split('\n', 2)[0].Trim(); + return firstLine.Length <= maxLength ? firstLine : firstLine[..(maxLength - 3)] + "..."; + } + + private void RenderCompletionSummary(List successes, int totalCount) + { + var sorted = successes + .Where(o => o.FinalPath != null) + .OrderBy(o => o.Plan.FinalFileName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (sorted.Count == 0) + return; + + AnsiConsole.WriteLine(); + + var table = new Table(); + table.Border(TableBorder.Rounded); + table.BorderColor(_theme.AccentColor); + table.Title($"[bold][{_theme.Colors.Success}]Complete[/] — {sorted.Count}/{totalCount} title{(totalCount != 1 ? "s" : "")} encoded[/]"); + + table.AddColumn(new TableColumn("#").Centered()); + table.AddColumn(new TableColumn("Duration").RightAligned()); + table.AddColumn("Filename"); + + for (var i = 0; i < sorted.Count; i++) + { + var outcome = sorted[i]; + var duration = DurationFormatter.Format(outcome.Plan.DurationSeconds); + var fileName = Path.GetFileName(outcome.FinalPath!); + + table.AddRow( + new Markup($"[{_theme.Colors.Muted}]{i + 1}[/]"), + new Markup($"[{_theme.Colors.Muted}]{Markup.Escape(duration)}[/]"), + new Markup(Markup.Escape(fileName)) + ); + } + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); } - private async Task> ProcessDiscSequentialAsync(DiscInfo discInfo, List titleIds, ContentMetadata metadata, RipOptions options, CancellationToken cancellationToken) + private async Task<(List Successes, List Failures)> ProcessDiscSequentialAsync(DiscInfo discInfo, List titleIds, ContentMetadata metadata, RipOptions options, IReadOnlyList titlePlans, CancellationToken cancellationToken) { - var rippedFilesMap = await RipTitlesAsync(discInfo, titleIds, options); - return await EncodeAndRenameAsync(discInfo, titleIds, rippedFilesMap, metadata, options); + var (rippedFilesMap, ripFailures) = await RipTitlesAsync(discInfo, titleIds, titlePlans, options); + var (encodeSuccesses, encodeFailures) = await EncodeAndRenameAsync(rippedFilesMap, titlePlans, metadata, options); + return (encodeSuccesses, ripFailures.Concat(encodeFailures).ToList()); } private async Task> BuildTitlePlansAsync(DiscInfo discInfo, List titleIds, ContentMetadata metadata, RipOptions options) @@ -116,9 +269,9 @@ private async Task> BuildTitlePlansAsync(DiscInfo discInfo, List if (options.Tv) { - episodeNum = (options.EpisodeStart - 1) + idx + 1; + episodeNum = (options.EpisodeStart!.Value - 1) + idx + 1; // Fetch episode title early so we can display/name immediately - episodeTitle = await _episodeTitles.GetEpisodeTitleAsync(metadata.Title, options.Season, episodeNum.Value, metadata.Year); + episodeTitle = await _episodeTitles.GetEpisodeTitleAsync(metadata.Title, options.Season!.Value, episodeNum.Value, metadata.Year); var safeEpisodeTitle = string.IsNullOrWhiteSpace(episodeTitle) ? "" : $" - {FileNaming.SanitizeFileName(episodeTitle)}"; finalFileName = $"{safeSeriesTitle} - S{options.Season:00}E{episodeNum:00}{safeEpisodeTitle}.mkv"; @@ -142,51 +295,64 @@ private async Task> BuildTitlePlansAsync(DiscInfo discInfo, List : metadata.Title; } - plans.Add(new TitlePlan(titleId, idx, episodeNum, episodeTitle, tempOutputPath, finalFileName, versionSuffix, displayName)); + plans.Add(new TitlePlan(titleId, idx, episodeNum, episodeTitle, tempOutputPath, finalFileName, versionSuffix, displayName, titleInfo?.DurationSeconds ?? 0)); } return plans; } - private async Task> ProcessDiscParallelAsync(DiscInfo discInfo, List titleIds, ContentMetadata metadata, RipOptions options, CancellationToken cancellationToken) + private async Task<(List Successes, List Failures)> ProcessDiscParallelAsync(DiscInfo discInfo, List titleIds, ContentMetadata metadata, RipOptions options, IReadOnlyList titlePlans, CancellationToken cancellationToken) { var ripChannel = Channel.CreateUnbounded(); - var resultChannel = Channel.CreateUnbounded(); + var resultChannel = Channel.CreateUnbounded(); + var ripFailures = new System.Collections.Concurrent.ConcurrentBag(); var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var finalFiles = new List(); - var titlePlans = await BuildTitlePlansAsync(discInfo, titleIds, metadata, options); + var encodeResults = new List(); + Interlocked.Exchange(ref _encodeProcessedCount, 0); try { await _progressDisplay.ExecuteAsync(async ctx => { - var ripProgress = ctx.AddTask("Ripping", maxValue: RipProgressScale); // Per-track progress (0-100) - var encodeProgress = ctx.AddTask("Encoding", maxValue: RipProgressScale); // Per-encode progress (0-100) - encodeProgress.AddMessage("Waiting for rip to complete..."); + var ripProgress = ctx.AddTask("Ripping", maxValue: RipProgressScale); + var encodeProgressTasks = new IProgressTask[options.Concurrency]; + for (int w = 0; w < options.Concurrency; w++) + { + var label = options.Concurrency > 1 ? $"Encoding [{w + 1}]" : "Encoding"; + encodeProgressTasks[w] = ctx.AddTask(label, maxValue: RipProgressScale); + encodeProgressTasks[w].AddMessage("Waiting for rip to complete..."); + } var overallProgress = ctx.AddTask("Overall", maxValue: titleIds.Count * 2); + overallProgress.StartTracking(); var overallTracker = new OverallProgressTracker(overallProgress); - // Start both ripping and encoding tasks in parallel - var ripTask = Task.Run(() => RipProducerAsync(ripChannel, discInfo, titleIds, titlePlans, options, ripProgress, overallTracker, cts.Token)); - var encodeTask = Task.Run(() => EncodeConsumerAsync(ripChannel, resultChannel, titlePlans, metadata, options, encodeProgress, overallTracker, cts.Token)); - var collectTask = CollectResultsAsync(resultChannel, titleIds.Count, cts.Token); + var ripTask = Task.Run(() => RipProducerAsync(ripChannel, ripFailures, discInfo, titleIds, titlePlans, options, ripProgress, overallTracker, cts.Token)); - // Wait for both to complete - await Task.WhenAll(ripTask, encodeTask); + var encodeWorkers = new Task[options.Concurrency]; + for (int w = 0; w < options.Concurrency; w++) + { + var workerProgress = encodeProgressTasks[w]; + encodeWorkers[w] = Task.Run(() => EncodeWorkerAsync(ripChannel, resultChannel, titlePlans, metadata, options, workerProgress, overallTracker, cts.Token)); + } + + var collectTask = CollectResultsAsync(resultChannel, cts.Token); + + await ripTask; + await Task.WhenAll(encodeWorkers); + resultChannel.Writer.Complete(); - // Stop progress bars ripProgress.StopTask(); - encodeProgress.StopTask(); + foreach (var ep in encodeProgressTasks) ep.StopTask(); overallProgress.StopTask(); - finalFiles = await collectTask; + encodeResults = await collectTask; }); - return finalFiles; + return BuildOutcomes(titlePlans, encodeResults, ripFailures); } catch (OperationCanceledException) { - throw; // Re-throw to let Program.cs handle the message + throw; } finally { @@ -254,9 +420,61 @@ private List IdentifyTitlesToRip(DiscInfo discInfo, RipOptions options) return titleIds; } - private async Task> RipTitlesAsync(DiscInfo discInfo, List titleIds, RipOptions options) + private static readonly Regex SeasonPattern = new(@"[Ss](?:eason[\s_]*)?(\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private void AutoDetectSeason(RipOptions options, DiscInfo discInfo) + { + var dirName = Path.GetFileName(Path.GetFullPath(options.Output)); + if (!string.IsNullOrWhiteSpace(dirName)) + { + var match = SeasonPattern.Match(dirName); + if (match.Success && int.TryParse(match.Groups[1].Value, out var season) && season > 0) + { + options.Season = season; + _notifier.Info($"Detected season {season} from output directory"); + return; + } + } + + if (!string.IsNullOrWhiteSpace(discInfo.DiscName)) + { + var match = SeasonPattern.Match(discInfo.DiscName); + if (match.Success && int.TryParse(match.Groups[1].Value, out var season) && season > 0) + { + options.Season = season; + _notifier.Info($"Detected season {season} from disc name '{discInfo.DiscName}'"); + } + } + } + + private void AutoDetectEpisodeStart(RipOptions options) + { + if (!Directory.Exists(options.Output)) + return; + + var pattern = new Regex($@"S{options.Season:00}E(\d{{2,}})", RegexOptions.IgnoreCase); + int maxEpisode = 0; + + foreach (var file in Directory.EnumerateFiles(options.Output, "*.mkv")) + { + var match = pattern.Match(Path.GetFileName(file)); + if (match.Success && int.TryParse(match.Groups[1].Value, out var ep) && ep > maxEpisode) + { + maxEpisode = ep; + } + } + + if (maxEpisode > 0) + { + options.EpisodeStart = maxEpisode + 1; + _notifier.Info($"Detected existing episodes up to E{maxEpisode:00}, starting at E{options.EpisodeStart:00}"); + } + } + + private async Task<(Dictionary RippedFiles, List RipFailures)> RipTitlesAsync(DiscInfo discInfo, List titleIds, IReadOnlyList titlePlans, RipOptions options) { var rippedFilesMap = new Dictionary(); + var ripFailures = new List(); var totalTitles = titleIds.Count; var preExistingRips = new Queue(Directory.EnumerateFiles(options.Temp!, "*.mkv").OrderBy(File.GetCreationTime)); for (int idx = 0; idx < titleIds.Count; idx++) @@ -264,6 +482,7 @@ private async Task> RipTitlesAsync(DiscInfo discInfo, Li var titleId = titleIds[idx]; var titleInfo = discInfo.Titles.FirstOrDefault(t => t.Id == titleId); var titleName = titleInfo?.Name; + var plan = titlePlans[idx]; if (preExistingRips.Count > 0) { @@ -278,6 +497,9 @@ private async Task> RipTitlesAsync(DiscInfo discInfo, Li var progressLogPath = Path.Combine(options.Temp!, $"progress_title_{titleId:D2}.log"); if (File.Exists(progressLogPath)) File.Delete(progressLogPath); + ProcessResult? ripFailureResult = null; + string? rawLogPathCapture = null; + await _progressDisplay.ExecuteAsync(async ctx => { var expectedBytes = titleInfo?.ReportedSizeBytes ?? 0; @@ -293,7 +515,6 @@ await _progressDisplay.ExecuteAsync(async ctx => { try { - // Identify the mkv file being written for this title: the first new mkv not in existingFiles if (currentMkv == null) { currentMkv = Directory @@ -314,25 +535,23 @@ await _progressDisplay.ExecuteAsync(async ctx => }); var rawLogPath = Path.Combine(options.Temp!, $"makemkv_title_{titleId:D2}.log"); + rawLogPathCapture = rawLogPath; var handler = new MakeMkvOutputHandler(expectedBytes, idx, totalTitles, task, progressLogPath, rawLogPath, _notifier, _theme); - var exit = await _makeMkv.RipTitleAsync(options.Disc, titleId, options.Temp!, + var ripResult = await _makeMkv.RipTitleAsync(options.Disc, titleId, options.Temp!, onOutput: handler.HandleLine, onError: errLine => { - if (!(errLine.StartsWith("PRGV:") || errLine.StartsWith("PRGC:"))) - { - _notifier.Error(errLine); - } + _notifier.Error(errLine); handler.HandleLine(errLine); }); ripDone = true; try { await pollTask; } catch { } - if (exit != 0) + if (!ripResult.Success) { task.Description = $"[{_theme.Colors.Error}]Failed: Title {titleId}[/]"; task.StopTask(); - _notifier.Error($"Failed to rip title {titleId}"); + ripFailureResult = ripResult; return; } if (handler.LastBytesProcessed < maxValue) @@ -342,6 +561,17 @@ await _progressDisplay.ExecuteAsync(async ctx => task.StopTask(); }); + if (ripFailureResult != null) + { + ripFailures.Add(new TitleOutcome( + plan, Success: false, FailedPhase: ProcessingPhase.Rip, + ErrorMessage: ripFailureResult.ErrorSummary, + ErrorLines: ripFailureResult.ErrorLines, + Command: ripFailureResult.Command, + RipLogPath: rawLogPathCapture)); + continue; + } + var newFiles = Directory.EnumerateFiles(options.Temp!, "*.mkv").Where(f => !existingFiles.Contains(f)).ToList(); if (newFiles.Count > 0) { @@ -349,10 +579,10 @@ await _progressDisplay.ExecuteAsync(async ctx => rippedFilesMap[titleId] = ripped; } } - return rippedFilesMap; + return (rippedFilesMap, ripFailures); } - private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInfo, List titleIds, IReadOnlyList plans, RipOptions options, IProgressTask ripProgress, OverallProgressTracker overallTracker, CancellationToken cancellationToken) + private async Task RipProducerAsync(Channel ripChannel, System.Collections.Concurrent.ConcurrentBag ripFailures, DiscInfo discInfo, List titleIds, IReadOnlyList plans, RipOptions options, IProgressTask ripProgress, OverallProgressTracker overallTracker, CancellationToken cancellationToken) { try { @@ -370,24 +600,27 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf if (titleInfo == null) { - var msg = $"Title {titleId} not found in disc info, skipping"; - ripProgress.AddMessage(msg); + var plan = plans[idx]; + ripFailures.Add(new TitleOutcome( + plan, Success: false, FailedPhase: ProcessingPhase.Rip, + ErrorMessage: $"Title {titleId} not found in disc info")); + ripProgress.AddMessage($"Title {titleId} not found in disc info, skipping"); overallTracker.MarkRipComplete(); overallTracker.MarkEncodeComplete(); continue; } - var plan = plans[idx]; + var planForTitle = plans[idx]; // Check for pre-existing rips if (preExistingRips.Count > 0) { var reused = preExistingRips.Dequeue(); - var msg = $"Using existing ripped file for title {idx + 1} of {totalTitles}: {plan.DisplayName} (Title ID: {titleId}) -> {Path.GetFileName(reused)}"; + var msg = $"Using existing ripped file for title {idx + 1} of {totalTitles}: {planForTitle.DisplayName} (Title ID: {titleId}) -> {Path.GetFileName(reused)}"; ripProgress.AddMessage(msg); await ripChannel.Writer.WriteAsync(new RipJob(titleId, idx, reused, titleInfo), cancellationToken); - ripProgress.Description = $"{plan.DisplayName} [100%]"; + ripProgress.Description = $"{planForTitle.DisplayName} [100%]"; ripProgress.Value += RipProgressScale; rippedCount++; overallTracker.MarkRipComplete(); @@ -395,7 +628,7 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf } // Perform actual rip with live progress contribution - var rippedPath = await PerformSingleRipAsync(titleId, idx, titleInfo, plan, totalTitles, options, ripProgress); + var (rippedPath, ripResult, rawLogPath) = await PerformSingleRipAsync(titleId, idx, titleInfo, planForTitle, totalTitles, options, ripProgress); if (!string.IsNullOrEmpty(rippedPath)) { @@ -405,8 +638,13 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf } else { - var msg = $"Failed to rip title {titleId}, skipping"; - ripProgress.AddMessage(msg); + ripFailures.Add(new TitleOutcome( + planForTitle, Success: false, FailedPhase: ProcessingPhase.Rip, + ErrorMessage: ripResult?.ErrorSummary ?? "Rip failed with no details", + ErrorLines: ripResult?.ErrorLines, + Command: ripResult?.Command, + RipLogPath: rawLogPath)); + ripProgress.AddMessage($"Failed to rip title {titleId}, skipping"); overallTracker.MarkRipComplete(); overallTracker.MarkEncodeComplete(); } @@ -427,7 +665,7 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf } } - private async Task PerformSingleRipAsync(int titleId, int idx, TitleInfo? titleInfo, TitlePlan plan, int totalTitles, RipOptions options, IProgressTask ripProgress) + private async Task<(string? RippedPath, ProcessResult? FailureResult, string RawLogPath)> PerformSingleRipAsync(int titleId, int idx, TitleInfo? titleInfo, TitlePlan plan, int totalTitles, RipOptions options, IProgressTask ripProgress) { // Reset ripProgress for this track (show 0-100% per track) ripProgress.Value = 0; @@ -441,6 +679,7 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf if (File.Exists(progressLogPath)) File.Delete(progressLogPath); string? rippedPath = null; + ProcessResult? failureResult = null; var expectedBytes = titleInfo?.ReportedSizeBytes ?? 0; var durationSeconds = titleInfo?.DurationSeconds ?? 0; @@ -462,7 +701,6 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf { pollCount++; - // Identify MKV being written if we haven't yet if (currentMkv == null) { currentMkv = Directory @@ -470,25 +708,20 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf .FirstOrDefault(f => !existingFiles.Contains(f)); } - // Prefer fraction parsed from PRGV when available var fraction = Math.Clamp(handler.LastProgressFraction, 0, 1); - // If we know expected bytes, normalize by bytes processed if (fraction == 0 && expectedBytes > 0 && handler.LastBytesProcessed > 0) { fraction = Math.Clamp(handler.LastBytesProcessed / Math.Max(1.0, expectedBytes), 0, 1); } - // If MakeMKV never emits PRGV/bytes, fall back to elapsed time vs. title duration (best-effort) if (fraction == 0 && durationSeconds > 0) { var elapsedSecs = DateTime.UtcNow.Subtract(ripStartTime).TotalSeconds; - // Assume ~1x read speed with a little slack var denom = Math.Max(10.0, durationSeconds * 1.2); fraction = Math.Clamp(elapsedSecs / denom, 0, 1); } - // If size is unknown and we have raw byte progress, derive a monotonic fraction from growth if (fraction == 0 && expectedBytes == 0 && handler.LastBytesProcessed > 0) { if (handler.LastBytesProcessed > observedMaxBytes) @@ -501,14 +734,12 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf fraction = displayedFraction; } - // As a last resort, use file size growth (monotonic) when we have no size estimate if (fraction == 0 && expectedBytes == 0 && currentMkv != null) { try { var size = new FileInfo(currentMkv).Length; if (size > observedMaxBytes) observedMaxBytes = size; - // Use relative growth (monotonic), capped so the bar moves but never hits 100% from this alone var candidate = observedMaxBytes > 0 ? size / observedMaxBytes : 0; displayedFraction = Math.Max(displayedFraction, Math.Clamp(candidate * 0.8, 0, 0.99)); fraction = displayedFraction; @@ -516,17 +747,16 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf catch { } } - // If rip has been running for a while but no progress yet, show minimal progress to indicate activity if (fraction == 0 && DateTime.UtcNow.Subtract(ripStartTime).TotalSeconds > 3) { var elapsedSecs = DateTime.UtcNow.Subtract(ripStartTime).TotalSeconds; - var minimalFraction = Math.Min(0.1, elapsedSecs / 60.0); // nudge up to 10% over a minute + var minimalFraction = Math.Min(0.1, elapsedSecs / 60.0); displayedFraction = Math.Max(displayedFraction, minimalFraction); fraction = displayedFraction; } var fractionalProgress = (long)Math.Round(fraction * RipProgressScale); - ripProgress.Value = fractionalProgress; // Show only current track progress (0-100%) + ripProgress.Value = fractionalProgress; } catch (Exception ex) @@ -537,14 +767,11 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf } }); - var exit = await _makeMkv.RipTitleAsync(options.Disc, titleId, options.Temp!, + var ripResult = await _makeMkv.RipTitleAsync(options.Disc, titleId, options.Temp!, onOutput: handler.HandleLine, onError: errLine => { - if (!(errLine.StartsWith("PRGV:") || errLine.StartsWith("PRGC:"))) - { - _notifier.Error(errLine); - } + ripProgress.AddMessage($"ERROR: {errLine}"); handler.HandleLine(errLine); }); ripDone = true; @@ -554,9 +781,10 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf } catch (Exception ex) { - _notifier.Error($"Error while monitoring rip progress: {ex}"); + ripProgress.AddMessage($"ERROR: monitoring rip progress: {ex.Message}"); } - if (exit == 0) + + if (ripResult.Success) { var newFiles = Directory.EnumerateFiles(options.Temp!, "*.mkv").Where(f => !existingFiles.Contains(f)).ToList(); if (newFiles.Count > 0) @@ -564,95 +792,88 @@ private async Task RipProducerAsync(Channel ripChannel, DiscInfo discInf rippedPath = newFiles.OrderByDescending(File.GetCreationTime).First(); } } + else + { + ripProgress.ReportFailure(ripResult); + failureResult = ripResult; + } - // Snap progress to the completed title - ripProgress.Value = RipProgressScale; // Show 100% for current track - return rippedPath; + ripProgress.Value = RipProgressScale; + return (rippedPath, failureResult, rawLogPath); } - private async Task EncodeConsumerAsync(Channel ripChannel, Channel resultChannel, IReadOnlyList titlePlans, ContentMetadata metadata, RipOptions options, IProgressTask encodeProgress, OverallProgressTracker overallTracker, CancellationToken cancellationToken) + private int _encodeProcessedCount; + + private async Task EncodeWorkerAsync(Channel ripChannel, Channel resultChannel, IReadOnlyList titlePlans, ContentMetadata metadata, RipOptions options, IProgressTask encodeProgress, OverallProgressTracker overallTracker, CancellationToken cancellationToken) { - try + var totalTitles = titlePlans.Count; + var planLookup = titlePlans.ToDictionary(p => p.TitleId); + + await foreach (var ripJob in ripChannel.Reader.ReadAllAsync(cancellationToken)) { - var totalTitles = titlePlans.Count; - int processedCount = 0; - var planLookup = titlePlans.ToDictionary(p => p.TitleId); + var processedCount = Interlocked.Increment(ref _encodeProcessedCount); - await foreach (var ripJob in ripChannel.Reader.ReadAllAsync(cancellationToken)) + if (!planLookup.TryGetValue(ripJob.TitleId, out var plan)) { - processedCount++; - - if (!planLookup.TryGetValue(ripJob.TitleId, out var plan)) - { - await resultChannel.Writer.WriteAsync(new EncodeResult(ripJob.TitleId, false, ErrorMessage: $"Missing plan for title {ripJob.TitleId}"), cancellationToken); - overallTracker.MarkEncodeComplete(); - continue; - } - - var outputPath = plan.TempOutputPath; - var versionSuffix = plan.VersionSuffix; - var episodeNum = plan.EpisodeNum; + await resultChannel.Writer.WriteAsync(new EncodeJobResult(ripJob.TitleId, false, ErrorMessage: $"Missing plan for title {ripJob.TitleId}"), cancellationToken); + overallTracker.MarkEncodeComplete(); + continue; + } - if (File.Exists(outputPath)) File.Delete(outputPath); + var outputPath = plan.TempOutputPath; + var versionSuffix = plan.VersionSuffix; + var episodeNum = plan.EpisodeNum; - // Reset encodeProgress for this job (show 0-100% per encode) - encodeProgress.Value = 0; - encodeProgress.ClearMessages(); // Clear messages from previous encoding + encodeProgress.ClearMessages(); + var encMsg = $"Encoding ({processedCount}/{totalTitles}): {plan.FinalFileName}"; + encodeProgress.AddMessage(encMsg); - var encMsg = $"Encoding ({processedCount}/{totalTitles}): {plan.FinalFileName}"; - encodeProgress.AddMessage(encMsg); + if (File.Exists(outputPath)) File.Delete(outputPath); + encodeProgress.Value = 0; - // Perform encoding - var success = await _encoder.EncodeAsync( - ripJob.RippedFilePath, - outputPath, - includeEnglishSubtitles: true, - ordinal: processedCount, - total: totalTitles, - progressTask: encodeProgress); + var outcome = await _encoder.EncodeAsync( + ripJob.RippedFilePath, + outputPath, + includeEnglishSubtitles: true, + ordinal: processedCount, + total: totalTitles, + progressTask: encodeProgress, + logDirectory: options.Temp); - if (success) - { - // Rename to final output - var finalPath = FileNaming.RenameFile( - outputPath, metadata, episodeNum, - options.Season, versionSuffix, plan.EpisodeTitle); - - await resultChannel.Writer.WriteAsync(new EncodeResult( - ripJob.TitleId, - true, - finalPath), cancellationToken); - encodeProgress.Value = RipProgressScale; // Show 100% for current encode - overallTracker.MarkEncodeComplete(); - } - else - { - await resultChannel.Writer.WriteAsync(new EncodeResult( - ripJob.TitleId, - false, - ErrorMessage: $"Failed to encode title {ripJob.TitleId}"), cancellationToken); - encodeProgress.Value = RipProgressScale; // Show 100% even on failure - overallTracker.MarkEncodeComplete(); - } + if (outcome.Success) + { + var finalPath = FileNaming.RenameFile( + outputPath, metadata, episodeNum, + options.Season!.Value, versionSuffix, plan.EpisodeTitle); + + await resultChannel.Writer.WriteAsync(new EncodeJobResult( + ripJob.TitleId, + true, + finalPath, + EncodeLogPath: outcome.LogPath, + RippedFilePath: ripJob.RippedFilePath), cancellationToken); + encodeProgress.Value = RipProgressScale; } - - // Ensure the encoding bar completes if it was created - if (encodeProgress != null) + else { - encodeProgress.Value = encodeProgress.MaxValue; - overallTracker.MarkAllComplete(); + encodeProgress.ReportFailure(outcome); + await resultChannel.Writer.WriteAsync(new EncodeJobResult( + ripJob.TitleId, + false, + ErrorMessage: outcome.ErrorSummary, + ErrorLines: outcome.ErrorLines, + Command: outcome.Command, + EncodeLogPath: outcome.LogPath, + RippedFilePath: ripJob.RippedFilePath), cancellationToken); } - } - finally - { - resultChannel.Writer.Complete(); + overallTracker.MarkEncodeComplete(); } } private sealed class OverallProgressTracker { private readonly IProgressTask _overallTask; - private readonly object _lock = new(); + private readonly Lock _lock = new(); private int _completedRips; private int _completedEncodes; @@ -679,14 +900,6 @@ public void MarkEncodeComplete() } } - public void MarkAllComplete() - { - lock (_lock) - { - _overallTask.Value = _overallTask.MaxValue; - } - } - private void UpdateValue() { _overallTask.Value = _completedRips + _completedEncodes; @@ -699,79 +912,250 @@ private static string BuildRipCompletionMessage(int rippedCount, int totalTitles return $"Ripping complete: {rippedCount}/{totalTitles} tracks in {durationText}."; } - private async Task> CollectResultsAsync(Channel resultChannel, int expectedCount, CancellationToken cancellationToken) + private async Task> CollectResultsAsync(Channel resultChannel, CancellationToken cancellationToken) { - var finalFiles = new List(); - var errors = new List(); + var results = new List(); await foreach (var result in resultChannel.Reader.ReadAllAsync(cancellationToken)) { - if (result.Success && result.FinalPath != null) + results.Add(result); + } + + return results; + } + + private static (List Successes, List Failures) BuildOutcomes( + IReadOnlyList plans, + List encodeResults, + System.Collections.Concurrent.ConcurrentBag ripFailures) + { + var planLookup = plans.ToDictionary(p => p.TitleId); + var successes = new List(); + var failures = new List(ripFailures); + + foreach (var er in encodeResults) + { + if (!planLookup.TryGetValue(er.TitleId, out var plan)) continue; + if (er.Success) { - finalFiles.Add(result.FinalPath); + successes.Add(new TitleOutcome( + plan, Success: true, + FinalPath: er.FinalPath, + RippedFilePath: er.RippedFilePath, + EncodeLogPath: er.EncodeLogPath)); } else { - errors.Add(result.ErrorMessage ?? "Unknown error"); - _notifier.Error(result.ErrorMessage ?? "Unknown error"); + failures.Add(new TitleOutcome( + plan, Success: false, FailedPhase: ProcessingPhase.Encode, + ErrorMessage: er.ErrorMessage, + ErrorLines: er.ErrorLines, + Command: er.Command, + EncodeLogPath: er.EncodeLogPath, + RippedFilePath: er.RippedFilePath)); } } - if (errors.Count > 0) - { - _notifier.Warning($"{errors.Count} title(s) failed to encode"); - } - - return finalFiles; + return (successes, failures); } - private async Task> EncodeAndRenameAsync(DiscInfo discInfo, List titleIds, Dictionary rippedFilesMap, ContentMetadata? metadata, RipOptions options) + private async Task<(List Successes, List Failures)> EncodeAndRenameAsync(Dictionary rippedFilesMap, IReadOnlyList titlePlans, ContentMetadata metadata, RipOptions options) { - var finalFiles = new List(); - foreach (var titleId in titleIds) + var successes = new List(); + var failures = new List(); + for (var idx = 0; idx < titlePlans.Count; idx++) { - if (!rippedFilesMap.TryGetValue(titleId, out var src)) + var plan = titlePlans[idx]; + if (!rippedFilesMap.TryGetValue(plan.TitleId, out var src)) { - _notifier.Error($"No ripped file found for title {titleId}"); + // Title was never ripped (failure already recorded in rip phase) continue; } - var titleInfo = discInfo.Titles.FirstOrDefault(t => t.Id == titleId); - var titleName = titleInfo?.Name; - string outputName; - string? versionSuffix = null; + if (File.Exists(plan.TempOutputPath)) File.Delete(plan.TempOutputPath); - if (options.Tv) + var outcome = await _encoder.EncodeAsync(src, plan.TempOutputPath, includeEnglishSubtitles: true, ordinal: idx + 1, total: titlePlans.Count, logDirectory: options.Temp); + if (outcome.Success) { - var episodeIdx = titleIds.IndexOf(titleId); - var episodeNum = (options.EpisodeStart - 1) + episodeIdx + 1; - outputName = Path.Combine(options.Output, $"temp_s{options.Season:00}e{episodeNum:00}.mkv"); + var finalPath = FileNaming.RenameFile( + plan.TempOutputPath, metadata, plan.EpisodeNum, + options.Season!.Value, plan.VersionSuffix, plan.EpisodeTitle); + successes.Add(new TitleOutcome( + plan, Success: true, + FinalPath: finalPath, + RippedFilePath: src, + EncodeLogPath: outcome.LogPath)); } else { - var ordinal = titleIds.IndexOf(titleId) + 1; - var safeTitle = !string.IsNullOrWhiteSpace(titleName) ? FileNaming.SanitizeFileName(titleName!) : $"movie_{ordinal}"; - var includeSuffix = titleIds.Count > 1; - versionSuffix = includeSuffix ? $" - title{ordinal:D2}" : null; - var safeVersionSuffix = string.IsNullOrWhiteSpace(versionSuffix) ? "" : FileNaming.SanitizeFileName(versionSuffix); - outputName = Path.Combine(options.Output, $"{safeTitle}{safeVersionSuffix}.mkv"); + failures.Add(new TitleOutcome( + plan, Success: false, FailedPhase: ProcessingPhase.Encode, + ErrorMessage: outcome.ErrorSummary, + ErrorLines: outcome.ErrorLines, + Command: outcome.Command, + EncodeLogPath: outcome.LogPath, + RippedFilePath: src)); } - if (File.Exists(outputName)) File.Delete(outputName); + } + return (successes, failures); + } - if (await _encoder.EncodeAsync(src, outputName, includeEnglishSubtitles: true, ordinal: titleIds.IndexOf(titleId) + 1, total: titleIds.Count)) + private void OrganizeLogsByStatus(List successes, List failures, RipOptions options) + { + if (failures.Count == 0) return; // No failures = no logs to preserve + + var logsBase = Path.Combine(options.Output, "logs"); + var successLogsDir = Path.Combine(logsBase, "success"); + var failedLogsDir = Path.Combine(logsBase, "failed"); + + void CopyLog(string? logPath, string destDir) + { + if (string.IsNullOrWhiteSpace(logPath) || !File.Exists(logPath)) return; + try { - var episodeIdx = options.Tv ? titleIds.IndexOf(titleId) : (int?)null; - var episodeNum = episodeIdx.HasValue ? (options.EpisodeStart - 1) + episodeIdx.Value + 1 : (int?)null; - string? episodeTitle = null; - if (options.Tv && episodeNum.HasValue) + Directory.CreateDirectory(destDir); + File.Copy(logPath, Path.Combine(destDir, Path.GetFileName(logPath)), overwrite: true); + } + catch (Exception ex) + { + _notifier.Warning($"Could not preserve log '{logPath}': {ex.Message}"); + } + } + + foreach (var s in successes) + { + CopyLog(s.RipLogPath, successLogsDir); + CopyLog(s.EncodeLogPath, successLogsDir); + } + + foreach (var f in failures) + { + CopyLog(f.RipLogPath, failedLogsDir); + CopyLog(f.EncodeLogPath, failedLogsDir); + } + + // Also copy makemkv/progress logs from temp that are associated with outcomes by title ID + if (!string.IsNullOrWhiteSpace(options.Temp) && Directory.Exists(options.Temp)) + { + var failedTitleIds = new HashSet(failures.Select(f => f.Plan.TitleId)); + var successTitleIds = new HashSet(successes.Select(s => s.Plan.TitleId)); + + foreach (var logFile in Directory.GetFiles(options.Temp, "*.log")) + { + var fileName = Path.GetFileName(logFile); + var match = Regex.Match(fileName, @"title_(\d+)"); + if (!match.Success || !int.TryParse(match.Groups[1].Value, out var id)) continue; + + var destDir = failedTitleIds.Contains(id) ? failedLogsDir : successLogsDir; + CopyLog(logFile, destDir); + } + } + } + + private async Task> RetryFailedTitlesAsync( + List toRetry, + ContentMetadata metadata, + RipOptions options) + { + var results = new List(); + + foreach (var failure in toRetry) + { + var plan = failure.Plan; + _notifier.Info($"Retrying: {plan.DisplayName}"); + + TitleOutcome? outcome = null; + await _progressDisplay.ExecuteAsync(async ctx => + { + var encodeTask = ctx.AddTask($"Encoding: {plan.FinalFileName}", maxValue: RipProgressScale); + + if (failure.FailedPhase == ProcessingPhase.Rip) + { + encodeTask.AddMessage("Waiting for rip..."); + var rippedPath = await RipSingleTitleForRetryAsync(plan, options); + if (rippedPath == null) + { + outcome = failure with { ErrorMessage = "Retry rip failed" }; + encodeTask.AddMessage("Rip failed"); + encodeTask.FailTask(); + return; + } + encodeTask.ClearMessages(); + encodeTask.StartTracking(); + outcome = await EncodeSingleTitleAsync(rippedPath, plan, metadata, options, encodeTask); + } + else { - episodeTitle = await _episodeTitles.GetEpisodeTitleAsync(metadata!.Title, options.Season, episodeNum.Value, metadata.Year); + if (string.IsNullOrEmpty(failure.RippedFilePath) || !File.Exists(failure.RippedFilePath)) + { + outcome = failure with { ErrorMessage = "Ripped file no longer available for re-encode" }; + encodeTask.AddMessage("Ripped file not available"); + encodeTask.FailTask(); + return; + } + encodeTask.StartTracking(); + outcome = await EncodeSingleTitleAsync(failure.RippedFilePath, plan, metadata, options, encodeTask); } - var final = FileNaming.RenameFile(outputName, metadata!, episodeNum, options.Season, versionSuffix, episodeTitle); - finalFiles.Add(final); - } + + if (outcome!.Success) + encodeTask.StopTask(); + }); + + results.Add(outcome!); + } + return results; + } + + private async Task RipSingleTitleForRetryAsync(TitlePlan plan, RipOptions options) + { + var existingFiles = new HashSet(Directory.EnumerateFiles(options.Temp!, "*.mkv")); + var rawLogPath = Path.Combine(options.Temp!, $"makemkv_title_{plan.TitleId:D2}.log"); + var progressLogPath = Path.Combine(options.Temp!, $"progress_title_{plan.TitleId:D2}.log"); + if (File.Exists(progressLogPath)) File.Delete(progressLogPath); + + var handler = new MakeMkvOutputHandler(0, plan.Index, 1, null, progressLogPath, rawLogPath, _notifier, _theme); + var ripResult = await _makeMkv.RipTitleAsync(options.Disc, plan.TitleId, options.Temp!, + onOutput: handler.HandleLine, + onError: errLine => handler.HandleLine(errLine)); + + if (!ripResult.Success) + { + _notifier.Error($"Retry rip failed for {plan.DisplayName}: {ripResult.ErrorSummary}"); + return null; + } + + var newFiles = Directory.EnumerateFiles(options.Temp!, "*.mkv").Where(f => !existingFiles.Contains(f)).ToList(); + return newFiles.Count > 0 ? newFiles.OrderByDescending(File.GetCreationTime).First() : null; + } + + private async Task EncodeSingleTitleAsync(string rippedFilePath, TitlePlan plan, ContentMetadata metadata, RipOptions options, IProgressTask? progressTask = null) + { + if (File.Exists(plan.TempOutputPath)) File.Delete(plan.TempOutputPath); + + var outcome = await _encoder.EncodeAsync( + rippedFilePath, plan.TempOutputPath, + includeEnglishSubtitles: true, + ordinal: plan.Index + 1, + total: 1, + progressTask: progressTask, + logDirectory: options.Temp); + + if (outcome.Success) + { + var finalPath = FileNaming.RenameFile( + plan.TempOutputPath, metadata, plan.EpisodeNum, + options.Season!.Value, plan.VersionSuffix, plan.EpisodeTitle); + return new TitleOutcome(plan, Success: true, FinalPath: finalPath, + RippedFilePath: rippedFilePath, EncodeLogPath: outcome.LogPath); } - return finalFiles; + + progressTask?.ReportFailure(outcome); + return new TitleOutcome(plan, Success: false, FailedPhase: ProcessingPhase.Encode, + ErrorMessage: outcome.ErrorSummary, + ErrorLines: outcome.ErrorLines, + Command: outcome.Command, + EncodeLogPath: outcome.LogPath, + RippedFilePath: rippedFilePath); } private void CleanupTempDirectory(RipOptions options) diff --git a/src/RipSharp/Services/EncoderService.cs b/src/RipSharp/Services/EncoderService.cs index 16eece1..4d8811f 100644 --- a/src/RipSharp/Services/EncoderService.cs +++ b/src/RipSharp/Services/EncoderService.cs @@ -1,55 +1,136 @@ using System.Text.Json; +using BugZapperLabs.RipSharp.Models; + namespace BugZapperLabs.RipSharp.Services; public class EncoderService : IEncoderService { private readonly IProcessRunner _runner; - public EncoderService(IProcessRunner runner, IConsoleWriter notifier, IProgressDisplay progressDisplay) + public EncoderService(IProcessRunner runner) => _runner = runner; + + public async Task AnalyzeAsync(string filePath) { - _runner = runner; + var (analysis, _) = await AnalyzeDetailedAsync(filePath); + return analysis; } - public async Task AnalyzeAsync(string filePath) + private async Task<(MediaFileAnalysis? Analysis, List Errors)> AnalyzeDetailedAsync(string filePath) { - var json = new System.Text.StringBuilder(); - var exit = await _runner.RunAsync("ffprobe", $"-v quiet -print_format json -show_streams -show_format \"{filePath}\"", - onOutput: line => json.AppendLine(line)); - if (exit != 0) return null; - var doc = JsonDocument.Parse(json.ToString()); - var streams = new List(); - double? durationSeconds = null; - - // Extract duration from format section - if (doc.RootElement.TryGetProperty("format", out var format) && - format.TryGetProperty("duration", out var dur) && - double.TryParse(dur.GetString(), out var durVal)) + var errors = new List(); + + if (!File.Exists(filePath)) { - durationSeconds = durVal; + errors.Add($"Input file does not exist: {filePath}"); + return (null, errors); } - foreach (var s in doc.RootElement.GetProperty("streams").EnumerateArray()) + var fileInfo = new FileInfo(filePath); + if (fileInfo.Length == 0) { - var si = new MediaStream + errors.Add($"Input file is empty (0 bytes): {filePath}"); + return (null, errors); + } + + const int maxAttempts = 3; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + errors.Clear(); + + var json = new System.Text.StringBuilder(); + var probeArgs = $"-v error -print_format json -show_streams -show_format \"{filePath}\""; + var exit = await _runner.RunAsync("ffprobe", probeArgs, + onOutput: line => json.AppendLine(line), + onError: line => { if (!string.IsNullOrWhiteSpace(line)) errors.Add(line); }); + + if (exit != 0) + { + errors.Insert(0, $"ffprobe exited with code {exit} (file size: {fileInfo.Length:N0} bytes)"); + if (attempt < maxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(1)); + continue; + } + return (null, errors); + } + + var jsonStr = json.ToString(); + if (string.IsNullOrWhiteSpace(jsonStr)) + { + errors.Add($"ffprobe produced no output for file (size: {fileInfo.Length:N0} bytes). The file may be corrupt or unreadable."); + if (attempt < maxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(1)); + continue; + } + return (null, errors); + } + + try + { + var doc = JsonDocument.Parse(jsonStr); + var streams = new List(); + double? durationSeconds = null; + + if (doc.RootElement.TryGetProperty("format", out var format) && + format.TryGetProperty("duration", out var dur) && + double.TryParse(dur.GetString(), out var durVal)) + { + durationSeconds = durVal; + } + + if (!doc.RootElement.TryGetProperty("streams", out var streamsElement) || + streamsElement.GetArrayLength() == 0) + { + errors.Add($"ffprobe found no streams in file (file size: {fileInfo.Length:N0} bytes)"); + return (null, errors); + } + + foreach (var s in streamsElement.EnumerateArray()) + { + var si = new MediaStream + { + Index = s.TryGetProperty("index", out var idx) ? idx.GetInt32() : 0, + CodecType = s.TryGetProperty("codec_type", out var ct) ? ct.GetString() ?? string.Empty : string.Empty, + CodecName = s.TryGetProperty("codec_name", out var cn) ? cn.GetString() : null, + Language = s.TryGetProperty("tags", out var tags) && tags.TryGetProperty("language", out var lang) ? lang.GetString() : null, + Channels = s.TryGetProperty("channels", out var ch) ? ch.GetInt32() : (int?)null, + Width = s.TryGetProperty("width", out var w) ? w.GetInt32() : (int?)null, + Height = s.TryGetProperty("height", out var h) ? h.GetInt32() : (int?)null, + }; + streams.Add(si); + } + return (new MediaFileAnalysis { Streams = streams, DurationSeconds = durationSeconds }, errors); + } + catch (JsonException ex) { - Index = s.TryGetProperty("index", out var idx) ? idx.GetInt32() : 0, - CodecType = s.TryGetProperty("codec_type", out var ct) ? ct.GetString() ?? string.Empty : string.Empty, - CodecName = s.TryGetProperty("codec_name", out var cn) ? cn.GetString() : null, - Language = s.TryGetProperty("tags", out var tags) && tags.TryGetProperty("language", out var lang) ? lang.GetString() : null, - Channels = s.TryGetProperty("channels", out var ch) ? ch.GetInt32() : (int?)null, - Width = s.TryGetProperty("width", out var w) ? w.GetInt32() : (int?)null, - Height = s.TryGetProperty("height", out var h) ? h.GetInt32() : (int?)null, - }; - streams.Add(si); + var preview = jsonStr.Length > 200 ? jsonStr[..200] + "..." : jsonStr; + errors.Add($"Failed to parse ffprobe output (attempt {attempt}/{maxAttempts}): {ex.Message}"); + errors.Add($"Raw output (first {Math.Min(jsonStr.Length, 200)} chars): {preview}"); + if (attempt < maxAttempts) + { + errors.Clear(); + await Task.Delay(TimeSpan.FromSeconds(1)); + continue; + } + return (null, errors); + } } - return new MediaFileAnalysis { Streams = streams, DurationSeconds = durationSeconds }; + + // Unreachable, but satisfies the compiler + errors.Add("ffprobe analysis failed after all attempts"); + return (null, errors); } - public async Task EncodeAsync(string inputFile, string outputFile, bool includeEnglishSubtitles, int ordinal, int total, IProgressTask? progressTask = null) + public async Task EncodeAsync(string inputFile, string outputFile, bool includeEnglishSubtitles, int ordinal, int total, IProgressTask? progressTask = null, string? logDirectory = null) { - var analysis = await AnalyzeAsync(inputFile); - if (analysis == null) return false; + var (analysis, probeErrors) = await AnalyzeDetailedAsync(inputFile); + if (analysis == null) + { + probeErrors.Insert(0, $"Pre-encoding analysis failed for input file: {Path.GetFileName(inputFile)}"); + return new ProcessResult(false, -1, probeErrors, $"ffprobe -v error -print_format json -show_streams -show_format \"{inputFile}\""); + } var selected = SelectStreams(analysis, includeEnglishSubtitles); var ffmpegArgs = BuildFfmpegArguments(inputFile, outputFile, selected); @@ -57,25 +138,90 @@ public async Task EncodeAsync(string inputFile, string outputFile, bool in var durationSeconds = analysis.DurationSeconds ?? 0; var durationTicks = (long)(durationSeconds * TimeSpan.TicksPerSecond); - var exit = await _runner.RunAsync("ffmpeg", ffmpegArgs, - onOutput: _ => { }, // ffmpeg stdout - not used with -progress pipe:2 - onError: line => HandleEncodingProgress(line, progressTask, durationTicks)); // Parse progress from stderr + var errorLines = new List(); + StreamWriter? logWriter = null; + string? logPath = null; + + try + { + if (!string.IsNullOrWhiteSpace(logDirectory)) + { + Directory.CreateDirectory(logDirectory); + var safeOutputName = Path.GetFileNameWithoutExtension(outputFile); + logPath = Path.Combine(logDirectory, $"ffmpeg_{safeOutputName}.log"); + logWriter = new StreamWriter(logPath, append: false) { AutoFlush = true }; + logWriter.WriteLine($"# ffmpeg {ffmpegArgs}"); + logWriter.WriteLine($"# Started: {DateTime.Now:O}"); + logWriter.WriteLine($"# Input: {inputFile} ({new FileInfo(inputFile).Length:N0} bytes)"); + logWriter.WriteLine($"# Duration: {durationSeconds:F1}s"); + logWriter.WriteLine($"# Streams: video={selected.Video?.CodecName ?? "none"} ({selected.Video?.Width}x{selected.Video?.Height})" + + $", audio=[{string.Join(", ", selected.Audio.Select(a => $"{a.CodecName}/{a.Language ?? "?"}/{a.Channels ?? 0}ch"))}]" + + $", subs=[{string.Join(", ", selected.Subtitles.Select(s => $"{s.CodecName}/{s.Language ?? "?"}"))}" + + $"]"); + logWriter.WriteLine(); + } + + var exit = await _runner.RunAsync("ffmpeg", ffmpegArgs, + onOutput: _ => { }, + onError: line => + { + logWriter?.WriteLine(line); + HandleEncodingProgress(line, progressTask, durationTicks); + if (IsErrorLine(line)) + errorLines.Add(line); + }); - return exit == 0; + logWriter?.WriteLine(); + logWriter?.WriteLine($"# Exit code: {exit}"); + if (File.Exists(outputFile)) + logWriter?.WriteLine($"# Output: {new FileInfo(outputFile).Length:N0} bytes"); + else + logWriter?.WriteLine("# Output: file not created"); + + return new ProcessResult(exit == 0, exit, errorLines, $"ffmpeg {ffmpegArgs}", logPath); + } + finally + { + logWriter?.Dispose(); + } + } + + private static bool IsErrorLine(string line) + { + if (line.Length == 0) return false; + // Skip progress key=value lines (out_time_us=, speed=, bitrate=, etc.) + if (line.Contains('=') && !line.Contains(' ')) return false; + // Skip "frame=" status lines + if (line.StartsWith("frame=")) return false; + // Skip common ffmpeg informational/warning lines that aren't actionable errors + if (line.StartsWith(" ")) return false; // continuation/indented lines from headers + if (line.StartsWith("[") && line.Contains("] ")) + { + // Codec context warnings like [hevc @ 0x...] are common and usually harmless + // Only keep lines that contain actual error keywords + var msg = line.Substring(line.IndexOf("] ") + 2); + return msg.Contains("error", StringComparison.OrdinalIgnoreCase) + || msg.Contains("fatal", StringComparison.OrdinalIgnoreCase) + || msg.Contains("failed", StringComparison.OrdinalIgnoreCase) + || msg.Contains("invalid", StringComparison.OrdinalIgnoreCase) + || msg.Contains("cannot", StringComparison.OrdinalIgnoreCase) + || msg.Contains("no such", StringComparison.OrdinalIgnoreCase); + } + if (line.StartsWith("Past duration")) return false; + if (line.StartsWith("Avi duration") || line.StartsWith("avi duration")) return false; + return true; } private void HandleEncodingProgress(string line, IProgressTask? task, long durationTicks) { if (task == null) return; - // Progress lines come in key=value format on stderr - // Use out_time_us (microseconds) for accurate progress tracking if (line.StartsWith("out_time_us=")) { var timeStr = line.Substring("out_time_us=".Length).Trim(); if (double.TryParse(timeStr, out var timeUs)) { - var currentTimeMs = timeUs / 1000.0; // Convert microseconds to milliseconds + var currentTimeMs = timeUs / 1000.0; var timeTicks = (long)(currentTimeMs * TimeSpan.TicksPerMillisecond); task.Value = Math.Min(100, (long)((double)timeTicks / durationTicks * 100)); } @@ -109,9 +255,14 @@ private static SelectedStreams SelectStreams(MediaFileAnalysis analysis, bool in var streams = analysis.Streams; var video = ChooseBestVideo(streams); - // Filter to only English audio tracks - var audioStreams = streams.FindAll(s => s.CodecType == "audio" && - (s.Language == null || s.Language == "eng" || s.Language == "en")); + // Keep English/unspecified audio tracks, plus the first audio track (original language). + // The first track is included so that foreign-language discs (e.g. Japanese anime) retain + // the original audio alongside any English dub, without pulling in every dubbed language. + var allAudio = streams.FindAll(s => s.CodecType == "audio"); + var firstAudioIndex = allAudio.Count > 0 ? allAudio[0].Index : -1; + var audioStreams = allAudio + .Where(s => s.Language == null || s.Language == "eng" || s.Language == "en" || s.Index == firstAudioIndex) + .ToList(); // When English subtitles are requested, filter to English (or unspecified) subtitle tracks var subtitleStreams = includeEnglishSubtitles diff --git a/src/RipSharp/Services/ProcessRunner.cs b/src/RipSharp/Services/ProcessRunner.cs index 0a1ee56..24ee48b 100644 --- a/src/RipSharp/Services/ProcessRunner.cs +++ b/src/RipSharp/Services/ProcessRunner.cs @@ -15,21 +15,28 @@ public async Task RunAsync(string fileName, string arguments, Action(TaskCreationOptions.RunContinuationsAsynchronously); + using var proc = new Process { StartInfo = psi }; proc.OutputDataReceived += (_, e) => { if (e.Data != null) onOutput?.Invoke(e.Data); }; proc.ErrorDataReceived += (_, e) => { if (e.Data != null) onError?.Invoke(e.Data); }; - proc.Exited += (_, __) => tcs.TrySetResult(proc.ExitCode); if (!proc.Start()) throw new InvalidOperationException($"Failed to start {fileName}"); proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); - using (ct.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } })) + try { - var exit = await tcs.Task.ConfigureAwait(false); - return exit; + await proc.WaitForExitAsync(ct).ConfigureAwait(false); + // WaitForExitAsync alone does not guarantee all async output events have fired. + // A synchronous WaitForExit() call afterward drains the redirected streams. + proc.WaitForExit(); } + catch (OperationCanceledException) + { + try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { } + throw; + } + + return proc.ExitCode; } } diff --git a/src/RipSharp/Utilities/ConsoleUserPrompt.cs b/src/RipSharp/Utilities/ConsoleUserPrompt.cs index c845a8b..4811140 100644 --- a/src/RipSharp/Utilities/ConsoleUserPrompt.cs +++ b/src/RipSharp/Utilities/ConsoleUserPrompt.cs @@ -1,4 +1,9 @@ +using System.IO.Compression; + +using BugZapperLabs.RipSharp.Models; + using Spectre.Console; +using Spectre.Console.Rendering; namespace BugZapperLabs.RipSharp.Utilities; @@ -8,17 +13,14 @@ namespace BugZapperLabs.RipSharp.Utilities; public class ConsoleUserPrompt : IUserPrompt { private readonly IConsoleWriter _notifier; + private readonly IThemeProvider _theme; - public ConsoleUserPrompt(IConsoleWriter notifier) + public ConsoleUserPrompt(IConsoleWriter notifier, IThemeProvider theme) { _notifier = notifier; + _theme = theme; } - /// - /// Prompts the user to select between movie or TV series mode. - /// - /// Optional hint about what was detected - /// True for TV series, false for movie public bool PromptForContentType(string? detectionHint = null) { var message = "Unable to confidently detect disc type"; @@ -41,4 +43,355 @@ public bool PromptForContentType(string? detectionHint = null) return isTv; } + + public PreviewResult ConfirmRipPlan(IReadOnlyList plans, ContentMetadata metadata, bool isTv, string? discName = null, int? season = null, int? episodeStart = null, IReadOnlySet? selectedTitleIds = null) + { + RenderPreview(plans, metadata, isTv, discName, season, episodeStart, selectedTitleIds); + + var choices = new List { "Proceed", "Edit title" }; + if (isTv) + choices.Add("Edit episode start"); + choices.Add("Edit output filenames"); + choices.Add("Select titles to include"); + choices.Add("Abort"); + + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("What would you like to do?") + .AddChoices(choices)); + + return selection switch + { + "Proceed" => PreviewResult.Proceed(), + "Abort" => PreviewResult.Abort(), + "Edit title" => PromptEditTitle(metadata), + "Edit episode start" => PromptEditEpisodeStart(episodeStart ?? 1), + "Edit output filenames" => PromptEditFilenames(plans), + "Select titles to include" => PromptSelectTitles(plans, selectedTitleIds), + _ => PreviewResult.Abort() + }; + } + + private void RenderPreview(IReadOnlyList plans, ContentMetadata metadata, bool isTv, string? discName, int? season, int? episodeStart, IReadOnlySet? selectedTitleIds) + { + AnsiConsole.Clear(); + + var metaGrid = new Grid(); + metaGrid.AddColumn(new GridColumn().PadRight(2)); + metaGrid.AddColumn(); + + if (!string.IsNullOrWhiteSpace(discName)) + metaGrid.AddRow("[dim]Disc:[/]", Markup.Escape(discName)); + + if (!string.IsNullOrWhiteSpace(metadata.Provider)) + { + var searchInfo = metadata.SearchTitle != null && metadata.SearchTitle != metadata.DiscTitle + ? $" (matched on [yellow]{Markup.Escape(metadata.SearchTitle)}[/])" + : ""; + metaGrid.AddRow("[dim]Lookup:[/]", $"{Markup.Escape(metadata.Provider)} → [bold]{Markup.Escape(metadata.Title)}[/]{(metadata.Year.HasValue ? $" ({metadata.Year.Value})" : "")}{searchInfo}"); + } + else + { + metaGrid.AddRow("[dim]Lookup:[/]", "[yellow]No provider match — using disc title as fallback[/]"); + } + + if (isTv) + { + var selectedPlans = selectedTitleIds != null + ? plans.Where(p => selectedTitleIds.Contains(p.TitleId)).ToList() + : plans.ToList(); + metaGrid.AddRow("[dim]Season:[/]", $"{season ?? 1}"); + metaGrid.AddRow("[dim]Episodes:[/]", $"E{episodeStart ?? 1:00}–E{(episodeStart ?? 1) + selectedPlans.Count - 1:00} ({selectedPlans.Count} title{(selectedPlans.Count != 1 ? "s" : "")})"); + } + + AnsiConsole.Write(metaGrid); + AnsiConsole.WriteLine(); + + var hasPartialSelection = selectedTitleIds != null && selectedTitleIds.Count < plans.Count; + + var table = new Table(); + table.Border(TableBorder.Rounded); + table.BorderColor(_theme.AccentColor); + var selectedCount = selectedTitleIds?.Count ?? plans.Count; + var titleSuffix = hasPartialSelection ? $" [{selectedCount}/{plans.Count} selected]" : ""; + table.Title($"[bold]{Markup.Escape(metadata.Title)}[/]{(metadata.Year.HasValue ? $" ({metadata.Year.Value})" : "")} — {(isTv ? "TV Series" : "Movie")}{titleSuffix}"); + + table.AddColumn(new TableColumn(" ").Centered()); + table.AddColumn(new TableColumn("#").Centered()); + table.AddColumn(new TableColumn("Title ID").Centered()); + table.AddColumn(new TableColumn("Duration").RightAligned()); + if (isTv) + { + table.AddColumn("Episode"); + } + table.AddColumn("Output Filename"); + + foreach (var plan in plans) + { + var isSelected = selectedTitleIds == null || selectedTitleIds.Contains(plan.TitleId); + var checkmark = isSelected ? $"[{_theme.Colors.Success}]✓[/]" : $"[{_theme.Colors.Muted}]✗[/]"; + var duration = DurationFormatter.Format(plan.DurationSeconds); + string Dim(string text) => isSelected ? text : $"[{_theme.Colors.Muted}]{Markup.Escape(text)}[/]"; + + var row = new List + { + checkmark, + Dim((plan.Index + 1).ToString()), + Dim(plan.TitleId.ToString()), + Dim(duration) + }; + if (isTv) + { + row.Add(Dim(plan.EpisodeNum.HasValue ? $"E{plan.EpisodeNum.Value:00}" : "-")); + } + row.Add(isSelected ? Markup.Escape(plan.FinalFileName) : $"[{_theme.Colors.Muted}]{Markup.Escape(plan.FinalFileName)}[/]"); + + table.AddRow(row.Select(r => new Markup(r)).ToArray()); + } + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + } + + private static PreviewResult PromptEditTitle(ContentMetadata metadata) + { + var newTitle = AnsiConsole.Prompt( + new TextPrompt("Enter new title:") + .DefaultValue(metadata.Title)); + + if (newTitle == metadata.Title) + return new PreviewResult { Action = PreviewAction.EditTitle }; + + return new PreviewResult { Action = PreviewAction.EditTitle, NewTitle = newTitle }; + } + + private static PreviewResult PromptEditEpisodeStart(int currentStart) + { + var newStart = AnsiConsole.Prompt( + new TextPrompt("Enter episode start number:") + .DefaultValue(currentStart) + .Validate(n => n > 0 ? ValidationResult.Success() : ValidationResult.Error("Must be a positive number"))); + + return new PreviewResult { Action = PreviewAction.EditEpisodeStart, NewEpisodeStart = newStart }; + } + + private static PreviewResult PromptSelectTitles(IReadOnlyList plans, IReadOnlySet? currentSelection) + { + static string BuildLabel(TitlePlan plan) => + Markup.Escape($"{plan.Index + 1}. {plan.FinalFileName} [{DurationFormatter.Format(plan.DurationSeconds)}]"); + + var prompt = new MultiSelectionPrompt() + .Title("Select titles to include:") + .PageSize(Math.Min(plans.Count + 3, 20)) + .InstructionsText("[grey](Press [blue][/] to toggle, [green][/] to confirm)[/]"); + + foreach (var plan in plans) + { + var label = BuildLabel(plan); + prompt.AddChoice(label); + if (currentSelection == null || currentSelection.Contains(plan.TitleId)) + prompt.Select(label); + } + + var selected = AnsiConsole.Prompt(prompt); + + // Map selected labels back to title IDs by position + var selectedIds = new HashSet(); + for (int i = 0; i < plans.Count; i++) + { + var label = BuildLabel(plans[i]); + if (selected.Contains(label)) + selectedIds.Add(plans[i].TitleId); + } + + return new PreviewResult { Action = PreviewAction.SelectTitles, SelectedTitleIds = selectedIds }; + } + + private static PreviewResult PromptEditFilenames(IReadOnlyList plans) + { + var choices = plans.Select((p, i) => $"{i + 1}. {p.FinalFileName}").ToList(); + choices.Add("Done"); + + var renamed = new Dictionary(); + + while (true) + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a file to rename (or Done):") + .PageSize(Math.Min(plans.Count + 2, 20)) + .AddChoices(choices)); + + if (selection == "Done") + break; + + var idx = choices.IndexOf(selection); + if (idx < 0 || idx >= plans.Count) + break; + + var plan = plans[idx]; + var currentName = renamed.TryGetValue(plan.TitleId, out var alreadyRenamed) + ? alreadyRenamed + : plan.FinalFileName; + + var newName = AnsiConsole.Prompt( + new TextPrompt($"New filename for #{idx + 1}:") + .DefaultValue(currentName)); + + if (!newName.EndsWith(".mkv", StringComparison.OrdinalIgnoreCase)) + newName += ".mkv"; + + renamed[plan.TitleId] = newName; + choices[idx] = $"{idx + 1}. {newName} [yellow](edited)[/]"; + } + + return new PreviewResult { Action = PreviewAction.EditFilenames, RenamedFiles = renamed.Count > 0 ? renamed : null }; + } + + public List ReviewFailures(IReadOnlyList failures) + { + // Summary table + AnsiConsole.WriteLine(); + var table = new Table(); + table.Border(TableBorder.Rounded); + table.BorderColor(_theme.ErrorColor); + table.Title($"[bold {_theme.Colors.Error}]{failures.Count} Title(s) Failed[/]"); + table.AddColumn(new TableColumn("#").Centered()); + table.AddColumn("Title"); + table.AddColumn("Phase"); + table.AddColumn("Error"); + + for (int i = 0; i < failures.Count; i++) + { + var f = failures[i]; + var errorPreview = f.ErrorMessage?.Split('\n', 2)[0].Trim() ?? "Unknown error"; + if (errorPreview.Length > 60) + errorPreview = errorPreview[..57] + "..."; + table.AddRow( + (i + 1).ToString(), + Markup.Escape(f.Plan.DisplayName), + f.FailedPhase?.ToString() ?? "Unknown", + Markup.Escape(errorPreview)); + } + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + + // Selection loop + var retryList = new List(); + const string saveLogs = "Save Logs (.zip)"; + const string done = "Done"; + var choices = failures + .Select((f, i) => $"{f.Plan.DisplayName} - {f.FailedPhase} failed") + .Append(saveLogs) + .Append(done) + .ToList(); + + while (true) + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a failure to inspect, or Done:") + .PageSize(Math.Min(failures.Count + 3, 20)) + .AddChoices(choices)); + + if (selection == done) + break; + + if (selection == saveLogs) + { + SaveLogsAsZip(failures); + continue; + } + + var idx = choices.IndexOf(selection); + if (idx < 0 || idx >= failures.Count) + break; + + var failure = failures[idx]; + RenderFailureDetail(failure); + + var action = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Action:") + .AddChoices("Retry", "Skip")); + + if (action == "Retry") + { + retryList.Add(failure); + choices[idx] = $"{failure.Plan.DisplayName} - [yellow]retry queued[/]"; + } + else + { + choices[idx] = $"{failure.Plan.DisplayName} - [dim]skipped[/]"; + } + } + + return retryList; + } + + private void SaveLogsAsZip(IReadOnlyList failures) + { + var logPaths = failures + .SelectMany(f => new[] { f.RipLogPath, f.EncodeLogPath }) + .Where(p => !string.IsNullOrWhiteSpace(p) && File.Exists(p)) + .Distinct() + .ToList(); + + if (logPaths.Count == 0) + { + _notifier.Warning("No log files found to save."); + return; + } + + var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + var zipPath = Path.Combine( + Path.GetDirectoryName(logPaths[0])!, + $"ripsharp-logs-{timestamp}.zip"); + + try + { + using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create); + foreach (var logPath in logPaths) + { + zip.CreateEntryFromFile(logPath!, Path.GetFileName(logPath!)); + } + + AnsiConsole.MarkupLine($"[green]Saved {logPaths.Count} log file(s) to:[/] {Markup.Escape(zipPath)}"); + } + catch (Exception ex) + { + _notifier.Warning($"Failed to create log archive: {ex.Message}"); + } + } + + private void RenderFailureDetail(TitleOutcome failure) + { + AnsiConsole.WriteLine(); + var grid = new Grid(); + grid.AddColumn(new GridColumn().PadRight(2)); + grid.AddColumn(); + grid.AddRow("[dim]Title:[/]", Markup.Escape(failure.Plan.DisplayName)); + grid.AddRow("[dim]Phase:[/]", failure.FailedPhase?.ToString() ?? "Unknown"); + if (!string.IsNullOrWhiteSpace(failure.Command)) + grid.AddRow("[dim]Command:[/]", $"[yellow]{Markup.Escape(failure.Command)}[/]"); + if (!string.IsNullOrWhiteSpace(failure.RipLogPath)) + grid.AddRow("[dim]Rip log:[/]", Markup.Escape(failure.RipLogPath)); + if (!string.IsNullOrWhiteSpace(failure.EncodeLogPath)) + grid.AddRow("[dim]Encode log:[/]", Markup.Escape(failure.EncodeLogPath)); + AnsiConsole.Write(grid); + + if (failure.ErrorLines is { Count: > 0 }) + { + AnsiConsole.WriteLine(); + var panel = new Panel( + string.Join(Environment.NewLine, failure.ErrorLines.TakeLast(15).Select(Markup.Escape))) + .Header("[bold red]Error Details[/]") + .Border(BoxBorder.Rounded) + .BorderColor(Color.Red) + .Padding(1, 0); + AnsiConsole.Write(panel); + } + AnsiConsole.WriteLine(); + } } diff --git a/src/RipSharp/Utilities/SpectreProgressDisplay.cs b/src/RipSharp/Utilities/SpectreProgressDisplay.cs index 2f1d9bd..0e87d1f 100644 --- a/src/RipSharp/Utilities/SpectreProgressDisplay.cs +++ b/src/RipSharp/Utilities/SpectreProgressDisplay.cs @@ -20,7 +20,7 @@ public async Task ExecuteAsync(Func action) var liveContext = new LiveProgressContext(); await AnsiConsole.Live(Render(liveContext)) - .AutoClear(false) + .AutoClear(true) .Overflow(VerticalOverflow.Ellipsis) .StartAsync(async live => { @@ -51,48 +51,95 @@ await AnsiConsole.Live(Render(liveContext)) AnsiConsole.WriteException(ex); } - // Final render after completion - live.UpdateTarget(Render(liveContext)); }); } private IRenderable Render(LiveProgressContext ctx) { - var (ripTask, encodeTask, overallTask) = ctx.GetLatest(); + var (ripTask, encodeTasks, overallTask) = ctx.GetSnapshot(); - var ripPanel = new Panel(RenderTask(ripTask, "Ripping")) + var grid = new Grid(); + grid.AddColumn(new GridColumn() { NoWrap = true, Width = null }); + + grid.AddRow(new Panel(RenderTask(ripTask, "Ripping")) { Header = new PanelHeader("Ripping", Justify.Left), Border = BoxBorder.Rounded, BorderStyle = _theme.SuccessColor, Expand = true - }; + }); - var encodePanel = new Panel(RenderTask(encodeTask, "Encoding")) + var activeEncodeTasks = encodeTasks.Where(t => !t.IsStopped || t.Value > 0).ToList(); + if (activeEncodeTasks.Count <= 1) { - Header = new PanelHeader("Encoding", Justify.Left), - Border = BoxBorder.Rounded, - BorderStyle = _theme.HighlightColor, - Expand = true - }; + var task = activeEncodeTasks.FirstOrDefault() ?? encodeTasks.FirstOrDefault(); + grid.AddRow(new Panel(RenderTask(task, "Encoding")) + { + Header = new PanelHeader("Encoding", Justify.Left), + Border = BoxBorder.Rounded, + BorderStyle = _theme.HighlightColor, + Expand = true + }); + } + else + { + for (int i = 0; i < activeEncodeTasks.Count; i++) + { + grid.AddRow(new Panel(RenderTask(activeEncodeTasks[i], "Encoding")) + { + Header = new PanelHeader($"Encoding [[{i + 1}]]", Justify.Left), + Border = BoxBorder.Rounded, + BorderStyle = _theme.HighlightColor, + Expand = true + }); + } + } - var overallPanel = new Panel(RenderTask(overallTask, "Overall")) + grid.AddRow(new Panel(RenderOverallTask(overallTask)) { - Header = new PanelHeader("Overall Progress", Justify.Left), - Border = BoxBorder.Rounded, + Header = new PanelHeader("[bold]Overall Progress[/]", Justify.Left), + Border = BoxBorder.Heavy, BorderStyle = _theme.AccentColor, Expand = true - }; - - var grid = new Grid(); - grid.AddColumn(new GridColumn() { NoWrap = true, Width = null }); - grid.AddRow(ripPanel); - grid.AddRow(encodePanel); - grid.AddRow(overallPanel); + }); return grid; } + private IRenderable RenderOverallTask(LiveTask? task) + { + if (task == null) + { + return new Text("Waiting to start...", new Style(_theme.MutedColor)); + } + + var percent = task.MaxValue > 0 ? Math.Clamp((double)task.Value / task.MaxValue, 0, 1) : 0; + const int barWidth = 80; + var filled = (int)Math.Round(percent * barWidth); + var empty = Math.Max(0, barWidth - filled); + var filledBar = new string('█', filled); + var emptyBar = new string('░', empty); + var pctText = (percent * 100).ToString("0.0").PadLeft(6); + + var elapsed = task.GetElapsed(); + var elapsedStr = elapsed.TotalSeconds > 0 ? FormatTimeSpan(elapsed) : "00:00:00"; + + var stepsCompleted = task.Value; + var stepsTotal = task.MaxValue; + var timeInfo = $"{stepsCompleted}/{stepsTotal} steps {elapsedStr} elapsed"; + + var barRenderable = new Markup($"[green]{filledBar}[/][{_theme.Colors.Muted}]{emptyBar}[/]"); + + var progressBar = new Columns(new IRenderable[] + { + barRenderable, + new Text($"{pctText}%", _theme.AccentColor), + new Text($" {timeInfo}", _theme.MutedColor) + }); + + return progressBar; + } + private IRenderable RenderTask(LiveTask? task, string label) { if (task == null) @@ -108,12 +155,15 @@ private IRenderable RenderTask(LiveTask? task, string label) var emptyBar = new string('░', empty); var pctText = (percent * 100).ToString("0.0").PadLeft(6); - // Calculate elapsed and remaining time var elapsed = task.GetElapsed(); var elapsedStr = elapsed.TotalSeconds > 0 ? FormatTimeSpan(elapsed) : "00:00:00"; var remainingStr = "--:--:--"; - if (percent > 0.05 && !task.IsStopped) // Only estimate if we have meaningful progress (>5%) + if (task.IsFailed) + { + remainingStr = "--:--:--"; + } + else if (percent > 0.02 && !task.IsStopped) { var totalEstimated = elapsed.TotalSeconds / percent; var remaining = TimeSpan.FromSeconds(totalEstimated - elapsed.TotalSeconds); @@ -126,8 +176,8 @@ private IRenderable RenderTask(LiveTask? task, string label) var timeInfo = $"{elapsedStr} / {remainingStr}"; - // Combine filled and empty bars with different colors - var barRenderable = new Markup($"[{_theme.Colors.Success}]{filledBar}[/][{_theme.Colors.Muted}]{emptyBar}[/]"); + var barColor = task.IsFailed ? _theme.Colors.Error : _theme.Colors.Success; + var barRenderable = new Markup($"[{barColor}]{filledBar}[/][{_theme.Colors.Muted}]{emptyBar}[/]"); var progressBar = new Columns(new IRenderable[] { @@ -142,10 +192,11 @@ private IRenderable RenderTask(LiveTask? task, string label) return progressBar; } + var msgStyle = task.IsFailed ? new Style(_theme.ErrorColor) : new Style(_theme.MutedColor); var rows = new List { progressBar }; foreach (var msg in messages) { - rows.Add(new Text(msg, new Style(_theme.MutedColor))); + rows.Add(new Text(msg, msgStyle)); } return new Rows(rows); @@ -159,7 +210,7 @@ private static string FormatTimeSpan(TimeSpan ts) private class LiveProgressContext : IProgressContext { private readonly List _tasks = new(); - private readonly object _lock = new(); + private readonly Lock _lock = new(); public IProgressTask AddTask(string description, long maxValue) { @@ -171,27 +222,38 @@ public IProgressTask AddTask(string description, long maxValue) return task; } - public (LiveTask? ripTask, LiveTask? encodeTask, LiveTask? overallTask) GetLatest() + public (LiveTask? RipTask, List EncodeTasks, LiveTask? OverallTask) GetSnapshot() { lock (_lock) { - // Assume first task is ripping, second is encoding, third is overall - var ripTask = _tasks.Count > 0 ? _tasks[0] : null; - var encodeTask = _tasks.Count > 1 ? _tasks[1] : null; - var overallTask = _tasks.Count > 2 ? _tasks[2] : null; - return (ripTask, encodeTask, overallTask); + LiveTask? ripTask = null; + var encodeTasks = new List(); + LiveTask? overallTask = null; + + foreach (var task in _tasks) + { + if (task.Description.StartsWith("Ripping") || task.Description == "Ripping") + ripTask = task; + else if (task.Description.StartsWith("Overall") || task.Description == "Overall") + overallTask = task; + else + encodeTasks.Add(task); + } + + return (ripTask, encodeTasks, overallTask); } } } private class LiveTask : IProgressTask { - private readonly object _lock = new(); + private readonly Lock _lock = new(); private long _value; private readonly long _maxValue; private string _description; private readonly List _messages = new(); private bool _isStopped; + private bool _isFailed; private DateTime? _startTime; private DateTime? _stopTime; @@ -207,6 +269,11 @@ public bool IsStopped get { lock (_lock) { return _isStopped; } } } + public bool IsFailed + { + get { lock (_lock) { return _isFailed; } } + } + public TimeSpan GetElapsed() { lock (_lock) @@ -233,6 +300,7 @@ public long Value _startTime = null; _stopTime = null; _isStopped = false; + _isFailed = false; return; } // Start tracking time when task first gets progress @@ -267,6 +335,14 @@ public string Description set { lock (_lock) { _description = value; } } } + public void StartTracking() + { + lock (_lock) + { + _startTime ??= DateTime.UtcNow; + } + } + public void StopTask() { lock (_lock) @@ -281,6 +357,17 @@ public void StopTask() } } + public void FailTask() + { + lock (_lock) + { + if (_isFailed) return; + _isFailed = true; + _isStopped = true; + _stopTime ??= DateTime.UtcNow; + } + } + public void AddMessage(string message) { lock (_lock)