Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@
- 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.
- Adding files no longer leaves the window in a half-usable state: while a dropped folder is analyzed, Cancel is available, Start is disabled and further drops are rejected, and the analysis can now actually be stopped.
- The processed preview render can be cancelled as well; it is a full FFmpeg pass and previously had to run to completion.
- The tool source shown in the app bar is translated instead of always appearing in German.

### 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.
- The queue no longer re-evaluates every button and rebuilds the summary on each FFmpeg progress tick, so the window stays responsive during a batch export.
- The startup checks for yt-dlp and for a new release are stopped when the window closes and no longer write into a closed window; an unexpected failure in either is logged instead of being swallowed.
- A tool that is missing or does not answer is remembered for half a minute instead of being probed again on every call, where each attempt cost the full probe timeout.

## 0.17.0 - 2026-06-13

Expand Down
9 changes: 5 additions & 4 deletions Models/ToolStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ public sealed class ToolStatus
public ToolStatus(
string name,
string executablePath,
string source,
string sourceKey,
bool isAvailable,
string? versionLine,
string? errorMessage)
{
Name = name;
ExecutablePath = executablePath;
Source = source;
SourceKey = sourceKey;
IsAvailable = isAvailable;
VersionLine = versionLine;
ErrorMessage = errorMessage;
Expand All @@ -24,7 +24,8 @@ public ToolStatus(

public string ExecutablePath { get; }

public string Source { get; }
/// <summary>Resource key of the place the tool was found in, resolved on display.</summary>
public string SourceKey { get; }

public bool IsAvailable { get; }

Expand All @@ -33,6 +34,6 @@ public ToolStatus(
public string? ErrorMessage { get; }

public string DisplayText => IsAvailable
? LocalizationService.Instance.Format("ToolStatus_Found", Name, Source)
? LocalizationService.Instance.Format("ToolStatus_Found", Name, LocalizationService.Instance[SourceKey])
: LocalizationService.Instance.Format("ToolStatus_NotFound", Name);
}
5 changes: 5 additions & 0 deletions Resources/Strings.en.resx
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,11 @@
<data name="ToolStatus_Found" xml:space="preserve"><value>{0}: found ({1})</value></data>
<data name="ToolStatus_NotFound" xml:space="preserve"><value>{0}: not found</value></data>

<data name="ToolSource_UserTools" xml:space="preserve"><value>user tools</value></data>
<data name="ToolSource_AppFolder" xml:space="preserve"><value>app folder</value></data>
<data name="ToolSource_ToolsFolder" xml:space="preserve"><value>tools folder</value></data>
<data name="ToolSource_Path" xml:space="preserve"><value>PATH</value></data>

<data name="Dialog_SelectFile_Title" xml:space="preserve"><value>Select audio or video file</value></data>
<data name="Dialog_SelectFolder_Title" xml:space="preserve"><value>Select output folder</value></data>
<data name="Dialog_FilterAudio" xml:space="preserve"><value>Audio and video files</value></data>
Expand Down
5 changes: 5 additions & 0 deletions Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,11 @@
<data name="ToolStatus_Found" xml:space="preserve"><value>{0}: gefunden ({1})</value></data>
<data name="ToolStatus_NotFound" xml:space="preserve"><value>{0}: nicht gefunden</value></data>

<data name="ToolSource_UserTools" xml:space="preserve"><value>Benutzer-Tools</value></data>
<data name="ToolSource_AppFolder" xml:space="preserve"><value>App-Ordner</value></data>
<data name="ToolSource_ToolsFolder" xml:space="preserve"><value>Tools-Ordner</value></data>
<data name="ToolSource_Path" xml:space="preserve"><value>PATH</value></data>

<data name="Dialog_SelectFile_Title" xml:space="preserve"><value>Audio- oder Videodatei auswählen</value></data>
<data name="Dialog_SelectFolder_Title" xml:space="preserve"><value>Ausgabeordner auswählen</value></data>
<data name="Dialog_FilterAudio" xml:space="preserve"><value>Audio- und Videodateien</value></data>
Expand Down
45 changes: 33 additions & 12 deletions Services/ToolDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ public sealed class ToolDiscoveryService
// forever, so it is bounded and reported as unavailable instead.
internal static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20);

// A failed probe used to be repeated on every call. Together with the bounded probe
// that costs the full timeout each time, so failures are cached as well - but only
// briefly, so a tool that is installed while the app runs is still picked up.
internal static readonly TimeSpan FailedStatusCacheDuration = TimeSpan.FromSeconds(30);

private readonly object _cacheLock = new();
private readonly Dictionary<string, ToolLocation> _locationCache = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ToolStatus> _statusCache = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FailedStatus> _failedStatusCache = new(StringComparer.OrdinalIgnoreCase);

public string ResolveExecutable(string toolName)
{
Expand All @@ -30,14 +36,27 @@ public async Task<ToolStatus> GetStatusAsync(string toolName, CancellationToken
{
return cachedStatus;
}

if (_failedStatusCache.TryGetValue(toolName, out var cachedFailure) &&
string.Equals(cachedFailure.Status.ExecutablePath, location.ExecutablePath, StringComparison.OrdinalIgnoreCase) &&
Stopwatch.GetElapsedTime(cachedFailure.Timestamp) < FailedStatusCacheDuration)
{
return cachedFailure.Status;
}
}

var status = await ProbeToolAsync(toolName, location, versionArgument, cancellationToken);
if (status.IsAvailable)

lock (_cacheLock)
{
lock (_cacheLock)
if (status.IsAvailable)
{
_statusCache[toolName] = status;
_failedStatusCache.Remove(toolName);
}
else
{
_failedStatusCache[toolName] = new FailedStatus(status, Stopwatch.GetTimestamp());
}
}

Expand Down Expand Up @@ -73,13 +92,13 @@ private static async Task<ToolStatus> ProbeToolAsync(string toolName, ToolLocati

if (process.ExitCode == 0)
{
return new ToolStatus(toolName, location.ExecutablePath, location.Source, true, versionLine, null);
return new ToolStatus(toolName, location.ExecutablePath, location.SourceKey, true, versionLine, null);
}

return new ToolStatus(
toolName,
location.ExecutablePath,
location.Source,
location.SourceKey,
false,
versionLine,
LocalizationService.Instance.Format("Error_ToolExitCodeFormat", toolName, process.ExitCode));
Expand All @@ -90,7 +109,7 @@ private static async Task<ToolStatus> ProbeToolAsync(string toolName, ToolLocati
return new ToolStatus(
toolName,
location.ExecutablePath,
location.Source,
location.SourceKey,
false,
null,
LocalizationService.Instance.Format("Error_ToolTimeoutFormat", toolName));
Expand All @@ -105,7 +124,7 @@ private static async Task<ToolStatus> ProbeToolAsync(string toolName, ToolLocati
return new ToolStatus(
toolName,
location.ExecutablePath,
location.Source,
location.SourceKey,
false,
null,
LocalizationService.Instance.Format("Error_ToolNotFoundFormat", toolName));
Expand Down Expand Up @@ -176,28 +195,28 @@ private static ToolLocation LocateTool(string toolName)
var userToolPath = Path.Combine(GetUserToolsDirectory(), exeName);
if (File.Exists(userToolPath))
{
return new ToolLocation(userToolPath, "Benutzer-Tools");
return new ToolLocation(userToolPath, "ToolSource_UserTools");
}

var appLocalPath = Path.Combine(AppContext.BaseDirectory, exeName);
if (File.Exists(appLocalPath))
{
return new ToolLocation(appLocalPath, "App-Ordner");
return new ToolLocation(appLocalPath, "ToolSource_AppFolder");
}

var toolsPath = Path.Combine(AppContext.BaseDirectory, "Tools", exeName);
if (File.Exists(toolsPath))
{
return new ToolLocation(toolsPath, "Tools-Ordner");
return new ToolLocation(toolsPath, "ToolSource_ToolsFolder");
}

var pathTool = FindInPath(exeName);
if (pathTool is not null)
{
return new ToolLocation(pathTool, "PATH");
return new ToolLocation(pathTool, "ToolSource_Path");
}

return new ToolLocation(exeName, "PATH");
return new ToolLocation(exeName, "ToolSource_Path");
}

private static string? FindInPath(string exeName)
Expand Down Expand Up @@ -241,5 +260,7 @@ private static ToolLocation LocateTool(string toolName)
return null;
}

private sealed record ToolLocation(string ExecutablePath, string Source);
private sealed record ToolLocation(string ExecutablePath, string SourceKey);

private sealed record FailedStatus(ToolStatus Status, long Timestamp);
}
24 changes: 22 additions & 2 deletions ViewModels/MainViewModel.Analysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,29 @@ public async Task LoadInputFilesAsync(IEnumerable<string> paths)
}

_logService.Info(LocalizationService.Instance.Format("Log_BatchAddedFilesFormat", addResult.AddedItems.Count));
await AnalyzeBatchItemsAsync(addResult.AddedItems, CancellationToken.None);

SetStatus("Status_BatchReadyFormat", _batchQueueService.GetProcessableItems(BatchItems).Count);
// The analysis of a dropped folder can take a while, so it runs as a busy phase:
// that keeps Cancel enabled, Start disabled and further drops out until it ends.
_analysisCancellation?.Dispose();
_analysisCancellation = new CancellationTokenSource();
IsBusy = true;

try
{
await AnalyzeBatchItemsAsync(addResult.AddedItems, _analysisCancellation.Token);
SetStatus("Status_BatchReadyFormat", _batchQueueService.GetProcessableItems(BatchItems).Count);
}
catch (OperationCanceledException)
{
SetProcessingPhase("Phase_Cancel");
SetStatus("Error_AnalysisCancelled");
}
finally
{
IsBusy = false;
_analysisCancellation.Dispose();
_analysisCancellation = null;
}
}

