diff --git a/OF DL.Core/Services/DownloadOrchestrationService.cs b/OF DL.Core/Services/DownloadOrchestrationService.cs index 5d19d53..51f5f79 100644 --- a/OF DL.Core/Services/DownloadOrchestrationService.cs +++ b/OF DL.Core/Services/DownloadOrchestrationService.cs @@ -177,7 +177,8 @@ public class DownloadOrchestrationService( if (config.DownloadAvatarHeaderPhoto) { eventHandler.CancellationToken.ThrowIfCancellationRequested(); - UserEntities.User? userInfo = await apiService.GetUserInfo($"/users/{username}", eventHandler.CancellationToken); + UserEntities.User? userInfo = + await apiService.GetUserInfo($"/users/{username}", eventHandler.CancellationToken); if (userInfo != null) { await downloadService.DownloadAvatarHeader(userInfo.Avatar, userInfo.Header, path, username); @@ -256,7 +257,7 @@ public class DownloadOrchestrationService( : tempStories.Count; DownloadResult result = await eventHandler.WithProgressAsync( - $"Downloading {tempStories.Count} Stories", totalSize, config.ShowScrapeSize, + $"Downloading {tempStories.Count} stories", totalSize, config.ShowScrapeSize, async reporter => await downloadService.DownloadStories(username, userId, path, PaidPostIds.ToHashSet(), reporter)); @@ -285,7 +286,7 @@ public class DownloadOrchestrationService( : tempHighlights.Count; DownloadResult result = await eventHandler.WithProgressAsync( - $"Downloading {tempHighlights.Count} Highlights", totalSize, config.ShowScrapeSize, + $"Downloading {tempHighlights.Count} highlights", totalSize, config.ShowScrapeSize, async reporter => await downloadService.DownloadHighlights(username, userId, path, PaidPostIds.ToHashSet(), reporter)); @@ -359,9 +360,12 @@ public class DownloadOrchestrationService( long totalSize = config.ShowScrapeSize ? await downloadService.CalculateTotalFileSize(post.SinglePosts.Values.ToList()) : post.SinglePosts.Count; + int postCount = post.SinglePostObjects.Count; + string postLabel = postCount == 1 ? "Post" : "Posts"; DownloadResult result = await eventHandler.WithProgressAsync( - "Downloading Post", totalSize, config.ShowScrapeSize, + $"Downloading {post.SinglePosts.Count} media from {postCount} {postLabel.ToLowerInvariant()}", totalSize, + config.ShowScrapeSize, async reporter => await downloadService.DownloadSinglePost(username, path, users, clientIdBlobMissing, devicePrivateKeyMissing, post, reporter)); @@ -455,7 +459,7 @@ public class DownloadOrchestrationService( : purchasedTabCollection.PaidPosts.PaidPosts.Count; DownloadResult postResult = await eventHandler.WithProgressAsync( - $"Downloading {purchasedTabCollection.PaidPosts.PaidPosts.Count} Media from {purchasedTabCollection.PaidPosts.PaidPostObjects.Count} Paid Posts", + $"Downloading {purchasedTabCollection.PaidPosts.PaidPosts.Count} media from {purchasedTabCollection.PaidPosts.PaidPostObjects.Count} paid posts", totalSize, config.ShowScrapeSize, async reporter => await downloadService.DownloadPaidPostsPurchasedTab( purchasedTabCollection.Username, path, users, @@ -483,7 +487,7 @@ public class DownloadOrchestrationService( : purchasedTabCollection.PaidMessages.PaidMessages.Count; DownloadResult msgResult = await eventHandler.WithProgressAsync( - $"Downloading {purchasedTabCollection.PaidMessages.PaidMessages.Count} Media from {purchasedTabCollection.PaidMessages.PaidMessageObjects.Count} Paid Messages", + $"Downloading {purchasedTabCollection.PaidMessages.PaidMessages.Count} media from {purchasedTabCollection.PaidMessages.PaidMessageObjects.Count} paid messages", totalSize, config.ShowScrapeSize, async reporter => await downloadService.DownloadPaidMessagesPurchasedTab( purchasedTabCollection.Username, path, users, @@ -554,7 +558,7 @@ public class DownloadOrchestrationService( : totalCount; DownloadResult result = await eventHandler.WithProgressAsync( - $"Downloading {totalCount} Media from {messageCount} {messageLabel} ({paidCount} Paid + {previewCount} Preview)", + $"Downloading {totalCount} media from {messageCount} {messageLabel} ({paidCount} paid + {previewCount} preview)", totalSize, config.ShowScrapeSize, async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users, clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter)); @@ -574,7 +578,7 @@ public class DownloadOrchestrationService( : previewCount; DownloadResult previewResult = await eventHandler.WithProgressAsync( - $"Downloading {previewCount} Preview Media from {messageCount} {messageLabel}", + $"Downloading {previewCount} preview media from {messageCount} {messageLabel.ToLowerInvariant()}", previewSize, config.ShowScrapeSize, async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users, clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter)); @@ -594,7 +598,7 @@ public class DownloadOrchestrationService( : paidCount; DownloadResult result = await eventHandler.WithProgressAsync( - $"Downloading {paidCount} Paid Media from {messageCount} {messageLabel}", + $"Downloading {paidCount} paid media from {messageCount} {messageLabel.ToLowerInvariant()}", totalSize, config.ShowScrapeSize, async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users, clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter)); @@ -658,7 +662,8 @@ public class DownloadOrchestrationService( : mediaCount; DownloadResult result = await eventHandler.WithProgressAsync( - $"Downloading {mediaCount} Media from {objectCount} {contentType}", totalSize, config.ShowScrapeSize, + $"Downloading {mediaCount} media from {objectCount} {contentType.ToLowerInvariant()}", totalSize, + config.ShowScrapeSize, async reporter => await downloadData(data, reporter)); eventHandler.OnDownloadComplete(contentType, result); diff --git a/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs b/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs index 8b48314..432c96e 100644 --- a/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs +++ b/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs @@ -12,6 +12,8 @@ internal sealed class AvaloniaDownloadEventHandler( Func isCancellationRequested, CancellationToken cancellationToken) : IDownloadEventHandler { + private string _lastProgressDescription = string.Empty; + public CancellationToken CancellationToken { get; } = cancellationToken; public async Task WithStatusAsync(string statusMessage, Func> work) @@ -33,6 +35,7 @@ internal sealed class AvaloniaDownloadEventHandler( Func> work) { ThrowIfCancellationRequested(); + _lastProgressDescription = description; progressStart(description, maxValue, showSize); try { @@ -87,7 +90,8 @@ internal sealed class AvaloniaDownloadEventHandler( public void OnScrapeComplete(TimeSpan elapsed) { ThrowIfCancellationRequested(); - activitySink($"Scrape completed in {elapsed.TotalMinutes:0.00} minutes."); + string summary = BuildCompletionSummary(elapsed); + activitySink(summary); } public void OnMessage(string message) @@ -103,4 +107,21 @@ internal sealed class AvaloniaDownloadEventHandler( throw new OperationCanceledException("Operation canceled by user."); } } + + private string BuildCompletionSummary(TimeSpan elapsed) + { + if (string.IsNullOrWhiteSpace(_lastProgressDescription)) + { + return $"Download completed in {elapsed.TotalMinutes:0.0} minutes."; + } + + string normalized = _lastProgressDescription.Trim().TrimEnd('.'); + if (normalized.StartsWith("Downloading ", StringComparison.OrdinalIgnoreCase)) + { + string remainder = normalized["Downloading ".Length..].ToLowerInvariant(); + return $"Downloaded {remainder} in {elapsed.TotalMinutes:0.0} minutes."; + } + + return $"{normalized} in {elapsed.TotalMinutes:0.0} minutes."; + } } diff --git a/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs index fb277f6..61742ce 100644 --- a/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs +++ b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs @@ -181,8 +181,6 @@ public partial class ConfigFieldViewModel : ViewModelBase [ObservableProperty] private string _textValue = string.Empty; - private bool _isNormalizingFileNameFormatInput; - [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(InsertSelectedFileNameVariableCommand))] private string? _selectedFileNameVariable; @@ -370,18 +368,6 @@ public partial class ConfigFieldViewModel : ViewModelBase partial void OnTextValueChanged(string value) { - if (IsFileNameFormatField && !_isNormalizingFileNameFormatInput) - { - string trimmedValue = value.Trim(); - if (!string.Equals(value, trimmedValue, StringComparison.Ordinal)) - { - _isNormalizingFileNameFormatInput = true; - TextValue = trimmedValue; - _isNormalizingFileNameFormatInput = false; - return; - } - } - // Store actual value if not the privacy placeholder if (value != "[Hidden for Privacy]") { diff --git a/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs b/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs index 378abb6..4094e38 100644 --- a/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs +++ b/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs @@ -19,7 +19,6 @@ public partial class CreatorConfigModalViewModel : ViewModelBase private readonly Action _onClose; private readonly Func _isUsernameDuplicate; private bool _isNormalizingUsername; - private bool _isNormalizingFileNameFormat; [ObservableProperty] private bool _isOpen; [ObservableProperty] private bool _isEditMode; @@ -238,30 +237,22 @@ public partial class CreatorConfigModalViewModel : ViewModelBase } partial void OnPaidPostFileNameFormatChanged(string value) => - NormalizeFileNameFormat( - value, - trimmed => PaidPostFileNameFormat = trimmed, + HandleFileNameFormatChanged( () => PaidPostFileNameFormatError = string.Empty, UpdatePaidPostPreview); partial void OnPostFileNameFormatChanged(string value) => - NormalizeFileNameFormat( - value, - trimmed => PostFileNameFormat = trimmed, + HandleFileNameFormatChanged( () => PostFileNameFormatError = string.Empty, UpdatePostPreview); partial void OnPaidMessageFileNameFormatChanged(string value) => - NormalizeFileNameFormat( - value, - trimmed => PaidMessageFileNameFormat = trimmed, + HandleFileNameFormatChanged( () => PaidMessageFileNameFormatError = string.Empty, UpdatePaidMessagePreview); partial void OnMessageFileNameFormatChanged(string value) => - NormalizeFileNameFormat( - value, - trimmed => MessageFileNameFormat = trimmed, + HandleFileNameFormatChanged( () => MessageFileNameFormatError = string.Empty, UpdateMessagePreview); @@ -341,24 +332,10 @@ public partial class CreatorConfigModalViewModel : ViewModelBase isValid = false; } - private void NormalizeFileNameFormat( - string value, - Action setValue, + private static void HandleFileNameFormatChanged( Action clearError, Action updatePreview) { - if (!_isNormalizingFileNameFormat) - { - string trimmedValue = value.Trim(); - if (!string.Equals(value, trimmedValue, StringComparison.Ordinal)) - { - _isNormalizingFileNameFormat = true; - setValue(trimmedValue); - _isNormalizingFileNameFormat = false; - return; - } - } - clearError(); updatePreview(); } diff --git a/OF DL.Gui/ViewModels/MainWindowViewModel.cs b/OF DL.Gui/ViewModels/MainWindowViewModel.cs index 8f89f70..d7695e4 100644 --- a/OF DL.Gui/ViewModels/MainWindowViewModel.cs +++ b/OF DL.Gui/ViewModels/MainWindowViewModel.cs @@ -428,6 +428,11 @@ public partial class MainWindowViewModel( _isUpdatingAllUsersSelected = true; try { + if (!string.IsNullOrWhiteSpace(SelectedListName)) + { + SelectedListName = null; + } + foreach (SelectableUserViewModel user in AvailableUsers) { user.IsSelected = shouldSelectAll; @@ -614,7 +619,8 @@ public partial class MainWindowViewModel( { if (!TryBuildConfig(out Config newConfig)) { - StatusMessage = "Fix configuration validation errors and save again."; + ConfigScreenMessage = "Fix configuration validation errors and save again."; + StatusMessage = ConfigScreenMessage; return; } @@ -637,12 +643,12 @@ public partial class MainWindowViewModel( ConfigScreenMessage = "Configuration saved."; StatusMessage = "Configuration saved."; - if (!await ValidateEnvironmentAsync()) + if (!await ValidateEnvironmentAsync(false)) { return; } - await EnsureAuthenticationAndLoadUsersAsync(); + await EnsureAuthenticationAndLoadUsersAsync(false); } [RelayCommand] @@ -677,7 +683,7 @@ public partial class MainWindowViewModel( await authService.SaveToFileAsync(); - bool isAuthValid = await ValidateCurrentAuthAsync(); + bool isAuthValid = await ValidateCurrentAuthAsync(true); if (!isAuthValid) { AuthScreenMessage = "Authentication is still invalid after login. Please retry."; @@ -1041,12 +1047,17 @@ public partial class MainWindowViewModel( return; } - if (!await ValidateEnvironmentAsync()) + if (!ValidateConfiguredToolPathsOnStartup()) { return; } - await EnsureAuthenticationAndLoadUsersAsync(); + if (!await ValidateEnvironmentAsync(true)) + { + return; + } + + await EnsureAuthenticationAndLoadUsersAsync(true); } private async Task RunSinglePostOrMessageDownloadAsync(SingleDownloadRequest request) @@ -1288,9 +1299,9 @@ public partial class MainWindowViewModel( } } - private async Task EnsureAuthenticationAndLoadUsersAsync() + private async Task EnsureAuthenticationAndLoadUsersAsync(bool logAuthenticationMessage) { - bool hasValidAuth = await TryLoadAndValidateExistingAuthAsync(); + bool hasValidAuth = await TryLoadAndValidateExistingAuthAsync(logAuthenticationMessage); if (!hasValidAuth) { if (configService.CurrentConfig.DisableBrowserAuth) @@ -1310,12 +1321,14 @@ public partial class MainWindowViewModel( await LoadUsersAndListsAsync(); } - private async Task ValidateEnvironmentAsync() + private async Task ValidateEnvironmentAsync(bool logMissingCdmKeysWarning) { SetLoading("Validating environment..."); _startupResult = await startupService.ValidateEnvironmentAsync(); OnPropertyChanged(nameof(FfmpegVersion)); OnPropertyChanged(nameof(FfprobeVersion)); + FfmpegPathError = string.Empty; + FfprobePathError = string.Empty; if (!_startupResult.IsWindowsVersionValid) { @@ -1325,7 +1338,33 @@ public partial class MainWindowViewModel( if (!_startupResult.FfmpegFound) { - ConfigScreenMessage = "FFmpeg was not found. Set a valid FFmpegPath before continuing."; + FfmpegPathError = BuildToolPathError( + nameof(Config.FFmpegPath), + configService.CurrentConfig.FFmpegPath, + "FFmpeg"); + ConfigScreenMessage = "FFmpeg was not found. Fix FFmpeg Path and save to continue."; + BuildConfigFields(configService.CurrentConfig); + FfmpegPathError = BuildToolPathError( + nameof(Config.FFmpegPath), + configService.CurrentConfig.FFmpegPath, + "FFmpeg"); + CurrentScreen = AppScreen.Config; + StatusMessage = ConfigScreenMessage; + return false; + } + + if (!_startupResult.FfprobeFound) + { + FfprobePathError = BuildToolPathError( + nameof(Config.FFprobePath), + configService.CurrentConfig.FFprobePath, + "FFprobe"); + ConfigScreenMessage = "FFprobe was not found. Fix FFprobe Path and save to continue."; + BuildConfigFields(configService.CurrentConfig); + FfprobePathError = BuildToolPathError( + nameof(Config.FFprobePath), + configService.CurrentConfig.FFprobePath, + "FFprobe"); CurrentScreen = AppScreen.Config; StatusMessage = ConfigScreenMessage; return false; @@ -1338,7 +1377,8 @@ public partial class MainWindowViewModel( return false; } - if (_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing) + if (logMissingCdmKeysWarning && + (_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing)) { AppendLog( "CDM key files are missing. Fallback decrypt services will be used for DRM protected videos."); @@ -1347,7 +1387,7 @@ public partial class MainWindowViewModel( return true; } - private async Task TryLoadAndValidateExistingAuthAsync() + private async Task TryLoadAndValidateExistingAuthAsync(bool logAuthenticationMessage) { bool loadedFromFile = await authService.LoadFromFileAsync(); if (!loadedFromFile) @@ -1357,10 +1397,40 @@ public partial class MainWindowViewModel( return false; } - return await ValidateCurrentAuthAsync(); + return await ValidateCurrentAuthAsync(logAuthenticationMessage); } - private async Task ValidateCurrentAuthAsync() + private bool ValidateConfiguredToolPathsOnStartup() + { + IReadOnlyDictionary validationErrors = ConfigValidationService.Validate(configService.CurrentConfig); + bool hasToolPathErrors = false; + FfmpegPathError = string.Empty; + FfprobePathError = string.Empty; + + if (validationErrors.TryGetValue(nameof(Config.FFmpegPath), out string? ffmpegError)) + { + FfmpegPathError = ffmpegError; + hasToolPathErrors = true; + } + + if (validationErrors.TryGetValue(nameof(Config.FFprobePath), out string? ffprobeError)) + { + FfprobePathError = ffprobeError; + hasToolPathErrors = true; + } + + if (!hasToolPathErrors) + { + return true; + } + + ConfigScreenMessage = "Configuration has invalid FFmpeg/FFprobe path values. Fix and save to continue."; + CurrentScreen = AppScreen.Config; + StatusMessage = ConfigScreenMessage; + return false; + } + + private async Task ValidateCurrentAuthAsync(bool logAuthenticationMessage) { authService.ValidateCookieString(); UserEntities.User? user = await authService.ValidateAuthAsync(); @@ -1383,12 +1453,18 @@ public partial class MainWindowViewModel( if (HidePrivateInfo) { AuthenticatedUserDisplay = "[Hidden for Privacy]"; - AppendLog("Authenticated as [Hidden for Privacy]."); + if (logAuthenticationMessage) + { + AppendLog("Authenticated as [Hidden for Privacy]."); + } } else { AuthenticatedUserDisplay = $"{displayName} ({displayUsername})"; - AppendLog($"Authenticated as {AuthenticatedUserDisplay}."); + if (logAuthenticationMessage) + { + AppendLog($"Authenticated as {AuthenticatedUserDisplay}."); + } } IsAuthenticated = true; @@ -1802,6 +1878,18 @@ public partial class MainWindowViewModel( private static string EscapePathForConfig(string path) => path.Replace(@"\", @"\\"); + private static string BuildToolPathError(string propertyName, string? configuredPath, string toolName) + { + string normalizedPath = NormalizePathForDisplay(configuredPath); + if (string.IsNullOrWhiteSpace(normalizedPath)) + { + return + $"{toolName} was not found automatically. Set {propertyName} to a valid executable path or add {toolName.ToLowerInvariant()} to PATH."; + } + + return $"{propertyName} does not point to an existing file: {normalizedPath}"; + } + private static string ResolveProgramVersion() { Version? version = Assembly.GetEntryAssembly()?.GetName().Version @@ -1916,6 +2004,11 @@ public partial class MainWindowViewModel( { if (e.PropertyName == nameof(SelectableUserViewModel.IsSelected)) { + if (!_isApplyingListSelection && !string.IsNullOrWhiteSpace(SelectedListName)) + { + SelectedListName = null; + } + OnPropertyChanged(nameof(SelectedUsersSummary)); if (!_isUpdatingAllUsersSelected) { diff --git a/OF DL.Gui/Views/MainWindow.axaml b/OF DL.Gui/Views/MainWindow.axaml index 6d3c4e1..001d3a2 100644 --- a/OF DL.Gui/Views/MainWindow.axaml +++ b/OF DL.Gui/Views/MainWindow.axaml @@ -1065,9 +1065,10 @@ - + + +