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 Serilog; 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 static readonly Dictionary s_configHelpTextByProperty = new(StringComparer.Ordinal) { [nameof(Config.FFmpegPath)] = "Path to the FFmpeg executable. If blank, OF-DL will try the app directory and PATH.", [nameof(Config.DownloadPath)] = "Base download folder. If blank, OF-DL uses __user_data__/sites/OnlyFans/{username}.", [nameof(Config.DownloadVideos)] = "Download video media when enabled.", [nameof(Config.DownloadImages)] = "Download image media when enabled.", [nameof(Config.DownloadAudios)] = "Download audio media when enabled.", [nameof(Config.DownloadAvatarHeaderPhoto)] = "Download creator avatar and header images when enabled.", [nameof(Config.DownloadPosts)] = "Download free posts when enabled.", [nameof(Config.DownloadPaidPosts)] = "Download paid posts when enabled.", [nameof(Config.DownloadArchived)] = "Download archived posts when enabled.", [nameof(Config.DownloadStreams)] = "Download posts from the Streams tab when enabled.", [nameof(Config.DownloadStories)] = "Download stories when enabled.", [nameof(Config.DownloadHighlights)] = "Download highlights when enabled.", [nameof(Config.DownloadMessages)] = "Download free media from messages (including paid-message previews) when enabled.", [nameof(Config.DownloadPaidMessages)] = "Download paid media from messages (excluding preview media) when enabled.", [nameof(Config.IgnoreOwnMessages)] = "Ignore your own sent messages and do not download media sent by your account.", [nameof(Config.DownloadPostsIncrementally)] = "Only download new posts after the latest downloaded post in metadata DB.", [nameof(Config.BypassContentForCreatorsWhoNoLongerExist)] = "Allow downloading accessible purchased content for deleted creators.", [nameof(Config.DownloadDuplicatedMedia)] = "When enabled, duplicate media can be downloaded instead of being skipped.", [nameof(Config.SkipAds)] = "Skip posts/messages containing #ad or free-trial links when enabled.", [nameof(Config.DownloadOnlySpecificDates)] = "Limit downloads by date using DownloadDateSelection and CustomDate.", [nameof(Config.DownloadDateSelection)] = "Choose whether date filtering uses content before or after CustomDate.", [nameof(Config.CustomDate)] = "Date used for date-filtered downloads (yyyy-MM-dd).", [nameof(Config.ShowScrapeSize)] = "Show total byte size instead of item counts during download progress.", [nameof(Config.DisableTextSanitization)] = "Store post/message text as-is without XML stripping.", [nameof(Config.DownloadVideoResolution)] = "Choose preferred video resolution (source, 240, or 720 when available).", [nameof(Config.PaidPostFileNameFormat)] = "Custom filename format for paid posts. See custom filename formats docs.", [nameof(Config.PostFileNameFormat)] = "Custom filename format for free posts/archived/streams. See custom filename formats docs.", [nameof(Config.PaidMessageFileNameFormat)] = "Custom filename format for paid messages. See custom filename formats docs.", [nameof(Config.MessageFileNameFormat)] = "Custom filename format for free messages. See custom filename formats docs.", [nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected)] = "Rename previously downloaded files when custom filename format is enabled.", [nameof(Config.CreatorConfigs)] = "Per-creator filename format overrides. Values here override global filename formats.", [nameof(Config.FolderPerPaidPost)] = "Create a separate folder per paid post when enabled.", [nameof(Config.FolderPerPost)] = "Create a separate folder per free post when enabled.", [nameof(Config.FolderPerPaidMessage)] = "Create a separate folder per paid message when enabled.", [nameof(Config.FolderPerMessage)] = "Create a separate folder per free message when enabled.", [nameof(Config.IncludeExpiredSubscriptions)] = "Include expired subscriptions in user selection.", [nameof(Config.IncludeRestrictedSubscriptions)] = "Include restricted creators in scraping and download flow.", [nameof(Config.IgnoredUsersListName)] = "Users in this list are ignored during scraping. Empty means no users are ignored.", [nameof(Config.Timeout)] = "HTTP timeout override in seconds (-1 uses default behavior).", [nameof(Config.LimitDownloadRate)] = "Enable download speed limiting.", [nameof(Config.DownloadLimitInMbPerSec)] = "Download rate limit in MB/s when rate limiting is enabled.", [nameof(Config.LoggingLevel)] = "Log verbosity written to logs/OFDL.txt." }; private Dictionary _allUsers = []; private Dictionary _allLists = []; private StartupResult _startupResult = new(); private CancellationTokenSource? _workCancellationSource; private AppScreen _configReturnScreen = AppScreen.Loading; private bool _isApplyingListSelection; private ObservableCollection ConfigFields { get; } = []; private static ObservableCollection ConfigCategories => []; public ObservableCollection ConfigCategoriesLeft { get; } = []; public ObservableCollection ConfigCategoriesRight { get; } = []; public ObservableCollection MediaTypeOptions { get; } = []; public ObservableCollection MediaSourceOptions { get; } = []; public ObservableCollection AvailableUsers { get; } = []; public ObservableCollection UserLists { get; } = []; public ObservableCollection ActivityLog { get; } = []; [ObservableProperty] private CreatorConfigEditorViewModel _creatorConfigEditor = new(Array.Empty()); [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; private string _actualFfmpegPath = string.Empty; private string _actualDownloadPath = string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(FfmpegPathDisplay))] private string _ffmpegPath = string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasFfmpegPathError))] private string _ffmpegPathError = string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(DownloadPathDisplay))] 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; public bool HidePrivateInfo { get; } = Program.HidePrivateInfo; [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 FfmpegPathDisplay => HidePrivateInfo && !string.IsNullOrWhiteSpace(FfmpegPath) ? "[Hidden for Privacy]" : FfmpegPath; public string DownloadPathDisplay => HidePrivateInfo && !string.IsNullOrWhiteSpace(DownloadPath) ? "[Hidden for Privacy]" : DownloadPath; public string FfmpegPathHelpText => GetConfigHelpText(nameof(Config.FFmpegPath)); public string DownloadPathHelpText => GetConfigHelpText(nameof(Config.DownloadPath)); public string SelectedUsersSummary => $"{AvailableUsers.Count(user => user.IsSelected)} / {AvailableUsers.Count} selected"; public bool? AllUsersSelected { get { if (AvailableUsers.Count == 0) { return false; } int selectedCount = AvailableUsers.Count(user => user.IsSelected); if (selectedCount == 0) { return false; } if (selectedCount == AvailableUsers.Count) { return true; } return null; } set { bool? current = AllUsersSelected; bool shouldSelectAll; if (current == true) { shouldSelectAll = false; } else if (current == false) { shouldSelectAll = true; } else { shouldSelectAll = value == true; } _isUpdatingAllUsersSelected = true; try { foreach (SelectableUserViewModel user in AvailableUsers) { user.IsSelected = shouldSelectAll; } } finally { _isUpdatingAllUsersSelected = false; } OnPropertyChanged(nameof(SelectedUsersSummary)); DownloadSelectedCommand.NotifyCanExecuteChanged(); } } private bool _isUpdatingAllUsersSelected; public async Task InitializeAsync() { if (HasInitialized) { return; } HasInitialized = true; await BeginStartupAsync(); } public void SetFfmpegPath(string? path) { string normalizedPath = NormalizePathForDisplay(path); _actualFfmpegPath = normalizedPath; FfmpegPath = HidePrivateInfo && !string.IsNullOrWhiteSpace(normalizedPath) ? "[Hidden for Privacy]" : normalizedPath; FfmpegPathError = string.Empty; } public void SetDownloadPath(string? path) { string normalizedPath = NormalizePathForDisplay(path); _actualDownloadPath = normalizedPath; DownloadPath = HidePrivateInfo && !string.IsNullOrWhiteSpace(normalizedPath) ? "[Hidden for Privacy]" : normalizedPath; 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(); RefreshIgnoredUsersListsCommand.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(CanExecute = nameof(CanRefreshIgnoredUsersLists))] private async Task RefreshIgnoredUsersListsAsync(ConfigFieldViewModel? field) { if (field == null || !field.IsIgnoredUsersListField) { return; } ConfigScreenMessage = "Refreshing user lists..."; StatusMessage = ConfigScreenMessage; try { Dictionary latestLists = await downloadOrchestrationService.GetUserListsAsync(); _allLists = latestLists .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) .ToDictionary(pair => pair.Key, pair => pair.Value); UpdateUserListsCollection(); UpdateIgnoredUsersListFieldOptions(); ConfigScreenMessage = $"User lists refreshed ({_allLists.Count})."; StatusMessage = ConfigScreenMessage; AppendLog(ConfigScreenMessage); } catch (Exception ex) { ConfigScreenMessage = $"Could not refresh user lists: {ex.Message}"; StatusMessage = ConfigScreenMessage; AppendLog(ConfigScreenMessage); } } [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 void AddCreatorConfig() { Log.Information("=== AddCreatorConfig command called ==="); CreatorConfigEditor.AddCreatorCommand.Execute(null); } [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(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)); OnPropertyChanged(nameof(AllUsersSelected)); DownloadSelectedCommand.NotifyCanExecuteChanged(); } finally { _isApplyingListSelection = false; StopDownloadProgress(); } } [RelayCommand(CanExecute = nameof(CanDownloadSelected))] private async Task DownloadSelectedAsync() => await RunDownloadAsync(false); [RelayCommand(CanExecute = nameof(CanDownloadPurchasedTab))] private async Task DownloadPurchasedTabAsync() => await RunDownloadAsync(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."); StatusMessage = downloadPurchasedTabOnly ? "Starting Purchased Tab download..." : $"Starting download for {selectedUsers.Count} users..."; // Show progress bar immediately with indeterminate state StartDownloadProgress( downloadPurchasedTabOnly ? "Initializing Purchased Tab download..." : $"Initializing download for {selectedUsers.Count} users...", 0, false); CancellationTokenSource cancellationSource = _workCancellationSource; AvaloniaDownloadEventHandler eventHandler = new( AppendLog, UpdateProgressStatus, StartDownloadProgress, IncrementDownloadProgress, StopDownloadProgress, () => cancellationSource.IsCancellationRequested, cancellationSource.Token); 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 CanRefreshIgnoredUsersLists() => CurrentScreen == AppScreen.Config && IsAuthenticated && !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(); RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged(); } partial void OnIsDownloadingChanged(bool value) { DownloadSelectedCommand.NotifyCanExecuteChanged(); DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); SelectUsersFromListCommand.NotifyCanExecuteChanged(); StopWorkCommand.NotifyCanExecuteChanged(); RefreshUsersCommand.NotifyCanExecuteChanged(); RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged(); } partial void OnIsAuthenticatedChanged(bool value) { LogoutCommand.NotifyCanExecuteChanged(); RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); } partial void OnSelectedListNameChanged(string? value) { SelectUsersFromListCommand.NotifyCanExecuteChanged(); if (_isApplyingListSelection || IsDownloading || CurrentScreen != AppScreen.UserSelection || string.IsNullOrWhiteSpace(value)) { return; } _ = SelectUsersFromListAsync(); } partial void OnFfmpegPathChanged(string value) { if (value != "[Hidden for Privacy]") { _actualFfmpegPath = value; } FfmpegPathError = string.Empty; } partial void OnDownloadPathChanged(string value) { if (value != "[Hidden for Privacy]") { _actualDownloadPath = 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"; if (HidePrivateInfo) { AuthenticatedUserDisplay = "[Hidden for Privacy]"; AppendLog("Authenticated as [Hidden for Privacy]."); } else { AuthenticatedUserDisplay = $"{displayName} ({displayUsername})"; AppendLog($"Authenticated as {AuthenticatedUserDisplay}."); } IsAuthenticated = true; return true; } private async Task LoadUsersAndListsAsync() { SetLoading("Fetching users and user lists..."); UserListResult listResult = await downloadOrchestrationService.GetAvailableUsersAsync(); _allUsers = listResult.Users .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) .ToDictionary(pair => pair.Key, pair => pair.Value); _allLists = listResult.Lists .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) .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)); OnPropertyChanged(nameof(AllUsersSelected)); UpdateUserListsCollection(); UpdateIgnoredUsersListFieldOptions(); CreatorConfigEditor.UpdateAvailableUsers(_allUsers.Keys); 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); config.CreatorConfigs = CreatorConfigEditor.ToDictionary(); 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); } } return !ConfigFields.Any(field => field.HasError) && !HasSpecialConfigErrors(); } private void BuildConfigFields(Config config) { ConfigFields.Clear(); ConfigCategories.Clear(); ConfigCategoriesLeft.Clear(); ConfigCategoriesRight.Clear(); BuildSpecialConfigInputs(config); CreatorConfigEditor = new CreatorConfigEditorViewModel(_allUsers.Keys); CreatorConfigEditor.LoadFromConfig(config.CreatorConfigs); 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); IEnumerable ignoredUsersListNames = property.Name == nameof(Config.IgnoredUsersListName) ? _allLists.Keys : []; ConfigFields.Add(new ConfigFieldViewModel( property, value, ignoredUsersListNames, GetConfigHelpText(property.Name))); } IEnumerable> grouped = ConfigFields .GroupBy(field => GetConfigCategory(field.PropertyName)) .OrderBy(group => GetCategoryOrder(group.Key)) .ThenBy(group => group.Key); int categoryIndex = 0; foreach (IGrouping group in grouped) { IEnumerable orderedFields = group.Key == "File Naming" ? group.OrderBy(field => GetFieldOrder(group.Key, field.PropertyName)) : group.OrderBy(field => field.DisplayName); ConfigCategoryViewModel category = new(group.Key, orderedFields); ConfigCategories.Add(category); if (categoryIndex % 2 == 0) { ConfigCategoriesLeft.Add(category); } else { ConfigCategoriesRight.Add(category); } categoryIndex++; } } private static int GetFieldOrder(string categoryName, string propertyName) { if (categoryName == "File Naming") { return propertyName switch { nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected) => 0, nameof(Config.PaidPostFileNameFormat) => 1, nameof(Config.PostFileNameFormat) => 2, nameof(Config.PaidMessageFileNameFormat) => 3, nameof(Config.MessageFileNameFormat) => 4, nameof(Config.CreatorConfigs) => 5, _ => 100 }; } return 0; } private void UpdateIgnoredUsersListFieldOptions() { List listNames = _allLists.Keys .OrderBy(listName => listName, StringComparer.OrdinalIgnoreCase).ToList(); foreach (ConfigFieldViewModel field in ConfigFields.Where(field => field.IsIgnoredUsersListField)) { field.SetIgnoredUsersListOptions(listNames); } } private void UpdateUserListsCollection() { UserLists.Clear(); foreach (string listName in _allLists.Keys.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)) { UserLists.Add(listName); } } private void BuildSpecialConfigInputs(Config config) { UnsubscribeSpecialSelectionEvents(); string ffmpegPath = NormalizePathForDisplay(config.FFmpegPath); _actualFfmpegPath = ffmpegPath; FfmpegPath = HidePrivateInfo && !string.IsNullOrWhiteSpace(ffmpegPath) ? "[Hidden for Privacy]" : ffmpegPath; string downloadPath = ResolveDownloadPathForDisplay(config.DownloadPath); _actualDownloadPath = downloadPath; DownloadPath = HidePrivateInfo && !string.IsNullOrWhiteSpace(downloadPath) ? "[Hidden for Privacy]" : 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), GetConfigHelpText(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 pathToUse = HidePrivateInfo ? _actualFfmpegPath : FfmpegPath; string normalizedFfmpegPath = NormalizePathForDisplay(pathToUse); config.FFmpegPath = string.IsNullOrWhiteSpace(normalizedFfmpegPath) ? string.Empty : EscapePathForConfig(normalizedFfmpegPath); string downloadPathToUse = HidePrivateInfo ? _actualDownloadPath : DownloadPath; string normalizedDownloadPath = NormalizePathForDisplay(downloadPathToUse); 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 static string GetConfigHelpText(string propertyName) => s_configHelpTextByProperty.TryGetValue(propertyName, out string? helpText) ? helpText : string.Empty; 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)); if (!_isUpdatingAllUsersSelected) { OnPropertyChanged(nameof(AllUsersSelected)); } 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 }; }