diff --git a/AudioQualityEnhancer.Tests/AudioInfoTests.cs b/AudioQualityEnhancer.Tests/AudioInfoTests.cs new file mode 100644 index 0000000..54b4f51 --- /dev/null +++ b/AudioQualityEnhancer.Tests/AudioInfoTests.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using AudioQualityEnhancer.Models; +using AudioQualityEnhancer.Services; + +namespace AudioQualityEnhancer.Tests; + +public sealed class AudioInfoTests +{ + /// + /// AudioInfo subscribes to the localization service in its constructor so its display + /// strings follow a language change. That service lives for the whole session, so an + /// instance that is never disposed stays reachable through it for just as long. This + /// pins the contract that makes disposal matter. + /// + [Fact] + public void Dispose_StopsListeningForLanguageChanges() + { + var info = new AudioInfo { Codec = "mp3" }; + var notifications = 0; + var original = LocalizationService.Instance.Culture; + + try + { + // Start from a known culture. Another test class may have left the process on + // English, and assigning the same culture again raises no change at all. + LocalizationService.Instance.Culture = CultureInfo.GetCultureInfo("de"); + info.PropertyChanged += (_, _) => notifications++; + + LocalizationService.Instance.Culture = CultureInfo.GetCultureInfo("en"); + var whileSubscribed = notifications; + Assert.True(whileSubscribed > 0, "A language change has to reach a live instance."); + + info.Dispose(); + LocalizationService.Instance.Culture = CultureInfo.GetCultureInfo("de"); + + Assert.Equal(whileSubscribed, notifications); + } + finally + { + LocalizationService.Instance.Culture = original; + info.Dispose(); + } + } + + [Fact] + public void WithSelectedAudioStream_ReturnsTheSameInstanceWithoutStreams() + { + using var info = new AudioInfo { Codec = "mp3" }; + + // Returning this is what keeps BatchProcessingItem.SelectAudioStream from handing + // an instance to SetAudioInfo that would then be disposed and stored at once. + Assert.Same(info, info.WithSelectedAudioStream(null)); + } +} diff --git a/Services/AudioProcessingService.cs b/Services/AudioProcessingService.cs index a3baf55..21f7475 100644 --- a/Services/AudioProcessingService.cs +++ b/Services/AudioProcessingService.cs @@ -85,18 +85,32 @@ public async Task> ProcessAsync( _fileNameService.CleanupTemporaryOutputFiles(options.OutputDirectory, TimeSpan.FromDays(2)); var sourceInfo = options.SourceInfo; - if (sourceInfo is null) + if (sourceInfo is not null) { - Report(progress, 8, LocalizationService.Instance["Phase_AnalyzingSource"]); - var analysis = await _ffprobeService.AnalyzeAsync(options.InputPath, _logService.Info, cancellationToken); - if (analysis.IsFailure || analysis.Value is null) - { - return Result.Failure(analysis.ErrorMessage ?? LocalizationService.Instance["Error_SourceAnalysisFailed"], analysis.Exception); - } + // Supplied by the caller, so it belongs to the queue item and is disposed with it. + return await RenderAsync(options, sourceInfo, progress, cancellationToken); + } - sourceInfo = analysis.Value; + Report(progress, 8, LocalizationService.Instance["Phase_AnalyzingSource"]); + var analysis = await _ffprobeService.AnalyzeAsync(options.InputPath, _logService.Info, cancellationToken); + if (analysis.IsFailure || analysis.Value is null) + { + return Result.Failure(analysis.ErrorMessage ?? LocalizationService.Instance["Error_SourceAnalysisFailed"], analysis.Exception); } + // Created here, so disposed here. AudioInfo subscribes to the localization + // service in its constructor, and that service outlives the run, so an + // undisposed instance would stay reachable for the rest of the session. + using var ownedSourceInfo = analysis.Value; + return await RenderAsync(options, ownedSourceInfo, progress, cancellationToken); + } + + private async Task> RenderAsync( + ProcessingOptions options, + AudioInfo sourceInfo, + IProgress? progress, + CancellationToken cancellationToken) + { var diskSpaceCheck = EnsureSufficientDiskSpace(options.OutputDirectory, EstimateOutputSizeBytes(options, sourceInfo)); if (diskSpaceCheck.IsFailure) {