diff --git a/src/RipSharp.Tests/Metadata/MetadataServiceTests.cs b/src/RipSharp.Tests/Metadata/MetadataServiceTests.cs index 6490368..b5905ee 100644 --- a/src/RipSharp.Tests/Metadata/MetadataServiceTests.cs +++ b/src/RipSharp.Tests/Metadata/MetadataServiceTests.cs @@ -34,7 +34,7 @@ public async Task LookupAsync_Fallbacks_WhenNoApiKeys() { Environment.SetEnvironmentVariable("OMDB_API_KEY", null); Environment.SetEnvironmentVariable("TMDB_API_KEY", null); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var providers = new List(); var svc = new MetadataService(providers, notifier); @@ -49,7 +49,7 @@ public async Task LookupAsync_Fallbacks_WhenNoApiKeys() [Fact] public async Task LookupAsync_ReturnsFromFirstProvider_WhenMatch() { - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider1 = Substitute.For(); provider1.Name.Returns("Provider1"); provider1.LookupAsync("test", false, null).Returns(new ContentMetadata { Title = "Test Movie", Year = 2020, Type = "movie" }); @@ -72,7 +72,7 @@ public async Task LookupAsync_ReturnsFromFirstProvider_WhenMatch() [Fact] public async Task LookupAsync_TriesSecondProvider_WhenFirstReturnsNull() { - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider1 = Substitute.For(); provider1.Name.Returns("Provider1"); provider1.LookupAsync("test", false, null).Returns((ContentMetadata?)null); @@ -96,7 +96,7 @@ public async Task LookupAsync_TriesSecondProvider_WhenFirstReturnsNull() [Fact] public async Task LookupAsync_UsesTitleVariations_WhenOriginalFails() { - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = Substitute.For(); provider.Name.Returns("TestProvider"); provider.LookupAsync("MOVIE_TITLE_2023", Arg.Any(), Arg.Any()).Returns((ContentMetadata?)null); @@ -117,7 +117,7 @@ public async Task LookupAsync_UsesTitleVariations_WhenOriginalFails() [Fact] public async Task LookupAsync_ShowsDifferentMessage_ForSimplifiedTitle() { - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = Substitute.For(); provider.Name.Returns("TestProvider"); provider.LookupAsync("SIMPSONS_WS", Arg.Any(), Arg.Any()).Returns((ContentMetadata?)null); @@ -137,7 +137,7 @@ public async Task LookupAsync_ShowsDifferentMessage_ForSimplifiedTitle() [Fact] public async Task LookupAsync_ShowsNormalMessage_ForOriginalTitle() { - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = Substitute.For(); provider.Name.Returns("TestProvider"); provider.LookupAsync("Test Movie", Arg.Any(), Arg.Any()).Returns(new ContentMetadata { Title = "Test Movie", Year = 2020, Type = "movie" }); diff --git a/src/RipSharp.Tests/Metadata/OmdbMetadataProviderTests.cs b/src/RipSharp.Tests/Metadata/OmdbMetadataProviderTests.cs index 127e158..4fc6548 100644 --- a/src/RipSharp.Tests/Metadata/OmdbMetadataProviderTests.cs +++ b/src/RipSharp.Tests/Metadata/OmdbMetadataProviderTests.cs @@ -21,7 +21,7 @@ public async Task LookupAsync_ReturnsMetadata_WhenMovieFound() { var json = @"{""Response"":""True"",""Title"":""Inception"",""Year"":""2010"",""Type"":""movie""}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new OmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("inception", isTv: false, year: null); @@ -37,7 +37,7 @@ public async Task LookupAsync_ReturnsMetadata_WhenTvSeriesFound() { var json = @"{""Response"":""True"",""Title"":""Breaking Bad"",""Year"":""2008-2013"",""Type"":""series""}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new OmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("breaking bad", isTv: true, year: 2008); @@ -53,7 +53,7 @@ public async Task LookupAsync_ReturnsNull_WhenResponseIsFalse() { var json = @"{""Response"":""False"",""Error"":""Movie not found!""}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new OmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("nonexistent movie", isTv: false, year: null); @@ -66,7 +66,7 @@ public async Task LookupAsync_ReturnsNull_WhenJsonMalformed() { var json = @"{invalid json}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new OmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("test", isTv: false, year: null); @@ -79,7 +79,7 @@ public async Task LookupAsync_ReturnsNull_WhenHttpRequestFails() { var handler = new FakeHttpMessageHandler(HttpStatusCode.ServiceUnavailable); var httpClient = new HttpClient(handler); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new OmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("test", isTv: false, year: null); @@ -91,7 +91,7 @@ public async Task LookupAsync_ReturnsNull_WhenHttpRequestFails() public void Name_ReturnsOMDB() { var httpClient = new HttpClient(); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new OmdbMetadataProvider(httpClient, "test-key", notifier); // Act & Assert @@ -104,7 +104,7 @@ public async Task LookupAsync_IncludesYear_WhenProvided() var json = @"{""Response"":""True"",""Title"":""Dune"",""Year"":""2021"",""Type"":""movie""}"; var handler = new FakeHttpMessageHandler(json); var httpClient = new HttpClient(handler); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new OmdbMetadataProvider(httpClient, "test-key", notifier); await provider.LookupAsync("dune", isTv: false, year: 2021); @@ -119,7 +119,7 @@ public async Task LookupAsync_HandlesYearParsingFailure() { var json = @"{""Response"":""True"",""Title"":""Test"",""Year"":""N/A"",""Type"":""movie""}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new OmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("test", isTv: false, year: 2020); diff --git a/src/RipSharp.Tests/Metadata/TmdbMetadataProviderTests.cs b/src/RipSharp.Tests/Metadata/TmdbMetadataProviderTests.cs index 5502d15..42ef395 100644 --- a/src/RipSharp.Tests/Metadata/TmdbMetadataProviderTests.cs +++ b/src/RipSharp.Tests/Metadata/TmdbMetadataProviderTests.cs @@ -21,7 +21,7 @@ public async Task LookupAsync_ReturnsMetadata_WhenMovieFound() { var json = @"{""results"":[{""title"":""The Matrix"",""release_date"":""1999-03-31""}]}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("the matrix", isTv: false, year: null); @@ -37,7 +37,7 @@ public async Task LookupAsync_ReturnsMetadata_WhenTvSeriesFound() { var json = @"{""results"":[{""name"":""Game of Thrones"",""first_air_date"":""2011-04-17""}]}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("game of thrones", isTv: true, year: null); @@ -53,7 +53,7 @@ public async Task LookupAsync_ReturnsNull_WhenNoResultsFound() { var json = @"{""results"":[]}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("nonexistent movie", isTv: false, year: null); @@ -66,7 +66,7 @@ public async Task LookupAsync_ReturnsNull_WhenJsonMalformed() { var json = @"{invalid json}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("test", isTv: false, year: null); @@ -79,7 +79,7 @@ public async Task LookupAsync_ReturnsNull_WhenHttpRequestFails() { var handler = new FakeHttpMessageHandler(HttpStatusCode.ServiceUnavailable); var httpClient = new HttpClient(handler); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("test", isTv: false, year: null); @@ -91,7 +91,7 @@ public async Task LookupAsync_ReturnsNull_WhenHttpRequestFails() public void Name_ReturnsTMDB() { var httpClient = new HttpClient(); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); // Act & Assert @@ -103,7 +103,7 @@ public async Task LookupAsync_HandlesMovieWithoutReleaseDate() { var json = @"{""results"":[{""title"":""Upcoming Movie""}]}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("upcoming", isTv: false, year: 2025); @@ -118,7 +118,7 @@ public async Task LookupAsync_HandlesTvWithoutAirDate() { var json = @"{""results"":[{""name"":""New Series""}]}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("new series", isTv: true, year: 2026); @@ -133,7 +133,7 @@ public async Task LookupAsync_HandlesShortDateString() { var json = @"{""results"":[{""title"":""Test Movie"",""release_date"":""202""}]}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("test", isTv: false, year: 2020); @@ -147,7 +147,7 @@ public async Task LookupAsync_UsesFirstResult_WhenMultipleResults() { var json = @"{""results"":[{""title"":""First Movie"",""release_date"":""2020-01-01""},{""title"":""Second Movie"",""release_date"":""2021-01-01""}]}"; var httpClient = CreateHttpClient(json); - var notifier = Substitute.For(); + var notifier = Substitute.For(); var provider = new TmdbMetadataProvider(httpClient, "test-key", notifier); var result = await provider.LookupAsync("movie", isTv: false, year: null); diff --git a/src/RipSharp/Abstractions/IProgressNotifier.cs b/src/RipSharp/Abstractions/IConsoleWriter.cs similarity index 70% rename from src/RipSharp/Abstractions/IProgressNotifier.cs rename to src/RipSharp/Abstractions/IConsoleWriter.cs index 660ecc9..d0987cc 100644 --- a/src/RipSharp/Abstractions/IProgressNotifier.cs +++ b/src/RipSharp/Abstractions/IConsoleWriter.cs @@ -1,6 +1,9 @@ namespace RipSharp.Abstractions; -public interface IProgressNotifier +/// +/// Abstraction for writing styled output messages to the console. +/// +public interface IConsoleWriter { void Info(string message); void Success(string message); diff --git a/src/RipSharp/Abstractions/IProgressDisplay.cs b/src/RipSharp/Abstractions/IProgressDisplay.cs new file mode 100644 index 0000000..ac33d7c --- /dev/null +++ b/src/RipSharp/Abstractions/IProgressDisplay.cs @@ -0,0 +1,44 @@ +namespace RipSharp.Abstractions; + +/// +/// Abstraction for displaying progress bars and animated progress indicators. +/// +public interface IProgressDisplay +{ + /// + /// Starts a progress tracking context and executes the provided action. + /// + Task ExecuteAsync(Func action); +} + +/// +/// Context for managing multiple progress tasks. +/// +public interface IProgressContext +{ + /// + /// Adds a new progress task with the given description and maximum value. + /// + IProgressTask AddTask(string description, long maxValue); +} + +/// +/// Represents an individual progress task that can be updated. +/// +public interface IProgressTask +{ + /// + /// Gets or sets the current progress value. + /// + long Value { get; set; } + + /// + /// Gets or sets the task description (can include markup for styling). + /// + string Description { get; set; } + + /// + /// Stops the task, marking it as complete. + /// + void StopTask(); +} diff --git a/src/RipSharp/Core/Program.cs b/src/RipSharp/Core/Program.cs index ccd1e5b..1324c9c 100644 --- a/src/RipSharp/Core/Program.cs +++ b/src/RipSharp/Core/Program.cs @@ -11,7 +11,6 @@ using NetEscapades.Configuration.Yaml; -using Spectre.Console; namespace RipSharp.Core; @@ -29,7 +28,8 @@ public static async Task Main(string[] args) .ConfigureServices((ctx, services) => { services.Configure(ctx.Configuration); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -43,7 +43,7 @@ public static async Task Main(string[] args) services.AddSingleton>(sp => { - var notifier = sp.GetRequiredService(); + var notifier = sp.GetRequiredService(); var httpClient = new HttpClient(); var providers = new List(); @@ -59,7 +59,7 @@ public static async Task Main(string[] args) services.AddSingleton(sp => { - var notifier = sp.GetRequiredService(); + var notifier = sp.GetRequiredService(); if (!string.IsNullOrWhiteSpace(tvdbKey)) return new TvdbMetadataProvider(new HttpClient(), tvdbKey, notifier); return new NullEpisodeTitleProvider(); @@ -75,25 +75,26 @@ public static async Task Main(string[] args) if (options.ShowHelp) { - RipOptions.DisplayHelp(); + RipOptions.DisplayHelp(new ConsoleWriter()); return 0; } var ripper = host.Services.GetRequiredService(); + var writer = host.Services.GetRequiredService(); var files = await ripper.ProcessDiscAsync(options); if (files.Count > 0) { - AnsiConsole.MarkupLine($"[{ConsoleColors.Success}]Success! Files created:[/]"); + writer.Success("Success! Files created:"); foreach (var f in files) { - AnsiConsole.WriteLine(Markup.Escape(f)); + writer.Plain(f); } return 0; } else { - AnsiConsole.MarkupLine($"[{ConsoleColors.Error}]Failed to process disc[/]"); + writer.Error("Failed to process disc"); return 1; } } diff --git a/src/RipSharp/Core/RipOptions.cs b/src/RipSharp/Core/RipOptions.cs index b8e3b76..1490744 100644 --- a/src/RipSharp/Core/RipOptions.cs +++ b/src/RipSharp/Core/RipOptions.cs @@ -79,48 +79,48 @@ public static RipOptions ParseArgs(string[] args) return opts; } - public static void DisplayHelp() + public static void DisplayHelp(IConsoleWriter writer) { - Console.WriteLine("media-encoding - DVD/Blu-Ray/UHD disc ripping tool"); - Console.WriteLine(); - Console.WriteLine("USAGE:"); - Console.WriteLine(" dotnet run --project src/MediaEncoding -- [OPTIONS]"); - Console.WriteLine(); - Console.WriteLine("REQUIRED OPTIONS:"); - Console.WriteLine(" --output PATH Output directory for ripped files"); - Console.WriteLine(); - Console.WriteLine("OPTIONS:"); - Console.WriteLine(" --mode auto|movie|tv Content type detection (default: auto)"); - Console.WriteLine(" - auto: Automatically detect movie vs TV series"); - Console.WriteLine(" - movie: Treat as single movie"); - Console.WriteLine(" - tv: Treat as TV series"); - Console.WriteLine(" --disc PATH Optical drive path (default: disc:0)"); - Console.WriteLine(" --temp PATH Temporary ripping directory (default: {output}/.makemkv)"); - Console.WriteLine(" --title TEXT Custom title for file naming"); - Console.WriteLine(" --year YYYY Release year (movies only)"); - Console.WriteLine(" --season N Season number (TV only, default: 1)"); - Console.WriteLine(" --episode-start N Starting episode number (TV only, default: 1)"); - Console.WriteLine(" --disc-type TYPE Override disc type: dvd|bd|uhd (auto-detect by default)"); - Console.WriteLine(" --debug Enable debug logging"); - Console.WriteLine(" -h, --help Show this help message"); - Console.WriteLine(); - Console.WriteLine("EXAMPLES:"); - Console.WriteLine(" # Rip with auto-detection (recommended)"); - Console.WriteLine(" dotnet run --project src/MediaEncoding -- --output ~/Movies --title \"The Matrix\" --year 1999"); - Console.WriteLine(); - Console.WriteLine(" # Rip a movie (explicit)"); - Console.WriteLine(" dotnet run --project src/MediaEncoding -- --output ~/Movies --mode movie --title \"The Matrix\" --year 1999"); - Console.WriteLine(); - Console.WriteLine(" # Rip a TV season (explicit)"); - Console.WriteLine(" dotnet run --project src/MediaEncoding -- --output ~/TV --mode tv --title \"Breaking Bad\" --season 1"); - Console.WriteLine(); - Console.WriteLine(" # Use second disc drive"); - Console.WriteLine(" dotnet run --project src/MediaEncoding -- --output ~/Movies --disc disc:1"); - Console.WriteLine(); - Console.WriteLine("ENVIRONMENT VARIABLES:"); - Console.WriteLine(" TMDB_API_KEY TMDB API key for metadata lookup (recommended)"); - Console.WriteLine(" OMDB_API_KEY OMDB API key for metadata lookup (optional)"); - Console.WriteLine(); - Console.WriteLine("For more information, visit: https://github.com/mapitman/media-encoding"); + writer.Plain("ripsharp - DVD/Blu-Ray/UHD disc ripping tool"); + writer.Plain(""); + writer.Plain("USAGE:"); + writer.Plain(" dotnet run --project src/RipSharp -- [OPTIONS]"); + writer.Plain(""); + writer.Plain("REQUIRED OPTIONS:"); + writer.Plain(" --output PATH Output directory for ripped files"); + writer.Plain(""); + writer.Plain("OPTIONS:"); + writer.Plain(" --mode auto|movie|tv Content type detection (default: auto)"); + writer.Plain(" - auto: Automatically detect movie vs TV series"); + writer.Plain(" - movie: Treat as single movie"); + writer.Plain(" - tv: Treat as TV series"); + writer.Plain(" --disc PATH Optical drive path (default: disc:0)"); + writer.Plain(" --temp PATH Temporary ripping directory (default: {output}/.makemkv)"); + writer.Plain(" --title TEXT Custom title for file naming"); + writer.Plain(" --year YYYY Release year (movies only)"); + writer.Plain(" --season N Season number (TV only, default: 1)"); + writer.Plain(" --episode-start N Starting episode number (TV only, default: 1)"); + writer.Plain(" --disc-type TYPE Override disc type: dvd|bd|uhd (auto-detect by default)"); + writer.Plain(" --debug Enable debug logging"); + writer.Plain(" -h, --help Show this help message"); + writer.Plain(""); + writer.Plain("EXAMPLES:"); + writer.Plain(" # Rip with auto-detection (recommended)"); + writer.Plain(" dotnet run --project src/RipSharp -- --output ~/Movies --title \"The Matrix\" --year 1999"); + writer.Plain(""); + writer.Plain(" # Rip a movie (explicit)"); + writer.Plain(" dotnet run --project src/RipSharp -- --output ~/Movies --mode movie --title \"The Matrix\" --year 1999"); + writer.Plain(""); + writer.Plain(" # Rip a TV season (explicit)"); + writer.Plain(" dotnet run --project src/RipSharp -- --output ~/TV --mode tv --title \"Breaking Bad\" --season 1"); + writer.Plain(""); + writer.Plain(" # Use second disc drive"); + writer.Plain(" dotnet run --project src/RipSharp -- --output ~/Movies --disc disc:1"); + writer.Plain(""); + writer.Plain("ENVIRONMENT VARIABLES:"); + writer.Plain(" TMDB_API_KEY TMDB API key for metadata lookup (recommended)"); + writer.Plain(" OMDB_API_KEY OMDB API key for metadata lookup (optional)"); + writer.Plain(""); + writer.Plain("For more information, visit: https://github.com/mapitman/ripsharp"); } } diff --git a/src/RipSharp/MakeMkv/MakeMkvOutputHandler.cs b/src/RipSharp/MakeMkv/MakeMkvOutputHandler.cs index f05e145..7734d0b 100644 --- a/src/RipSharp/MakeMkv/MakeMkvOutputHandler.cs +++ b/src/RipSharp/MakeMkv/MakeMkvOutputHandler.cs @@ -2,8 +2,6 @@ using System.IO; using System.Text.RegularExpressions; -using Spectre.Console; - namespace RipSharp.MakeMkv; public class MakeMkvOutputHandler @@ -11,13 +9,14 @@ public class MakeMkvOutputHandler private readonly long _expectedBytes; private readonly int _index; private readonly int _totalTitles; - private readonly ProgressTask _task; + private readonly IProgressTask _task; private readonly string _progressLogPath; private readonly string _rawLogPath; + private readonly IConsoleWriter _writer; public double LastBytesProcessed { get; private set; } - public MakeMkvOutputHandler(long expectedBytes, int index, int totalTitles, ProgressTask task, string progressLogPath, string rawLogPath) + public MakeMkvOutputHandler(long expectedBytes, int index, int totalTitles, IProgressTask task, string progressLogPath, string rawLogPath, IConsoleWriter writer) { _expectedBytes = expectedBytes; _index = index; @@ -25,6 +24,7 @@ public MakeMkvOutputHandler(long expectedBytes, int index, int totalTitles, Prog _task = task; _progressLogPath = progressLogPath; _rawLogPath = rawLogPath; + _writer = writer; } public void HandleLine(string line) @@ -39,7 +39,7 @@ public void HandleLine(string line) if (_expectedBytes > 0 && bytesProcessed <= 1.0) bytesProcessed *= _expectedBytes; // fraction -> bytes bytesProcessed = Math.Max(0, _expectedBytes > 0 ? Math.Min(_expectedBytes, bytesProcessed) : bytesProcessed); - _task.Value = bytesProcessed; + _task.Value = (long)bytesProcessed; LastBytesProcessed = bytesProcessed; TryAppend(_progressLogPath, $"PRGV {bytesProcessed:F0}\n"); } @@ -55,7 +55,7 @@ public void HandleLine(string line) } } - private static void TryAppend(string path, string content) + private void TryAppend(string path, string content) { try { @@ -64,8 +64,7 @@ private static void TryAppend(string path, string content) catch (Exception ex) { // Best-effort logging: do not rethrow, but make failures visible. - AnsiConsole.MarkupLine( - $"[red]Failed to append to log file '{Markup.Escape(path)}': {Markup.Escape(ex.Message)}[/]"); + _writer.Error($"Failed to append to log file '{path}': {ex.Message}"); } } } diff --git a/src/RipSharp/MakeMkv/ScanOutputHandler.cs b/src/RipSharp/MakeMkv/ScanOutputHandler.cs index 72a2d63..2f24f8d 100644 --- a/src/RipSharp/MakeMkv/ScanOutputHandler.cs +++ b/src/RipSharp/MakeMkv/ScanOutputHandler.cs @@ -6,7 +6,7 @@ namespace RipSharp.MakeMkv; public class ScanOutputHandler { - private readonly IProgressNotifier _notifier; + private readonly IConsoleWriter _notifier; private readonly List _titles; private string? _discName; private string? _discType; @@ -16,7 +16,7 @@ public class ScanOutputHandler private int _titleAddedCount; private readonly HashSet _printedTitles = new(); - public ScanOutputHandler(IProgressNotifier notifier, List titles) + public ScanOutputHandler(IConsoleWriter notifier, List titles) { _notifier = notifier; _titles = titles; diff --git a/src/RipSharp/Metadata/MetadataService.cs b/src/RipSharp/Metadata/MetadataService.cs index 54f85b7..23698a5 100644 --- a/src/RipSharp/Metadata/MetadataService.cs +++ b/src/RipSharp/Metadata/MetadataService.cs @@ -7,9 +7,9 @@ namespace RipSharp.Metadata; public class MetadataService : IMetadataService { private readonly List _providers; - private readonly IProgressNotifier _notifier; + private readonly IConsoleWriter _notifier; - public MetadataService(IEnumerable providers, IProgressNotifier notifier) + public MetadataService(IEnumerable providers, IConsoleWriter notifier) { _notifier = notifier; _providers = providers.ToList(); diff --git a/src/RipSharp/Metadata/OmdbMetadataProvider.cs b/src/RipSharp/Metadata/OmdbMetadataProvider.cs index 0501524..0dfa3ed 100644 --- a/src/RipSharp/Metadata/OmdbMetadataProvider.cs +++ b/src/RipSharp/Metadata/OmdbMetadataProvider.cs @@ -9,11 +9,11 @@ public class OmdbMetadataProvider : IMetadataProvider { private readonly HttpClient _http; private readonly string _apiKey; - private readonly IProgressNotifier _notifier; + private readonly IConsoleWriter _notifier; public string Name => "OMDB"; - public OmdbMetadataProvider(HttpClient http, string apiKey, IProgressNotifier notifier) + public OmdbMetadataProvider(HttpClient http, string apiKey, IConsoleWriter notifier) { _http = http; _apiKey = apiKey; diff --git a/src/RipSharp/Metadata/TmdbMetadataProvider.cs b/src/RipSharp/Metadata/TmdbMetadataProvider.cs index 483ccd0..4be2dd0 100644 --- a/src/RipSharp/Metadata/TmdbMetadataProvider.cs +++ b/src/RipSharp/Metadata/TmdbMetadataProvider.cs @@ -9,11 +9,11 @@ public class TmdbMetadataProvider : IMetadataProvider { private readonly HttpClient _http; private readonly string _apiKey; - private readonly IProgressNotifier _notifier; + private readonly IConsoleWriter _notifier; public string Name => "TMDB"; - public TmdbMetadataProvider(HttpClient http, string apiKey, IProgressNotifier notifier) + public TmdbMetadataProvider(HttpClient http, string apiKey, IConsoleWriter notifier) { _http = http; _apiKey = apiKey; diff --git a/src/RipSharp/Metadata/TvdbMetadataProvider.cs b/src/RipSharp/Metadata/TvdbMetadataProvider.cs index 67c5fc9..a6af9c7 100644 --- a/src/RipSharp/Metadata/TvdbMetadataProvider.cs +++ b/src/RipSharp/Metadata/TvdbMetadataProvider.cs @@ -16,7 +16,7 @@ public class TvdbMetadataProvider : IMetadataProvider, ITvEpisodeTitleProvider { private readonly HttpClient _http; private readonly string _apiKey; - private readonly IProgressNotifier _notifier; + private readonly IConsoleWriter _notifier; private string? _token; private DateTime _tokenExpiryUtc = DateTime.MinValue; @@ -24,7 +24,7 @@ public class TvdbMetadataProvider : IMetadataProvider, ITvEpisodeTitleProvider public string Name => "TVDB"; - public TvdbMetadataProvider(HttpClient http, string apiKey, IProgressNotifier notifier) + public TvdbMetadataProvider(HttpClient http, string apiKey, IConsoleWriter notifier) { _http = http; _apiKey = apiKey; diff --git a/src/RipSharp/Services/DiscRipper.cs b/src/RipSharp/Services/DiscRipper.cs index 935b357..b1675cc 100644 --- a/src/RipSharp/Services/DiscRipper.cs +++ b/src/RipSharp/Services/DiscRipper.cs @@ -5,7 +5,6 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; -using Spectre.Console; namespace RipSharp.Services; @@ -14,12 +13,13 @@ public class DiscRipper : IDiscRipper private readonly IDiscScanner _scanner; private readonly IEncoderService _encoder; private readonly IMetadataService _metadata; - private readonly IProgressNotifier _notifier; + private readonly IConsoleWriter _notifier; private readonly IMakeMkvService _makeMkv; private readonly IUserPrompt _userPrompt; private readonly ITvEpisodeTitleProvider _episodeTitles; + private readonly IProgressDisplay _progressDisplay; - public DiscRipper(IDiscScanner scanner, IEncoderService encoder, IMetadataService metadata, IMakeMkvService makeMkv, IProgressNotifier notifier, IUserPrompt userPrompt, ITvEpisodeTitleProvider episodeTitles) + public DiscRipper(IDiscScanner scanner, IEncoderService encoder, IMetadataService metadata, IMakeMkvService makeMkv, IConsoleWriter notifier, IUserPrompt userPrompt, ITvEpisodeTitleProvider episodeTitles, IProgressDisplay progressDisplay) { _scanner = scanner; _encoder = encoder; @@ -28,6 +28,7 @@ public DiscRipper(IDiscScanner scanner, IEncoderService encoder, IMetadataServic _notifier = notifier; _userPrompt = userPrompt; _episodeTitles = episodeTitles; + _progressDisplay = progressDisplay; } public async Task> ProcessDiscAsync(RipOptions options) @@ -140,82 +141,69 @@ 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); - await AnsiConsole.Progress() - .AutoRefresh(true) - .AutoClear(false) - .HideCompleted(false) - .Columns(new ProgressColumn[] - { - new TaskDescriptionColumn(), - new ElapsedTimeColumn { Style = Color.Green }, - new ProgressBarColumn(), - new PercentageColumn{ Style = Color.Yellow }, - new RemainingTimeColumn { Style = Color.Blue }, - new SpinnerColumn(), - }) - .StartAsync(async ctx => - { - var expectedBytes = titleInfo?.ReportedSizeBytes ?? 0; - var maxValue = expectedBytes > 0 ? expectedBytes : 100; - var task = ctx.AddTask($"[{ConsoleColors.Success}]Title {idx + 1} ({idx + 1}/{totalTitles})[/]", maxValue: maxValue); - bool ripDone = false; + await _progressDisplay.ExecuteAsync(async ctx => + { + var expectedBytes = titleInfo?.ReportedSizeBytes ?? 0; + var maxValue = expectedBytes > 0 ? expectedBytes : 100; + var task = ctx.AddTask($"[{ConsoleColors.Success}]Title {idx + 1} ({idx + 1}/{totalTitles})[/]", maxValue); + bool ripDone = false; - var pollTask = Task.Run(async () => + var pollTask = Task.Run(async () => + { + double lastSizeLocal = 0; + string? currentMkv = null; + while (!ripDone) { - double lastSizeLocal = 0; - string? currentMkv = null; - while (!ripDone) + try { - try + // Identify the mkv file being written for this title: the first new mkv not in existingFiles + if (currentMkv == null) { - // Identify the mkv file being written for this title: the first new mkv not in existingFiles - if (currentMkv == null) - { - currentMkv = Directory - .EnumerateFiles(options.Temp!, "*.mkv") - .FirstOrDefault(f => !existingFiles.Contains(f)); - } - - if (currentMkv != null && expectedBytes > 0) - { - var size = new FileInfo(currentMkv).Length; - lastSizeLocal = Math.Max(lastSizeLocal, size); - task.Value = Math.Min(expectedBytes, lastSizeLocal); - } + currentMkv = Directory + .EnumerateFiles(options.Temp!, "*.mkv") + .FirstOrDefault(f => !existingFiles.Contains(f)); } - catch { } - await Task.Delay(1000); - } - }); - var rawLogPath = Path.Combine(options.Temp!, $"makemkv_title_{titleId:D2}.log"); - var handler = new MakeMkvOutputHandler(expectedBytes, idx, totalTitles, task, progressLogPath, rawLogPath); - var exit = await _makeMkv.RipTitleAsync(options.Disc, titleId, options.Temp!, - onOutput: handler.HandleLine, - onError: errLine => - { - if (!(errLine.StartsWith("PRGV:") || errLine.StartsWith("PRGC:"))) + if (currentMkv != null && expectedBytes > 0) { - _notifier.Error(errLine); + var size = new FileInfo(currentMkv).Length; + lastSizeLocal = Math.Max(lastSizeLocal, size); + task.Value = (long)Math.Min(expectedBytes, lastSizeLocal); } - handler.HandleLine(errLine); - }); - ripDone = true; - try { await pollTask; } catch { } - - if (exit != 0) - { - task.Description = $"[{ConsoleColors.Error}]Failed: Title {titleId}[/]"; - task.StopTask(); - _notifier.Error($"Failed to rip title {titleId}"); - return; + } + catch { } + await Task.Delay(1000); } - if (handler.LastBytesProcessed < maxValue) + }); + + var rawLogPath = Path.Combine(options.Temp!, $"makemkv_title_{titleId:D2}.log"); + var handler = new MakeMkvOutputHandler(expectedBytes, idx, totalTitles, task, progressLogPath, rawLogPath, _notifier); + var exit = await _makeMkv.RipTitleAsync(options.Disc, titleId, options.Temp!, + onOutput: handler.HandleLine, + onError: errLine => { - task.Value = maxValue; - } + if (!(errLine.StartsWith("PRGV:") || errLine.StartsWith("PRGC:"))) + { + _notifier.Error(errLine); + } + handler.HandleLine(errLine); + }); + ripDone = true; + try { await pollTask; } catch { } + + if (exit != 0) + { + task.Description = $"[{ConsoleColors.Error}]Failed: Title {titleId}[/]"; task.StopTask(); - }); + _notifier.Error($"Failed to rip title {titleId}"); + return; + } + if (handler.LastBytesProcessed < maxValue) + { + task.Value = maxValue; + } + task.StopTask(); + }); var newFiles = Directory.EnumerateFiles(options.Temp!, "*.mkv").Where(f => !existingFiles.Contains(f)).ToList(); if (newFiles.Count > 0) diff --git a/src/RipSharp/Services/DiscScanner.cs b/src/RipSharp/Services/DiscScanner.cs index d9639a0..a97928e 100644 --- a/src/RipSharp/Services/DiscScanner.cs +++ b/src/RipSharp/Services/DiscScanner.cs @@ -8,10 +8,10 @@ namespace RipSharp.Services; public class DiscScanner : IDiscScanner { private readonly IProcessRunner _runner; - private readonly IProgressNotifier _notifier; + private readonly IConsoleWriter _notifier; private readonly IDiscTypeDetector _typeDetector; - public DiscScanner(IProcessRunner runner, IProgressNotifier notifier, IDiscTypeDetector typeDetector) + public DiscScanner(IProcessRunner runner, IConsoleWriter notifier, IDiscTypeDetector typeDetector) { _runner = runner; _notifier = notifier; diff --git a/src/RipSharp/Services/EncoderService.cs b/src/RipSharp/Services/EncoderService.cs index bac3101..dda8e3a 100644 --- a/src/RipSharp/Services/EncoderService.cs +++ b/src/RipSharp/Services/EncoderService.cs @@ -3,19 +3,20 @@ using System.Text.Json; using System.Threading.Tasks; -using Spectre.Console; namespace RipSharp.Services; public class EncoderService : IEncoderService { private readonly IProcessRunner _runner; - private readonly IProgressNotifier _notifier; + private readonly IConsoleWriter _notifier; + private readonly IProgressDisplay _progressDisplay; - public EncoderService(IProcessRunner runner, IProgressNotifier notifier) + public EncoderService(IProcessRunner runner, IConsoleWriter notifier, IProgressDisplay progressDisplay) { _runner = runner; _notifier = notifier; + _progressDisplay = progressDisplay; } public async Task AnalyzeAsync(string filePath) @@ -82,39 +83,25 @@ private async Task RunEncodingWithProgress(string ffmpegArgs, long duration { var exit = 0; - await AnsiConsole.Progress() - .Columns(new ProgressColumn[] - { - new TaskDescriptionColumn(), - new ElapsedTimeColumn { Style = CustomColors.Highlight }, - new ProgressBarColumn - { - CompletedStyle = CustomColors.Success, - RemainingStyle = CustomColors.Muted - }, - new PercentageColumn { Style = CustomColors.Info }, - new RemainingTimeColumn { Style = CustomColors.Accent }, - new SpinnerColumn(), - }) - .StartAsync(async ctx => - { - var task = ctx.AddTask($"[{ConsoleColors.Success}]Encoding ({ordinal}/{total})[/]", maxValue: durationTicks); + await _progressDisplay.ExecuteAsync(async ctx => + { + var task = ctx.AddTask($"[{ConsoleColors.Success}]Encoding ({ordinal}/{total})[/]", durationTicks); - exit = await _runner.RunAsync("ffmpeg", ffmpegArgs, - onOutput: _ => { }, // ffmpeg stdout - not used with -progress pipe:2 - onError: line => HandleEncodingProgress(line, task, durationTicks, ordinal, total)); + exit = await _runner.RunAsync("ffmpeg", ffmpegArgs, + onOutput: _ => { }, // ffmpeg stdout - not used with -progress pipe:2 + onError: line => HandleEncodingProgress(line, task, durationTicks, ordinal, total)); - if (exit == 0 && task.Value < durationTicks) - { - task.Value = durationTicks; - } - task.StopTask(); - }); + if (exit == 0 && task.Value < durationTicks) + { + task.Value = durationTicks; + } + task.StopTask(); + }); return exit; } - private static void HandleEncodingProgress(string line, Spectre.Console.ProgressTask task, long durationTicks, int ordinal, int total) + private void HandleEncodingProgress(string line, IProgressTask task, long durationTicks, int ordinal, int total) { // Progress lines come in key=value format on stderr // Use out_time_us (microseconds) for accurate progress tracking @@ -145,8 +132,7 @@ static bool IsProgressLine(string s) => s.StartsWith("dup_frames=") || s.StartsWith("drop_frames=") || s.StartsWith("progress="); if (string.IsNullOrWhiteSpace(line) || IsProgressLine(line)) return; - // Note: Still using AnsiConsole for error output during active progress display - AnsiConsole.MarkupLine($"[{ConsoleColors.Error}]❌ ffmpeg: {Markup.Escape(line)}[/]"); + _notifier.Error($"❌ ffmpeg: {line}"); } } diff --git a/src/RipSharp/Utilities/ConsoleUserPrompt.cs b/src/RipSharp/Utilities/ConsoleUserPrompt.cs index 719c980..5457c77 100644 --- a/src/RipSharp/Utilities/ConsoleUserPrompt.cs +++ b/src/RipSharp/Utilities/ConsoleUserPrompt.cs @@ -7,9 +7,9 @@ namespace RipSharp.Utilities; /// public class ConsoleUserPrompt : IUserPrompt { - private readonly IProgressNotifier _notifier; + private readonly IConsoleWriter _notifier; - public ConsoleUserPrompt(IProgressNotifier notifier) + public ConsoleUserPrompt(IConsoleWriter notifier) { _notifier = notifier; } diff --git a/src/RipSharp/Utilities/ConsoleProgressNotifier.cs b/src/RipSharp/Utilities/ConsoleWriter.cs similarity index 85% rename from src/RipSharp/Utilities/ConsoleProgressNotifier.cs rename to src/RipSharp/Utilities/ConsoleWriter.cs index 96c3186..1a760d0 100644 --- a/src/RipSharp/Utilities/ConsoleProgressNotifier.cs +++ b/src/RipSharp/Utilities/ConsoleWriter.cs @@ -2,7 +2,10 @@ namespace RipSharp.Utilities; -public class ConsoleProgressNotifier : IProgressNotifier +/// +/// Spectre.Console implementation for writing styled messages to the console. +/// +public class ConsoleWriter : IConsoleWriter { private static void WriteColored(string color, string message) => AnsiConsole.MarkupLine($"[{color}]{Markup.Escape(message)}[/]"); diff --git a/src/RipSharp/Utilities/SpectreProgressDisplay.cs b/src/RipSharp/Utilities/SpectreProgressDisplay.cs new file mode 100644 index 0000000..a5f14ee --- /dev/null +++ b/src/RipSharp/Utilities/SpectreProgressDisplay.cs @@ -0,0 +1,72 @@ +using Spectre.Console; + +namespace RipSharp.Utilities; + +/// +/// Spectre.Console implementation of progress display with rich terminal animations. +/// +public class SpectreProgressDisplay : IProgressDisplay +{ + public async Task ExecuteAsync(Func action) + { + await AnsiConsole.Progress() + .Columns(new ProgressColumn[] + { + new TaskDescriptionColumn(), + new ElapsedTimeColumn { Style = CustomColors.Highlight }, + new ProgressBarColumn + { + CompletedStyle = CustomColors.Success, + RemainingStyle = CustomColors.Muted + }, + new PercentageColumn { Style = CustomColors.Info }, + new RemainingTimeColumn { Style = CustomColors.Accent }, + new SpinnerColumn(), + }) + .StartAsync(async ctx => + { + var context = new SpectreProgressContext(ctx); + await action(context); + }); + } + + private class SpectreProgressContext : IProgressContext + { + private readonly ProgressContext _context; + + public SpectreProgressContext(ProgressContext context) + { + _context = context; + } + + public IProgressTask AddTask(string description, long maxValue) + { + var task = _context.AddTask(description, maxValue: maxValue); + return new SpectreProgressTask(task); + } + } + + private class SpectreProgressTask : IProgressTask + { + private readonly Spectre.Console.ProgressTask _task; + + public SpectreProgressTask(Spectre.Console.ProgressTask task) + { + _task = task; + } + + public long Value + { + get => (long)_task.Value; + set => _task.Value = value; + } + + public string Description + { + get => _task.Description; + set => _task.Description = value; + } + + public void StopTask() => _task.StopTask(); + } +}