private async Task SelectFileAsync()
Expand Down
33 changes: 30 additions & 3 deletions ViewModels/MainViewModel.Batch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,24 @@ private async Task<int> PrepareRetryItemsAsync(IReadOnlyList<BatchProcessingItem
var needsAnalysis = preparedItems.Where(item => item.AudioInfo is null).ToArray();
if (needsAnalysis.Length > 0)
{
_analysisCancellation?.Dispose();
_analysisCancellation = new CancellationTokenSource();
IsBusy = true;
try
{
await AnalyzeBatchItemsAsync(needsAnalysis, CancellationToken.None);
await AnalyzeBatchItemsAsync(needsAnalysis, _analysisCancellation.Token);
}
catch (OperationCanceledException)
{
SetProcessingPhase("Phase_Cancel");
SetStatus("Error_AnalysisCancelled");
return 0;
}
finally
{
IsBusy = false;
_analysisCancellation.Dispose();
_analysisCancellation = null;
}
}

Expand Down Expand Up @@ -212,8 +222,11 @@ private void OnBatchItemPropertyChanged(object? sender, PropertyChangedEventArgs
}
}

UpdateBatchSummary();
RaiseCommandStates();
if (BatchQueueStateChanged(e.PropertyName))
{
UpdateBatchSummary();
RaiseCommandStates();
}

