using System.Collections.ObjectModel; using System.ComponentModel; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Avalonia.Threading; using Newtonsoft.Json; using OF_DL.Gui.Services; using OF_DL.Models; using OF_DL.Models.Config; using OF_DL.Models.Downloads; using OF_DL.Services; using UserEntities = OF_DL.Models.Entities.Users; namespace OF_DL.Gui.ViewModels; public partial class MainWindowViewModel( IConfigService configService, IAuthService authService, IStartupService startupService, IDownloadOrchestrationService downloadOrchestrationService) : ViewModelBase { private static readonly string s_defaultDownloadPath = Path.GetFullPath( Path.Combine(Directory.GetCurrentDirectory(), "__user_data__", "sites", "OnlyFans")); private static readonly (string DisplayName, string PropertyName)[] s_mediaTypeOptions = [ ("Videos", nameof(Config.DownloadVideos)), ("Images", nameof(Config.DownloadImages)), ("Audios", nameof(Config.DownloadAudios)) ]; private static readonly (string DisplayName, string PropertyName)[] s_mediaSourceOptions = [ ("Avatar/Header Photo", nameof(Config.DownloadAvatarHeaderPhoto)), ("Posts", nameof(Config.DownloadPosts)), ("Paid Posts", nameof(Config.DownloadPaidPosts)), ("Archived", nameof(Config.DownloadArchived)), ("Streams", nameof(Config.DownloadStreams)), ("Stories", nameof(Config.DownloadStories)), ("Highlights", nameof(Config.DownloadHighlights)), ("Messages", nameof(Config.DownloadMessages)), ("Paid Messages", nameof(Config.DownloadPaidMessages)) ]; private Dictionary _allUsers = []; private Dictionary _allLists = []; private StartupResult _startupResult = new(); private CancellationTokenSource? _workCancellationSource; private AppScreen _configReturnScreen = AppScreen.Loading; private bool _isApplyingListSelection; public ObservableCollection ConfigFields { get; } = []; public ObservableCollection ConfigCategories { get; } = []; public ObservableCollection MediaTypeOptions { get; } = []; public ObservableCollection MediaSourceOptions { get; } = []; public ObservableCollection AvailableUsers { get; } = []; public ObservableCollection UserLists { get; } = []; public ObservableCollection ActivityLog { get; } = []; [ObservableProperty] private AppScreen _currentScreen = AppScreen.Loading; [ObservableProperty] private string _statusMessage = "Initializing..."; [ObservableProperty] private string _loadingMessage = "Initializing..."; [ObservableProperty] private string _configScreenMessage = string.Empty; [ObservableProperty] private string _authScreenMessage = string.Empty; [ObservableProperty] private string _errorMessage = string.Empty; [ObservableProperty] private string _ffmpegPath = string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasFfmpegPathError))] private string _ffmpegPathError = string.Empty; [ObservableProperty] private string _downloadPath = string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasDownloadPathError))] private string _downloadPathError = string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasMediaTypesError))] private string _mediaTypesError = string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasMediaSourcesError))] private string _mediaSourcesError = string.Empty; [ObservableProperty] private string _authenticatedUserDisplay = "Not authenticated."; [ObservableProperty] private bool _isAuthenticated; [ObservableProperty] private string? _selectedListName; [ObservableProperty] private bool _hasInitialized; [ObservableProperty] private bool _isDownloading; [ObservableProperty] private bool _isDownloadProgressVisible; [ObservableProperty] private bool _isDownloadProgressIndeterminate; [ObservableProperty] private double _downloadProgressValue; [ObservableProperty] private double _downloadProgressMaximum = 1; [ObservableProperty] private string _downloadProgressDescription = string.Empty; public bool IsLoadingScreen => CurrentScreen == AppScreen.Loading; public bool IsConfigScreen => CurrentScreen == AppScreen.Config; public bool IsAuthScreen => CurrentScreen == AppScreen.Auth; public bool IsUserSelectionScreen => CurrentScreen == AppScreen.UserSelection; public bool IsErrorScreen => CurrentScreen == AppScreen.Error; public bool HasFfmpegPathError => !string.IsNullOrWhiteSpace(FfmpegPathError); public bool HasDownloadPathError => !string.IsNullOrWhiteSpace(DownloadPathError); public bool HasMediaTypesError => !string.IsNullOrWhiteSpace(MediaTypesError); public bool HasMediaSourcesError => !string.IsNullOrWhiteSpace(MediaSourcesError); public string SelectedUsersSummary => $"{AvailableUsers.Count(user => user.IsSelected)} / {AvailableUsers.Count} selected"; public async Task InitializeAsync() { if (HasInitialized) { return; } HasInitialized = true; await BeginStartupAsync(); } public void SetFfmpegPath(string? path) { FfmpegPath = NormalizePathForDisplay(path); FfmpegPathError = string.Empty; } public void SetDownloadPath(string? path) { DownloadPath = NormalizePathForDisplay(path); DownloadPathError = string.Empty; } [RelayCommand] private async Task RetryStartupAsync() { await BeginStartupAsync(); } [RelayCommand] private void ExitApplication() { if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.Shutdown(); return; } Environment.Exit(0); } [RelayCommand(CanExecute = nameof(CanLogout))] private void Logout() { authService.Logout(); authService.CurrentAuth = null; IsAuthenticated = false; foreach (SelectableUserViewModel user in AvailableUsers) { user.PropertyChanged -= OnSelectableUserPropertyChanged; } _allUsers = []; _allLists = []; AvailableUsers.Clear(); UserLists.Clear(); SelectedListName = null; AuthenticatedUserDisplay = "Not authenticated."; AuthScreenMessage = "You have been logged out. Click 'Login with Browser' to authenticate."; StatusMessage = "Logged out."; CurrentScreen = AppScreen.Auth; OnPropertyChanged(nameof(SelectedUsersSummary)); DownloadSelectedCommand.NotifyCanExecuteChanged(); DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); SelectUsersFromListCommand.NotifyCanExecuteChanged(); RefreshUsersCommand.NotifyCanExecuteChanged(); } [RelayCommand] private void EditConfig() { _configReturnScreen = CurrentScreen; BuildConfigFields(configService.CurrentConfig); ConfigScreenMessage = "Edit configuration values and save to apply changes."; StatusMessage = "Editing configuration."; CurrentScreen = AppScreen.Config; } [RelayCommand] private async Task CancelConfigAsync() { bool loaded = await configService.LoadConfigurationAsync([]); BuildConfigFields(configService.CurrentConfig); if (!loaded) { ConfigScreenMessage = "Configuration is still invalid."; StatusMessage = ConfigScreenMessage; CurrentScreen = AppScreen.Config; return; } if (_configReturnScreen == AppScreen.UserSelection && _allUsers.Count > 0) { CurrentScreen = AppScreen.UserSelection; StatusMessage = "Configuration changes canceled."; return; } await BeginStartupAsync(); } [RelayCommand(CanExecute = nameof(CanRefreshUsers))] private async Task RefreshUsersAsync() { await LoadUsersAndListsAsync(); } [RelayCommand] private async Task SaveConfigAsync() { if (!TryBuildConfig(out Config newConfig)) { StatusMessage = "Fix configuration validation errors and save again."; return; } configService.UpdateConfig(newConfig); await configService.SaveConfigurationAsync(); bool reloaded = await configService.LoadConfigurationAsync([]); if (!reloaded) { ConfigScreenMessage = "config.conf could not be loaded after saving. Please review your values."; CurrentScreen = AppScreen.Config; StatusMessage = ConfigScreenMessage; BuildConfigFields(configService.CurrentConfig); return; } BuildConfigFields(configService.CurrentConfig); ConfigScreenMessage = "Configuration saved."; StatusMessage = "Configuration saved."; if (!await ValidateEnvironmentAsync()) { return; } await EnsureAuthenticationAndLoadUsersAsync(); } [RelayCommand] private async Task StartBrowserLoginAsync() { if (configService.CurrentConfig.DisableBrowserAuth) { AuthScreenMessage = "Browser authentication is disabled in config."; return; } SetLoading("Opening browser for authentication..."); AppendLog("Starting browser authentication flow."); bool success = await authService.LoadFromBrowserAsync(); if (!success || authService.CurrentAuth == null) { AuthScreenMessage = "Authentication failed. Log in using the opened browser window and retry."; CurrentScreen = AppScreen.Auth; StatusMessage = "Authentication failed."; AppendLog("Browser authentication failed."); return; } await authService.SaveToFileAsync(); bool isAuthValid = await ValidateCurrentAuthAsync(); if (!isAuthValid) { AuthScreenMessage = "Authentication is still invalid after login. Please retry."; CurrentScreen = AppScreen.Auth; StatusMessage = "Authentication failed."; return; } await LoadUsersAndListsAsync(); } [RelayCommand] private void SelectAllUsers() { foreach (SelectableUserViewModel user in AvailableUsers) { user.IsSelected = true; } OnPropertyChanged(nameof(SelectedUsersSummary)); AppendLog($"Selected all users ({AvailableUsers.Count})."); } [RelayCommand] private void SelectNoUsers() { foreach (SelectableUserViewModel user in AvailableUsers) { user.IsSelected = false; } OnPropertyChanged(nameof(SelectedUsersSummary)); } [RelayCommand(CanExecute = nameof(CanApplySelectedList))] private async Task SelectUsersFromListAsync() { if (string.IsNullOrWhiteSpace(SelectedListName)) { return; } _isApplyingListSelection = true; StartDownloadProgress($"Selecting users from list '{SelectedListName}'...", 0, false); try { foreach (SelectableUserViewModel user in AvailableUsers) { user.IsSelected = false; } Dictionary listUsers = await downloadOrchestrationService.GetUsersForListAsync( SelectedListName, _allUsers, _allLists); HashSet selectedUsernames = listUsers.Keys.ToHashSet(StringComparer.Ordinal); foreach (SelectableUserViewModel user in AvailableUsers) { user.IsSelected = selectedUsernames.Contains(user.Username); } StatusMessage = $"Selected {selectedUsernames.Count} users from list '{SelectedListName}'."; OnPropertyChanged(nameof(SelectedUsersSummary)); DownloadSelectedCommand.NotifyCanExecuteChanged(); } finally { _isApplyingListSelection = false; StopDownloadProgress(); } } [RelayCommand(CanExecute = nameof(CanDownloadSelected))] private async Task DownloadSelectedAsync() { await RunDownloadAsync(downloadPurchasedTabOnly: false); } [RelayCommand(CanExecute = nameof(CanDownloadPurchasedTab))] private async Task DownloadPurchasedTabAsync() { await RunDownloadAsync(downloadPurchasedTabOnly: true); } [RelayCommand(CanExecute = nameof(CanStopWork))] private void StopWork() { if (_workCancellationSource is { IsCancellationRequested: false }) { _workCancellationSource.Cancel(); StatusMessage = "Stop requested. Waiting for current operation to cancel..."; UpdateProgressStatus("Stopping..."); AppendLog("Stop requested."); } } private async Task RunDownloadAsync(bool downloadPurchasedTabOnly) { List selectedUsers = AvailableUsers.Where(user => user.IsSelected).ToList(); if (!downloadPurchasedTabOnly && selectedUsers.Count == 0) { StatusMessage = "Select at least one user before downloading."; return; } if (downloadPurchasedTabOnly && _allUsers.Count == 0) { StatusMessage = "No users are loaded. Refresh users and retry."; return; } IsDownloading = true; _workCancellationSource?.Dispose(); _workCancellationSource = new CancellationTokenSource(); DownloadSelectedCommand.NotifyCanExecuteChanged(); DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); StopWorkCommand.NotifyCanExecuteChanged(); DateTime start = DateTime.Now; AppendLog(downloadPurchasedTabOnly ? "Starting Purchased Tab download." : $"Starting download for {selectedUsers.Count} users."); AvaloniaDownloadEventHandler eventHandler = new( AppendLog, UpdateProgressStatus, StartDownloadProgress, IncrementDownloadProgress, StopDownloadProgress, () => _workCancellationSource?.IsCancellationRequested == true); try { if (downloadPurchasedTabOnly) { await downloadOrchestrationService.DownloadPurchasedTabAsync(_allUsers, _startupResult.ClientIdBlobMissing, _startupResult.DevicePrivateKeyMissing, eventHandler); } else { foreach (SelectableUserViewModel user in selectedUsers) { ThrowIfStopRequested(); string path = downloadOrchestrationService.ResolveDownloadPath(user.Username); await downloadOrchestrationService.DownloadCreatorContentAsync(user.Username, user.UserId, path, _allUsers, _startupResult.ClientIdBlobMissing, _startupResult.DevicePrivateKeyMissing, eventHandler); } } ThrowIfStopRequested(); eventHandler.OnScrapeComplete(DateTime.Now - start); StatusMessage = downloadPurchasedTabOnly ? "Purchased Tab download completed." : "Download run completed."; } catch (OperationCanceledException) { StatusMessage = "Operation canceled."; AppendLog("Operation canceled."); } catch (Exception ex) { AppendLog($"Download failed: {ex.Message}"); StatusMessage = "Download failed. Check logs."; } finally { IsDownloading = false; _workCancellationSource?.Dispose(); _workCancellationSource = null; StopDownloadProgress(); DownloadSelectedCommand.NotifyCanExecuteChanged(); DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); StopWorkCommand.NotifyCanExecuteChanged(); } } private bool CanApplySelectedList() => CurrentScreen == AppScreen.UserSelection && !string.IsNullOrWhiteSpace(SelectedListName) && !IsDownloading; private bool CanDownloadSelected() => CurrentScreen == AppScreen.UserSelection && AvailableUsers.Any(user => user.IsSelected) && !IsDownloading; private bool CanDownloadPurchasedTab() => CurrentScreen == AppScreen.UserSelection && _allUsers.Count > 0 && !IsDownloading; private bool CanStopWork() => IsDownloading; private bool CanRefreshUsers() => CurrentScreen == AppScreen.UserSelection && !IsDownloading; private bool CanLogout() => IsAuthenticated && !IsDownloading; partial void OnCurrentScreenChanged(AppScreen value) { OnPropertyChanged(nameof(IsLoadingScreen)); OnPropertyChanged(nameof(IsConfigScreen)); OnPropertyChanged(nameof(IsAuthScreen)); OnPropertyChanged(nameof(IsUserSelectionScreen)); OnPropertyChanged(nameof(IsErrorScreen)); OnPropertyChanged(nameof(SelectedUsersSummary)); DownloadSelectedCommand.NotifyCanExecuteChanged(); DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); SelectUsersFromListCommand.NotifyCanExecuteChanged(); StopWorkCommand.NotifyCanExecuteChanged(); RefreshUsersCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged(); } partial void OnIsDownloadingChanged(bool value) { DownloadSelectedCommand.NotifyCanExecuteChanged(); DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); SelectUsersFromListCommand.NotifyCanExecuteChanged(); StopWorkCommand.NotifyCanExecuteChanged(); RefreshUsersCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged(); } partial void OnIsAuthenticatedChanged(bool value) { LogoutCommand.NotifyCanExecuteChanged(); } partial void OnSelectedListNameChanged(string? value) { SelectUsersFromListCommand.NotifyCanExecuteChanged(); if (_isApplyingListSelection || IsDownloading || CurrentScreen != AppScreen.UserSelection || string.IsNullOrWhiteSpace(value)) { return; } _ = SelectUsersFromListAsync(); } partial void OnFfmpegPathChanged(string value) { FfmpegPathError = string.Empty; } partial void OnDownloadPathChanged(string value) { DownloadPathError = string.Empty; } private async Task BeginStartupAsync() { _configReturnScreen = CurrentScreen; SetLoading("Loading configuration..."); BuildConfigFields(configService.CurrentConfig); UserLists.Clear(); AvailableUsers.Clear(); bool configLoaded = await configService.LoadConfigurationAsync([]); BuildConfigFields(configService.CurrentConfig); if (!configLoaded) { ConfigScreenMessage = "config.conf is invalid. Update all fields below and save to continue."; CurrentScreen = AppScreen.Config; StatusMessage = ConfigScreenMessage; return; } if (!await ValidateEnvironmentAsync()) { return; } await EnsureAuthenticationAndLoadUsersAsync(); } private async Task EnsureAuthenticationAndLoadUsersAsync() { bool hasValidAuth = await TryLoadAndValidateExistingAuthAsync(); if (!hasValidAuth) { if (configService.CurrentConfig.DisableBrowserAuth) { ShowError( "Authentication is missing or invalid and browser auth is disabled. Enable browser auth in config or provide a valid auth.json."); return; } AuthScreenMessage = "Authentication is required. Click 'Login with Browser' and complete the OnlyFans login flow."; CurrentScreen = AppScreen.Auth; StatusMessage = "Authentication required."; return; } await LoadUsersAndListsAsync(); } private async Task ValidateEnvironmentAsync() { SetLoading("Validating environment..."); _startupResult = await startupService.ValidateEnvironmentAsync(); if (!_startupResult.IsWindowsVersionValid) { ShowError($"Unsupported Windows version detected: {_startupResult.OsVersionString}"); return false; } if (!_startupResult.FfmpegFound) { ConfigScreenMessage = "FFmpeg was not found. Set a valid FFmpegPath before continuing."; CurrentScreen = AppScreen.Config; StatusMessage = ConfigScreenMessage; return false; } if (_startupResult.RulesJsonExists && !_startupResult.RulesJsonValid) { ShowError( $"rules.json is invalid: {_startupResult.RulesJsonError}. Fix rules.json and retry startup."); return false; } if (_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing) { AppendLog( "Widevine device files are missing. Fallback decrypt services will be used for DRM protected videos."); } return true; } private async Task TryLoadAndValidateExistingAuthAsync() { bool loadedFromFile = await authService.LoadFromFileAsync(); if (!loadedFromFile) { IsAuthenticated = false; AppendLog("No valid auth.json found."); return false; } return await ValidateCurrentAuthAsync(); } private async Task ValidateCurrentAuthAsync() { authService.ValidateCookieString(); UserEntities.User? user = await authService.ValidateAuthAsync(); if (user == null || (string.IsNullOrWhiteSpace(user.Name) && string.IsNullOrWhiteSpace(user.Username))) { authService.CurrentAuth = null; IsAuthenticated = false; if (File.Exists("auth.json") && !configService.CurrentConfig.DisableBrowserAuth) { File.Delete("auth.json"); } AppendLog("Auth validation failed."); return false; } string displayName = !string.IsNullOrWhiteSpace(user.Name) ? user.Name : "Unknown Name"; string displayUsername = !string.IsNullOrWhiteSpace(user.Username) ? user.Username : "Unknown Username"; AuthenticatedUserDisplay = $"{displayName} ({displayUsername})"; IsAuthenticated = true; AppendLog($"Authenticated as {AuthenticatedUserDisplay}."); return true; } private async Task LoadUsersAndListsAsync() { SetLoading("Fetching users and user lists..."); UserListResult listResult = await downloadOrchestrationService.GetAvailableUsersAsync(); _allUsers = listResult.Users.OrderBy(pair => pair.Key).ToDictionary(pair => pair.Key, pair => pair.Value); _allLists = listResult.Lists.OrderBy(pair => pair.Key).ToDictionary(pair => pair.Key, pair => pair.Value); foreach (SelectableUserViewModel user in AvailableUsers) { user.PropertyChanged -= OnSelectableUserPropertyChanged; } AvailableUsers.Clear(); foreach (KeyValuePair user in _allUsers) { SelectableUserViewModel userViewModel = new(user.Key, user.Value); userViewModel.PropertyChanged += OnSelectableUserPropertyChanged; AvailableUsers.Add(userViewModel); } OnPropertyChanged(nameof(SelectedUsersSummary)); UserLists.Clear(); foreach (string listName in _allLists.Keys) { UserLists.Add(listName); } SelectedListName = null; if (!string.IsNullOrWhiteSpace(listResult.IgnoredListError)) { AppendLog(listResult.IgnoredListError); } CurrentScreen = AppScreen.UserSelection; StatusMessage = $"Loaded {_allUsers.Count} users and {_allLists.Count} lists."; AppendLog(StatusMessage); DownloadSelectedCommand.NotifyCanExecuteChanged(); DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); SelectUsersFromListCommand.NotifyCanExecuteChanged(); RefreshUsersCommand.NotifyCanExecuteChanged(); } private bool TryBuildConfig(out Config config) { config = CloneConfig(configService.CurrentConfig); ClearSpecialConfigErrors(); Dictionary parsedValues = new(StringComparer.Ordinal); Dictionary fieldMap = ConfigFields .ToDictionary(field => field.PropertyName, field => field, StringComparer.Ordinal); foreach (ConfigFieldViewModel field in ConfigFields) { field.ClearError(); if (!field.TryGetTypedValue(out object? value, out string? error)) { field.SetError(error ?? "Invalid value."); continue; } parsedValues[field.PropertyName] = value; } bool hasFieldErrors = ConfigFields.Any(field => field.HasError); if (hasFieldErrors) { return false; } foreach (ConfigFieldViewModel field in ConfigFields) { if (!parsedValues.TryGetValue(field.PropertyName, out object? value)) { continue; } field.PropertyInfo.SetValue(config, value); } ApplySpecialConfigValues(config); ValidateSpecialConfigValues(); if (HasSpecialConfigErrors()) { return false; } IReadOnlyDictionary validationErrors = ConfigValidationService.Validate(config); foreach (KeyValuePair error in validationErrors) { if (error.Key == nameof(Config.FFmpegPath)) { FfmpegPathError = error.Value; continue; } if (error.Key == nameof(Config.DownloadPath)) { DownloadPathError = error.Value; continue; } if (fieldMap.TryGetValue(error.Key, out ConfigFieldViewModel? field)) { field.SetError(error.Value); continue; } if (error.Key.StartsWith($"{nameof(Config.CreatorConfigs)}.", StringComparison.Ordinal) && fieldMap.TryGetValue(nameof(Config.CreatorConfigs), out ConfigFieldViewModel? creatorConfigsField)) { creatorConfigsField.SetError(error.Value); } } return !ConfigFields.Any(field => field.HasError) && !HasSpecialConfigErrors(); } private void BuildConfigFields(Config config) { ConfigFields.Clear(); ConfigCategories.Clear(); BuildSpecialConfigInputs(config); IEnumerable properties = typeof(Config) .GetProperties() .Where(property => property.CanRead && property.CanWrite) .Where(property => !IsHiddenConfigField(property.Name)) .OrderBy(property => property.Name); foreach (System.Reflection.PropertyInfo property in properties) { object? value = property.GetValue(config); ConfigFields.Add(new ConfigFieldViewModel(property, value)); } IEnumerable> grouped = ConfigFields .GroupBy(field => GetConfigCategory(field.PropertyName)) .OrderBy(group => GetCategoryOrder(group.Key)) .ThenBy(group => group.Key); foreach (IGrouping group in grouped) { ConfigCategories.Add(new ConfigCategoryViewModel(group.Key, group.OrderBy(field => field.DisplayName))); } } private void BuildSpecialConfigInputs(Config config) { UnsubscribeSpecialSelectionEvents(); FfmpegPath = NormalizePathForDisplay(config.FFmpegPath); DownloadPath = ResolveDownloadPathForDisplay(config.DownloadPath); ClearSpecialConfigErrors(); PopulateSelectionOptions(MediaTypeOptions, s_mediaTypeOptions, config); PopulateSelectionOptions(MediaSourceOptions, s_mediaSourceOptions, config); SubscribeSpecialSelectionEvents(); } private static void PopulateSelectionOptions( ObservableCollection options, IEnumerable<(string DisplayName, string PropertyName)> definitions, Config config) { options.Clear(); foreach ((string displayName, string propertyName) in definitions) { options.Add(new MultiSelectOptionViewModel(displayName, propertyName, GetBooleanConfigValue(config, propertyName))); } } private static bool GetBooleanConfigValue(Config config, string propertyName) { System.Reflection.PropertyInfo? property = typeof(Config).GetProperty(propertyName); if (property == null) { return false; } return property.GetValue(config) is bool currentValue && currentValue; } private void ApplySpecialConfigValues(Config config) { string normalizedFfmpegPath = NormalizePathForDisplay(FfmpegPath); config.FFmpegPath = string.IsNullOrWhiteSpace(normalizedFfmpegPath) ? string.Empty : EscapePathForConfig(normalizedFfmpegPath); string normalizedDownloadPath = NormalizePathForDisplay(DownloadPath); config.DownloadPath = string.IsNullOrWhiteSpace(normalizedDownloadPath) ? EscapePathForConfig(s_defaultDownloadPath) : EscapePathForConfig(normalizedDownloadPath); ApplySelectionOptionsToConfig(config, MediaTypeOptions); ApplySelectionOptionsToConfig(config, MediaSourceOptions); } private static void ApplySelectionOptionsToConfig( Config config, IEnumerable options) { foreach (MultiSelectOptionViewModel option in options) { System.Reflection.PropertyInfo? property = typeof(Config).GetProperty(option.PropertyName); if (property?.PropertyType == typeof(bool)) { property.SetValue(config, option.IsSelected); } } } private void ValidateSpecialConfigValues() { if (!MediaTypeOptions.Any(option => option.IsSelected)) { MediaTypesError = "Select at least one media type."; } if (!MediaSourceOptions.Any(option => option.IsSelected)) { MediaSourcesError = "Select at least one source."; } } private void ClearSpecialConfigErrors() { FfmpegPathError = string.Empty; DownloadPathError = string.Empty; MediaTypesError = string.Empty; MediaSourcesError = string.Empty; } private bool HasSpecialConfigErrors() => HasFfmpegPathError || HasDownloadPathError || HasMediaTypesError || HasMediaSourcesError; private static string ResolveDownloadPathForDisplay(string? configuredPath) { string normalized = NormalizePathForDisplay(configuredPath); return string.IsNullOrWhiteSpace(normalized) ? s_defaultDownloadPath : normalized; } private static string NormalizePathForDisplay(string? path) { if (string.IsNullOrWhiteSpace(path)) { return string.Empty; } string normalized = path.Trim(); if (!normalized.Contains(@"\\", StringComparison.Ordinal)) { return normalized; } if (normalized.StartsWith(@"\\", StringComparison.Ordinal)) { return @"\\" + normalized[2..].Replace(@"\\", @"\"); } return normalized.Replace(@"\\", @"\"); } private static string EscapePathForConfig(string path) => path.Replace(@"\", @"\\"); private void SubscribeSpecialSelectionEvents() { foreach (MultiSelectOptionViewModel option in MediaTypeOptions) { option.PropertyChanged += OnMediaTypeSelectionChanged; } foreach (MultiSelectOptionViewModel option in MediaSourceOptions) { option.PropertyChanged += OnMediaSourceSelectionChanged; } } private void UnsubscribeSpecialSelectionEvents() { foreach (MultiSelectOptionViewModel option in MediaTypeOptions) { option.PropertyChanged -= OnMediaTypeSelectionChanged; } foreach (MultiSelectOptionViewModel option in MediaSourceOptions) { option.PropertyChanged -= OnMediaSourceSelectionChanged; } } private void OnMediaTypeSelectionChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(MultiSelectOptionViewModel.IsSelected) && MediaTypeOptions.Any(option => option.IsSelected)) { MediaTypesError = string.Empty; } } private void OnMediaSourceSelectionChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(MultiSelectOptionViewModel.IsSelected) && MediaSourceOptions.Any(option => option.IsSelected)) { MediaSourcesError = string.Empty; } } private void SetLoading(string message) { LoadingMessage = message; StatusMessage = message; CurrentScreen = AppScreen.Loading; } private void ShowError(string message) { ErrorMessage = message; StatusMessage = message; CurrentScreen = AppScreen.Error; AppendLog(message); } private void AppendLog(string message) { if (Dispatcher.UIThread.CheckAccess()) { AddLogEntry(message); return; } Dispatcher.UIThread.Post(() => AddLogEntry(message)); } private void AddLogEntry(string message) { ActivityLog.Add($"{DateTime.Now:HH:mm:ss} {message}"); if (ActivityLog.Count > 500) { ActivityLog.RemoveAt(0); } } private void OnSelectableUserPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(SelectableUserViewModel.IsSelected)) { OnPropertyChanged(nameof(SelectedUsersSummary)); DownloadSelectedCommand.NotifyCanExecuteChanged(); } } private void StartDownloadProgress(string description, long maxValue, bool showSize) { Dispatcher.UIThread.Post(() => { DownloadProgressDescription = description; DownloadProgressMaximum = Math.Max(1, maxValue); DownloadProgressValue = 0; IsDownloadProgressIndeterminate = maxValue <= 0; IsDownloadProgressVisible = true; }); } private void IncrementDownloadProgress(long increment) { Dispatcher.UIThread.Post(() => { if (IsDownloadProgressIndeterminate) { return; } DownloadProgressValue = Math.Min(DownloadProgressMaximum, DownloadProgressValue + increment); }); } private void UpdateProgressStatus(string message) { Dispatcher.UIThread.Post(() => { if (IsDownloadProgressVisible) { DownloadProgressDescription = message; } }); } private void StopDownloadProgress() { Dispatcher.UIThread.Post(() => { DownloadProgressDescription = string.Empty; DownloadProgressValue = 0; DownloadProgressMaximum = 1; IsDownloadProgressIndeterminate = false; IsDownloadProgressVisible = false; }); } private void ThrowIfStopRequested() { if (_workCancellationSource?.IsCancellationRequested == true) { throw new OperationCanceledException("Operation canceled by user."); } } private static Config CloneConfig(Config source) { string json = JsonConvert.SerializeObject(source); return JsonConvert.DeserializeObject(json) ?? new Config(); } private static bool IsHiddenConfigField(string propertyName) => propertyName is nameof(Config.NonInteractiveMode) or nameof(Config.NonInteractiveModeListName) or nameof(Config.NonInteractiveModePurchasedTab) or nameof(Config.DisableBrowserAuth) or nameof(Config.FFmpegPath) or nameof(Config.DownloadPath) or nameof(Config.DownloadVideos) or nameof(Config.DownloadImages) or nameof(Config.DownloadAudios) or nameof(Config.DownloadAvatarHeaderPhoto) or nameof(Config.DownloadPaidPosts) or nameof(Config.DownloadPosts) or nameof(Config.DownloadArchived) or nameof(Config.DownloadStreams) or nameof(Config.DownloadStories) or nameof(Config.DownloadHighlights) or nameof(Config.DownloadMessages) or nameof(Config.DownloadPaidMessages); private static string GetConfigCategory(string propertyName) => propertyName switch { nameof(Config.DisableBrowserAuth) => "Auth", nameof(Config.FFmpegPath) => "External", nameof(Config.DownloadAvatarHeaderPhoto) => "Download Media Types", nameof(Config.DownloadPaidPosts) => "Download Media Types", nameof(Config.DownloadPosts) => "Download Media Types", nameof(Config.DownloadArchived) => "Download Media Types", nameof(Config.DownloadStreams) => "Download Media Types", nameof(Config.DownloadStories) => "Download Media Types", nameof(Config.DownloadHighlights) => "Download Media Types", nameof(Config.DownloadMessages) => "Download Media Types", nameof(Config.DownloadPaidMessages) => "Download Media Types", nameof(Config.DownloadImages) => "Download Media Types", nameof(Config.DownloadVideos) => "Download Media Types", nameof(Config.DownloadAudios) => "Download Media Types", nameof(Config.IgnoreOwnMessages) => "Download Behavior", nameof(Config.DownloadPostsIncrementally) => "Download Behavior", nameof(Config.BypassContentForCreatorsWhoNoLongerExist) => "Download Behavior", nameof(Config.DownloadDuplicatedMedia) => "Download Behavior", nameof(Config.SkipAds) => "Download Behavior", nameof(Config.DownloadPath) => "Download Behavior", nameof(Config.DownloadOnlySpecificDates) => "Download Behavior", nameof(Config.DownloadDateSelection) => "Download Behavior", nameof(Config.CustomDate) => "Download Behavior", nameof(Config.ShowScrapeSize) => "Download Behavior", nameof(Config.DisableTextSanitization) => "Download Behavior", nameof(Config.DownloadVideoResolution) => "Download Behavior", nameof(Config.PaidPostFileNameFormat) => "File Naming", nameof(Config.PostFileNameFormat) => "File Naming", nameof(Config.PaidMessageFileNameFormat) => "File Naming", nameof(Config.MessageFileNameFormat) => "File Naming", nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected) => "File Naming", nameof(Config.CreatorConfigs) => "File Naming", nameof(Config.FolderPerPaidPost) => "Folder Structure", nameof(Config.FolderPerPost) => "Folder Structure", nameof(Config.FolderPerPaidMessage) => "Folder Structure", nameof(Config.FolderPerMessage) => "Folder Structure", nameof(Config.IncludeExpiredSubscriptions) => "Subscriptions", nameof(Config.IncludeRestrictedSubscriptions) => "Subscriptions", nameof(Config.IgnoredUsersListName) => "Subscriptions", nameof(Config.Timeout) => "Performance", nameof(Config.LimitDownloadRate) => "Performance", nameof(Config.DownloadLimitInMbPerSec) => "Performance", nameof(Config.LoggingLevel) => "Logging", _ => "Other" }; private static int GetCategoryOrder(string categoryName) => categoryName switch { "Auth" => 0, "External" => 1, "Download Media Types" => 2, "Download Behavior" => 3, "File Naming" => 4, "Folder Structure" => 5, "Subscriptions" => 6, "Performance" => 7, "Logging" => 8, _ => 100 }; }