diff --git a/App.xaml.cs b/App.xaml.cs
index 8f9f4ac..2bcfe7d 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -34,11 +34,22 @@ private static void OnDispatcherUnhandledException(object sender, System.Windows
{
var logPath = WriteCrashLog(e.Exception);
var loc = LocalizationService.Instance;
- System.Windows.MessageBox.Show(
- $"{loc["Error_AppCrash"]}{Environment.NewLine}{Environment.NewLine}{loc["Error_CrashLogSaved"]}{Environment.NewLine}{logPath}",
- "Audio Quality Enhancer",
- System.Windows.MessageBoxButton.OK,
- System.Windows.MessageBoxImage.Error);
+ var message = logPath is null
+ ? loc["Error_AppCrash"]
+ : $"{loc["Error_AppCrash"]}{Environment.NewLine}{Environment.NewLine}{loc["Error_CrashLogSaved"]}{Environment.NewLine}{logPath}";
+
+ try
+ {
+ System.Windows.MessageBox.Show(
+ message,
+ "Audio Quality Enhancer",
+ System.Windows.MessageBoxButton.OK,
+ System.Windows.MessageBoxImage.Error);
+ }
+ catch
+ {
+ // Reporting the crash must never prevent the orderly shutdown below.
+ }
e.Handled = true;
Current.Shutdown(-1);
@@ -58,17 +69,27 @@ private static void OnUnobservedTaskException(object? sender, UnobservedTaskExce
e.SetObserved();
}
- private static string WriteCrashLog(Exception exception)
+ /// Writes the crash log and returns its path, or null if it could not be written.
+ private static string? WriteCrashLog(Exception exception)
{
- var logDirectory = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "AudioQualityEnhancer",
- "Logs");
+ try
+ {
+ var logDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "AudioQualityEnhancer",
+ "Logs");
- Directory.CreateDirectory(logDirectory);
+ Directory.CreateDirectory(logDirectory);
- var logPath = Path.Combine(logDirectory, $"crash_{DateTime.Now:yyyyMMdd_HHmmss}.log");
- File.WriteAllText(logPath, exception.ToString());
- return logPath;
+ var logPath = Path.Combine(logDirectory, $"crash_{DateTime.Now:yyyyMMdd_HHmmss}.log");
+ File.WriteAllText(logPath, exception.ToString());
+ return logPath;
+ }
+ catch
+ {
+ // A failing crash log (read-only profile, full disk) must not throw inside
+ // the crash handler and turn a handled error into a hard termination.
+ return null;
+ }
}
}
diff --git a/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs b/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs
index 3cf444e..8c66edb 100644
--- a/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs
+++ b/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs
@@ -17,6 +17,19 @@ public void ParseVersion_StripsPrefixAndNormalizesToThreeParts(string? tag, stri
Assert.Equal(expected, AppUpdateService.ParseVersion(tag)?.ToString());
}
+ [Theory]
+ [InlineData("https://github.com/Kentarohakase/AudioQualityEnhancer/releases/tag/v0.18.0", "https://github.com/Kentarohakase/AudioQualityEnhancer/releases/tag/v0.18.0")]
+ [InlineData("http://github.com/Kentarohakase/AudioQualityEnhancer/releases", AppUpdateService.ReleasesPageUrl)]
+ [InlineData("https://example.com/releases", AppUpdateService.ReleasesPageUrl)]
+ [InlineData("file:///C:/Windows/System32/cmd.exe", AppUpdateService.ReleasesPageUrl)]
+ [InlineData("C:\\Windows\\System32\\cmd.exe", AppUpdateService.ReleasesPageUrl)]
+ [InlineData("", AppUpdateService.ReleasesPageUrl)]
+ [InlineData(null, AppUpdateService.ReleasesPageUrl)]
+ public void ResolveReleaseUrl_AcceptsOnlyHttpsProjectLinks(string? url, string expected)
+ {
+ Assert.Equal(expected, AppUpdateService.ResolveReleaseUrl(url));
+ }
+
[Theory]
[InlineData("0.16.0", "0.17.0", true)]
[InlineData("0.16.0", "0.16.1", true)]
diff --git a/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs b/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs
index e6165dc..9481c0b 100644
--- a/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs
+++ b/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs
@@ -175,6 +175,51 @@ public void BuildReport_PremiereProfileRequires48Khz()
Assert.Contains(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.SampleRateMismatch);
}
+ [Fact]
+ public void BuildReport_ArchivePresetKeepsSourceSampleRateWithPremiereSelected()
+ {
+ using var source = CreateInfo("flac", isLossy: false, sampleRate: 44_100);
+ using var output = CreateInfo("flac", isLossy: false, sampleRate: 44_100);
+ using var diagnostics = new AudioDiagnostics
+ {
+ IntegratedLoudnessLufs = -14,
+ TruePeakDb = -2
+ };
+
+ // The archive preset forces FLAC, so the selected Premiere profile (48 kHz)
+ // must not be validated against.
+ var report = AudioValidationService.BuildReport(
+ CreateOptions(source, AudioPreset.ArchiveExport, ExportFormat.PremierePro),
+ source,
+ output,
+ sourceDiagnostics: null,
+ diagnostics,
+ outputDiagnosticsSkipped: false,
+ outputPath: @"C:\audio\song_archive_flac.flac");
+
+ Assert.DoesNotContain(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.SampleRateMismatch);
+ }
+
+ [Fact]
+ public void BuildReport_StreamCopyKeepsSourceSampleRateWithPremiereSelected()
+ {
+ using var source = CreateInfo("mp3", isLossy: true, sampleRate: 44_100);
+ using var output = CreateInfo("mp3", isLossy: true, sampleRate: 44_100);
+
+ var report = AudioValidationService.BuildReport(
+ CreateOptions(source, AudioPreset.ExtractCopy, ExportFormat.PremierePro),
+ source,
+ output,
+ sourceDiagnostics: null,
+ outputDiagnostics: null,
+ outputDiagnosticsSkipped: true,
+ outputPath: @"C:\audio\song_extracted.mp3");
+
+ Assert.DoesNotContain(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.SampleRateMismatch);
+ Assert.DoesNotContain(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.LossyToLossless);
+ Assert.Contains(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.StreamCopyMetadataOnly);
+ }
+
[Fact]
public void BuildReport_ChannelCountChangeCreatesWarning()
{
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66dd35b..f375f31 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,10 +7,19 @@
- A result validation that ends with a critical finding now fails the affected file instead of reporting it as exported successfully. The report stays attached to the item, so the findings remain visible in the validation panel.
- A run that was stopped is no longer misreported: an inactivity timeout now ends the wait for the process directly and is reported as a timeout, while only an actual user cancellation is reported as one.
- After a process is terminated the app waits briefly for it to exit and stops the output readers before reading what was captured, so the collected output can no longer be read while it is still being written.
+- Result validation no longer expects 48 kHz when the Premiere profile is selected but the preset does not use it: the archive preset forces FLAC and a stream copy keeps the source format, so both reported a sample-rate warning for a correct export. A stream copy also no longer claims that a lossy source was written to a lossless format.
+- An unexpected error in an asynchronous command (start, download, analysis, retry, preview) is now logged and shown as status instead of shutting the app down; cancelling a running command is treated as a normal outcome.
+- A log file that cannot be written after a batch run (read-only or full output folder) is reported as a warning instead of ending the session through the crash handler. The exported files are already finished at that point.
+- The crash handler no longer fails when the crash log itself cannot be written and shows the error without a log path in that case.
### Changed
- The release packaging script now requires an explicit `-Version` in `major.minor.patch` form instead of falling back to a stale default version.
+- The tool check for FFmpeg, FFprobe and yt-dlp is bounded by a timeout: a binary that never answers is terminated and reported as unavailable instead of blocking the startup check indefinitely.
+- Reading the remaining tool output after a process exits is bounded as well, so a pipe that a child process keeps open cannot hang a run.
+- The update check accepts only an https link to the project host from the release API and otherwise falls back to the known releases page, because the link is handed to the shell when it is opened.
+
+## 0.17.0 - 2026-06-13
### Added
diff --git a/Resources/Strings.en.resx b/Resources/Strings.en.resx
index b0a1db7..d83fddb 100644
--- a/Resources/Strings.en.resx
+++ b/Resources/Strings.en.resx
@@ -545,4 +545,7 @@
Preview segment: loudest section starting at {0:0} seconds.
FFmpeg stopped responding and was terminated.
Not enough free disk space in the output folder (about {0} MB needed, {1} MB free).
+ {0} did not answer the check in time.
+ The log file could not be saved. The exported files are not affected.
+ Unexpected error: {0}
diff --git a/Resources/Strings.resx b/Resources/Strings.resx
index caf1aa0..55c3383 100644
--- a/Resources/Strings.resx
+++ b/Resources/Strings.resx
@@ -546,4 +546,7 @@
Vorschau-Abschnitt: lautester Bereich ab {0:0} Sekunden.
FFmpeg hat zu lange nicht reagiert und wurde beendet.
Zu wenig freier Speicherplatz im Ausgabeordner (benötigt etwa {0} MB, frei {1} MB).
+ {0} hat beim Prüfen nicht rechtzeitig geantwortet.
+ Die Logdatei konnte nicht gespeichert werden. Die Ausgabedateien sind davon nicht betroffen.
+ Unerwarteter Fehler: {0}
diff --git a/Services/AppUpdateService.cs b/Services/AppUpdateService.cs
index cd451db..3e07e62 100644
--- a/Services/AppUpdateService.cs
+++ b/Services/AppUpdateService.cs
@@ -13,10 +13,14 @@ public sealed class AppUpdateService
private const string LatestReleaseApi = "https://api.github.com/repos/Kentarohakase/AudioQualityEnhancer/releases/latest";
internal const string ReleasesPageUrl = "https://github.com/Kentarohakase/AudioQualityEnhancer/releases/latest";
+ // One shared client for the whole app: the check runs once per session and a new
+ // client per instance would leak its connection pool.
+ private static readonly HttpClient SharedHttpClient = new() { Timeout = TimeSpan.FromSeconds(10) };
+
private readonly HttpClient _httpClient;
public AppUpdateService()
- : this(new HttpClient { Timeout = TimeSpan.FromSeconds(10) })
+ : this(SharedHttpClient)
{
}
@@ -52,7 +56,7 @@ internal AppUpdateService(HttpClient httpClient)
}
var url = root.TryGetProperty("html_url", out var urlElement) ? urlElement.GetString() : null;
- return new AppUpdateInfo(latest.ToString(), string.IsNullOrWhiteSpace(url) ? ReleasesPageUrl : url!);
+ return new AppUpdateInfo(latest.ToString(), ResolveReleaseUrl(url));
}
catch
{
@@ -61,6 +65,24 @@ internal AppUpdateService(HttpClient httpClient)
}
}
+ ///
+ /// The release link is handed to the shell when the user clicks the update notice,
+ /// so only an https link on the project host is accepted. Anything else (other
+ /// schemes, another host, a missing field) falls back to the known releases page.
+ ///
+ internal static string ResolveReleaseUrl(string? url)
+ {
+ if (!string.IsNullOrWhiteSpace(url) &&
+ Uri.TryCreate(url.Trim(), UriKind.Absolute, out var parsed) &&
+ parsed.Scheme == Uri.UriSchemeHttps &&
+ string.Equals(parsed.Host, "github.com", StringComparison.OrdinalIgnoreCase))
+ {
+ return parsed.AbsoluteUri;
+ }
+
+ return ReleasesPageUrl;
+ }
+
internal static Version? ParseVersion(string? tag)
{
if (string.IsNullOrWhiteSpace(tag))
diff --git a/Services/AudioValidationService.cs b/Services/AudioValidationService.cs
index aeb8dbe..aa352bb 100644
--- a/Services/AudioValidationService.cs
+++ b/Services/AudioValidationService.cs
@@ -128,8 +128,10 @@ internal static AudioComparisonReport BuildReport(
AddPeakFindings(outputDiagnostics, findings);
AddLoudnessFinding(options, outputDiagnostics, findings);
+ // A stream copy keeps the source codec, so the selected export format says
+ // nothing about the result and the lossless note would be misleading.
var effectiveExportFormat = ExportFormat.ResolveForPreset(options.Preset, options.ExportFormat);
- if (sourceInfo.IsLikelyLossy && effectiveExportFormat.IsLossless)
+ if (!options.Preset.IsCopyOnly && sourceInfo.IsLikelyLossy && effectiveExportFormat.IsLossless)
{
AddFinding(findings, AudioComparisonFindingKind.LossyToLossless, AudioInsightSeverity.Info);
}
@@ -416,7 +418,15 @@ private static bool RequiresOutputDiagnostics(AudioPreset preset)
private static int? GetExpectedSampleRate(ProcessingOptions options)
{
- return options.ExportFormat.Id == ExportFormat.PremierePro.Id ? 48_000 : null;
+ // A stream copy keeps the source rate no matter which export format is selected,
+ // and the archive preset forces FLAC, so both must not expect the Premiere rate.
+ if (options.Preset.IsCopyOnly)
+ {
+ return null;
+ }
+
+ var exportFormat = ExportFormat.ResolveForPreset(options.Preset, options.ExportFormat);
+ return exportFormat.Id == ExportFormat.PremierePro.Id ? 48_000 : null;
}
private static bool CodecsMatch(string expectedCodec, string actualCodec)
diff --git a/Services/ProcessRunner.cs b/Services/ProcessRunner.cs
index 9121fda..1c60fee 100644
--- a/Services/ProcessRunner.cs
+++ b/Services/ProcessRunner.cs
@@ -7,7 +7,13 @@ namespace AudioQualityEnhancer.Services;
internal sealed class ProcessRunner : IProcessRunner
{
private static readonly TimeSpan TerminationWaitTimeout = TimeSpan.FromSeconds(2);
- private static readonly TimeSpan ReaderDrainTimeout = TimeSpan.FromSeconds(2);
+
+ // The output readers normally finish right after the process exits. A child process
+ // that inherited the pipes can keep them open, so the drain is bounded: generous on
+ // the normal path (the last lines carry the loudness measurements) and short after
+ // a cancellation, where the remaining output is discarded anyway.
+ private static readonly TimeSpan ReaderDrainTimeout = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan CancelDrainTimeout = TimeSpan.FromSeconds(2);
public async Task RunAsync(ProcessRunOptions options, CancellationToken cancellationToken)
{
@@ -96,7 +102,7 @@ public async Task RunAsync(ProcessRunOptions options, Cancellatio
}
await process.WaitForExitAsync(exitCancellation.Token).ConfigureAwait(false);
- await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false);
+ await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task, ReaderDrainTimeout).ConfigureAwait(false);
return CreateResult(process, stdout, stderr, startedAt, wasCancelled: false, timedOut: timedOutFlag == 1);
}
@@ -105,7 +111,7 @@ public async Task RunAsync(ProcessRunOptions options, Cancellatio
var timedOut = timedOutFlag == 1;
TryKill(process);
await TryWaitForExitAsync(process).ConfigureAwait(false);
- await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false);
+ await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task, CancelDrainTimeout).ConfigureAwait(false);
return CreateResult(
process,
stdout,
@@ -207,16 +213,16 @@ private static async Task TryWaitForExitAsync(Process process)
}
}
- private static async Task TryWaitForReadersAsync(Process process, Task outputClosed, Task errorClosed)
+ private static async Task TryWaitForReadersAsync(Process process, Task outputClosed, Task errorClosed, TimeSpan timeout)
{
try
{
- await Task.WhenAll(outputClosed, errorClosed).WaitAsync(ReaderDrainTimeout).ConfigureAwait(false);
+ await Task.WhenAll(outputClosed, errorClosed).WaitAsync(timeout).ConfigureAwait(false);
return;
}
catch
{
- // Best effort drain after cancellation.
+ // Best effort drain; a reader that never closes must not hang the run.
}
TryCancelOutputRead(process);
diff --git a/Services/ToolDiscoveryService.cs b/Services/ToolDiscoveryService.cs
index 7128816..681936b 100644
--- a/Services/ToolDiscoveryService.cs
+++ b/Services/ToolDiscoveryService.cs
@@ -5,6 +5,11 @@ namespace AudioQualityEnhancer.Services;
public sealed class ToolDiscoveryService
{
+ // The probe only runs " -version". A binary that never answers (broken
+ // download, unreachable network path) would otherwise block the startup check
+ // forever, so it is bounded and reported as unavailable instead.
+ internal static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20);
+
private readonly object _cacheLock = new();
private readonly Dictionary _locationCache = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _statusCache = new(StringComparer.OrdinalIgnoreCase);
@@ -41,23 +46,26 @@ public async Task GetStatusAsync(string toolName, CancellationToken
private static async Task ProbeToolAsync(string toolName, ToolLocation location, string versionArgument, CancellationToken cancellationToken)
{
- try
+ using var process = new Process();
+ process.StartInfo = new ProcessStartInfo
{
- using var process = new Process();
- process.StartInfo = new ProcessStartInfo
- {
- FileName = location.ExecutablePath,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
- process.StartInfo.ArgumentList.Add(versionArgument);
+ FileName = location.ExecutablePath,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ process.StartInfo.ArgumentList.Add(versionArgument);
+
+ using var probeCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ probeCancellation.CancelAfter(ProbeTimeout);
+ try
+ {
process.Start();
- var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
- var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
- await process.WaitForExitAsync(cancellationToken);
+ var outputTask = process.StandardOutput.ReadToEndAsync(probeCancellation.Token);
+ var errorTask = process.StandardError.ReadToEndAsync(probeCancellation.Token);
+ await process.WaitForExitAsync(probeCancellation.Token);
var output = await outputTask;
var error = await errorTask;
@@ -76,6 +84,22 @@ private static async Task ProbeToolAsync(string toolName, ToolLocati
versionLine,
LocalizationService.Instance.Format("Error_ToolExitCodeFormat", toolName, process.ExitCode));
}
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ TryKill(process);
+ return new ToolStatus(
+ toolName,
+ location.ExecutablePath,
+ location.Source,
+ false,
+ null,
+ LocalizationService.Instance.Format("Error_ToolTimeoutFormat", toolName));
+ }
+ catch (OperationCanceledException)
+ {
+ TryKill(process);
+ throw;
+ }
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
{
return new ToolStatus(
@@ -88,6 +112,21 @@ private static async Task ProbeToolAsync(string toolName, ToolLocati
}
}
+ private static void TryKill(Process process)
+ {
+ try
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ }
+ catch
+ {
+ // The probe result is already decided; cleanup must never throw on top of it.
+ }
+ }
+
private ToolLocation GetOrLocateTool(string toolName)
{
lock (_cacheLock)
diff --git a/ViewModels/AsyncRelayCommand.cs b/ViewModels/AsyncRelayCommand.cs
index 1f47aee..e28b457 100644
--- a/ViewModels/AsyncRelayCommand.cs
+++ b/ViewModels/AsyncRelayCommand.cs
@@ -6,12 +6,14 @@ public sealed class AsyncRelayCommand : ICommand
{
private readonly Func _execute;
private readonly Func? _canExecute;
+ private readonly Action? _onError;
private bool _isExecuting;
- public AsyncRelayCommand(Func execute, Func? canExecute = null)
+ public AsyncRelayCommand(Func execute, Func? canExecute = null, Action? onError = null)
{
_execute = execute;
_canExecute = canExecute;
+ _onError = onError;
}
public event EventHandler? CanExecuteChanged;
@@ -34,6 +36,16 @@ public async void Execute(object? parameter)
RaiseCanExecuteChanged();
await _execute();
}
+ catch (OperationCanceledException)
+ {
+ // Cancelling a running command is a normal outcome, not a failure.
+ }
+ catch (Exception ex) when (_onError is not null)
+ {
+ // This runs as async void, so an escaping exception would reach the
+ // dispatcher handler and take the whole app down instead of the command.
+ _onError(ex);
+ }
finally
{
_isExecuting = false;
diff --git a/ViewModels/MainViewModel.Processing.cs b/ViewModels/MainViewModel.Processing.cs
index 4098cc5..89418f2 100644
--- a/ViewModels/MainViewModel.Processing.cs
+++ b/ViewModels/MainViewModel.Processing.cs
@@ -113,8 +113,7 @@ private async Task StartProcessingAsync()
if (SaveLogFile)
{
- var logPath = await _logService.SaveAsync(OutputDirectory, "audio-quality-enhancer-batch", CancellationToken.None);
- _logService.Info(LocalizationService.Instance.Format("Log_LogSavedFormat", logPath));
+ await TrySaveBatchLogAsync();
}
if (SaveReportFile)
@@ -187,6 +186,23 @@ private async Task ValidateProcessedItemAsync(BatchProcessingItem item, Ca
return true;
}
+ ///
+ /// Saves the run log without ever failing the run: the files are already exported,
+ /// so a read-only or full output folder is reported as a warning, not as a crash.
+ ///
+ private async Task TrySaveBatchLogAsync()
+ {
+ try
+ {
+ var logPath = await _logService.SaveAsync(OutputDirectory, "audio-quality-enhancer-batch", CancellationToken.None);
+ _logService.Info(LocalizationService.Instance.Format("Log_LogSavedFormat", logPath));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException)
+ {
+ _logService.Warning(LocalizationService.Instance["Error_LogSaveFailed"]);
+ }
+ }
+
private async Task SaveQualityReportAsync(CancellationToken cancellationToken)
{
if (SelectedPreset is null || SelectedExportFormat is null)
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 735ac3c..87574fb 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -170,19 +170,19 @@ public MainViewModel()
BatchItemsView = CollectionViewSource.GetDefaultView(BatchItems);
BatchItemsView.Filter = FilterBatchItem;
- _selectFileCommand = new AsyncRelayCommand(SelectFileAsync, () => !IsBusy);
- _downloadFromUrlCommand = new AsyncRelayCommand(DownloadFromUrlAsync, CanDownloadFromUrl);
- _analyzeDiagnosticsCommand = new AsyncRelayCommand(AnalyzeDiagnosticsAsync, CanAnalyzeDiagnostics);
+ _selectFileCommand = new AsyncRelayCommand(SelectFileAsync, () => !IsBusy, HandleCommandError);
+ _downloadFromUrlCommand = new AsyncRelayCommand(DownloadFromUrlAsync, CanDownloadFromUrl, HandleCommandError);
+ _analyzeDiagnosticsCommand = new AsyncRelayCommand(AnalyzeDiagnosticsAsync, CanAnalyzeDiagnostics, HandleCommandError);
_selectOutputFolderCommand = new RelayCommand(SelectOutputFolder, () => !IsBusy);
- _startCommand = new AsyncRelayCommand(StartProcessingAsync, CanStartProcessing);
+ _startCommand = new AsyncRelayCommand(StartProcessingAsync, CanStartProcessing, HandleCommandError);
_cancelCommand = new RelayCommand(CancelProcessing, () => IsBusy);
_removeSelectedFileCommand = new RelayCommand(RemoveSelectedFile, () => !IsBusy && SelectedBatchItem is not null);
_clearFinishedFilesCommand = new RelayCommand(ClearFinishedFiles, () => !IsBusy && _batchQueueService.GetFinishedItems(BatchItems).Count > 0);
- _retrySelectedFileCommand = new AsyncRelayCommand(RetrySelectedFileAsync, CanRetrySelectedFile);
- _retryFailedFilesCommand = new AsyncRelayCommand(RetryFailedFilesAsync, CanRetryFailedFiles);
+ _retrySelectedFileCommand = new AsyncRelayCommand(RetrySelectedFileAsync, CanRetrySelectedFile, HandleCommandError);
+ _retryFailedFilesCommand = new AsyncRelayCommand(RetryFailedFilesAsync, CanRetryFailedFiles, HandleCommandError);
_playSourceCommand = new RelayCommand(PlaySourcePreview, () => !IsBusy && File.Exists(InputPath));
_playOutputCommand = new RelayCommand(PlayOutputPreview, () => !IsBusy && File.Exists(LastOutputPath));
- _renderProcessedPreviewCommand = new AsyncRelayCommand(RenderProcessedPreviewAsync, CanRenderProcessedPreview);
+ _renderProcessedPreviewCommand = new AsyncRelayCommand(RenderProcessedPreviewAsync, CanRenderProcessedPreview, HandleCommandError);
_playProcessedPreviewCommand = new RelayCommand(PlayProcessedPreview, CanPlayProcessedPreview);
_stopPreviewCommand = new RelayCommand(StopPreview);
_openOutputFolderCommand = new RelayCommand(OpenOutputFolder, () => Directory.Exists(OutputDirectory));
@@ -419,6 +419,20 @@ private static string FormatLocalized(string resourceKey, object?[] arguments)
: LocalizationService.Instance.Format(resourceKey, arguments);
}
+ ///
+ /// Last line of defence for asynchronous commands: an unexpected failure is logged
+ /// and shown as status instead of ending the session through the crash handler.
+ ///
+ private void HandleCommandError(Exception exception)
+ {
+ IsBusy = false;
+ SetProcessingPhase("Phase_Error");
+ SetStatus("Error_UnexpectedFormat", exception.Message);
+ _logService.Error(LocalizationService.Instance.Format(
+ "Error_UnexpectedFormat",
+ $"{exception.GetType().Name}: {exception.Message}"));
+ }
+
private void RaiseCommandStates()
{
_selectFileCommand.RaiseCanExecuteChanged();