if (ReferenceEquals(sender, SelectedBatchItem))
{
Expand All @@ -235,6 +248,20 @@ private static bool BatchViewNeedsRefresh(string? propertyName)
nameof(BatchProcessingItem.ComparisonReport);
}

// Progress is written on every FFmpeg progress tick of every running item. It feeds
// neither the summary counts (they only look at Status and ComparisonReport) nor any
// command predicate, so it must not trigger the expensive refresh: RaiseCommandStates
// makes WPF re-evaluate 24 predicates on the UI thread, several of which touch the
// file system or build a filter plan.
private static bool BatchQueueStateChanged(string? propertyName)
{
return propertyName is null or
nameof(BatchProcessingItem.Status) or
nameof(BatchProcessingItem.HasComparisonWarnings) or
nameof(BatchProcessingItem.ComparisonReport) or
nameof(BatchProcessingItem.OutputPath);
}

private int GetVisibleIndex(BatchProcessingItem item)
{
var visibleItems = BatchItemsView.Cast<BatchProcessingItem>().ToArray();
Expand Down
16 changes: 11 additions & 5 deletions ViewModels/MainViewModel.Download.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ private bool CanDownloadFromUrl()
return !IsBusy && YtDlpDownloadService.IsLikelyValidUrl(YouTubeUrl);
}

private async Task PrepareYtDlpAsync()
private async Task PrepareYtDlpAsync(CancellationToken cancellationToken)
{
try
{
Expand All @@ -100,16 +100,22 @@ private async Task PrepareYtDlpAsync()
_ytDlpAutoUpdate,
lastCheck,
_logService.Info,
CancellationToken.None);
cancellationToken);

if (newCheck.HasValue)
if (newCheck.HasValue && !cancellationToken.IsCancellationRequested)
{
_ytDlpLastUpdateCheckUtc = newCheck.Value.ToString("o", CultureInfo.InvariantCulture);
}
}
catch
catch (OperationCanceledException)
{
// Startup preparation of the downloader is best effort and never blocks the app.
// The app is closing.
}
catch (Exception exception)
{
// Startup preparation of the downloader is best effort and never blocks the
// app, but a genuine defect in this path should not stay invisible either.
_logService.Warning($"{exception.GetType().Name}: {exception.Message}");
}
}
}
16 changes: 15 additions & 1 deletion ViewModels/MainViewModel.Preview.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ private async Task RenderProcessedPreviewAsync()

InvalidateProcessedPreview();
_isProcessedPreviewRendering = true;

// The render is a full FFmpeg pass over the loudest section, so it runs as a busy
// phase like the deep analysis does. That is what makes Cancel reach it at all.
_previewRenderCancellation?.Dispose();
_previewRenderCancellation = new CancellationTokenSource();
IsBusy = true;
RaiseCommandStates();

try
Expand All @@ -41,7 +47,7 @@ private async Task RenderProcessedPreviewAsync()
options,
_logService.Info,
null,
CancellationToken.None);
_previewRenderCancellation.Token);

if (result.IsFailure || result.Value is null)
{
Expand All @@ -61,9 +67,17 @@ private async Task RenderProcessedPreviewAsync()
SetStatus("Status_ProcessedPreviewReadyFormat", Path.GetFileName(_processedPreviewPath));
_logService.Info(LocalizationService.Instance.Format("Log_ProcessedPreviewReadyFormat", _processedPreviewPath));
}
catch (OperationCanceledException)
{
SetProcessingPhase("Phase_Cancel");
SetStatus("Error_ProcessingCancelled");
}
finally
{
_isProcessedPreviewRendering = false;
IsBusy = false;
_previewRenderCancellation.Dispose();
_previewRenderCancellation = null;
RaiseCommandStates();
}
}
Expand Down
2 changes: 2 additions & 0 deletions ViewModels/MainViewModel.Processing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ private void CancelProcessing()
SetProcessingPhase("Phase_Cancel");
_processingCancellation?.Cancel();
_diagnosticsCancellation?.Cancel();
_analysisCancellation?.Cancel();
_previewRenderCancellation?.Cancel();
}

private async Task<bool> ValidateProcessedItemAsync(BatchProcessingItem item, CancellationToken cancellationToken)
Expand Down
Loading