OF-DL/OF DL.Gui/ViewModels/MainWindowViewModel.cs

1447 lines
53 KiB
C#

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<string, string> 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<string, long> _allUsers = [];
private Dictionary<string, long> _allLists = [];
private StartupResult _startupResult = new();
private CancellationTokenSource? _workCancellationSource;
private AppScreen _configReturnScreen = AppScreen.Loading;
private bool _isApplyingListSelection;
public ObservableCollection<ConfigFieldViewModel> ConfigFields { get; } = [];
public ObservableCollection<ConfigCategoryViewModel> ConfigCategories { get; } = [];
public ObservableCollection<ConfigCategoryViewModel> ConfigCategoriesLeft { get; } = [];
public ObservableCollection<ConfigCategoryViewModel> ConfigCategoriesRight { get; } = [];
public ObservableCollection<MultiSelectOptionViewModel> MediaTypeOptions { get; } = [];
public ObservableCollection<MultiSelectOptionViewModel> MediaSourceOptions { get; } = [];
public ObservableCollection<SelectableUserViewModel> AvailableUsers { get; } = [];
public ObservableCollection<string> UserLists { get; } = [];
public ObservableCollection<string> ActivityLog { get; } = [];
[ObservableProperty] private CreatorConfigEditorViewModel _creatorConfigEditor = new(Array.Empty<string>());
[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 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)
{
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();
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<string, long> 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 ===");
Log.Information("CreatorConfigEditor is null: {IsNull}", CreatorConfigEditor == null);
if (CreatorConfigEditor != null)
{
Log.Information("CreatorConfigEditor.AddCreatorCommand is null: {IsNull}", CreatorConfigEditor.AddCreatorCommand == null);
Log.Information("ModalViewModel is null: {IsNull}", CreatorConfigEditor.ModalViewModel == null);
if (CreatorConfigEditor.ModalViewModel != null)
{
Log.Information("ModalViewModel.IsOpen before: {IsOpen}", CreatorConfigEditor.ModalViewModel.IsOpen);
}
CreatorConfigEditor.AddCreatorCommand.Execute(null);
if (CreatorConfigEditor.ModalViewModel != null)
{
Log.Information("ModalViewModel.IsOpen after: {IsOpen}", CreatorConfigEditor.ModalViewModel.IsOpen);
}
}
}
[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<string, long> listUsers = await downloadOrchestrationService.GetUsersForListAsync(
SelectedListName,
_allUsers,
_allLists);
HashSet<string> 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<SelectableUserViewModel> 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);
AvaloniaDownloadEventHandler eventHandler = new(
AppendLog,
UpdateProgressStatus,
StartDownloadProgress,
IncrementDownloadProgress,
StopDownloadProgress,
() => _workCancellationSource?.IsCancellationRequested == true,
_workCancellationSource.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) => 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<bool> 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<bool> TryLoadAndValidateExistingAuthAsync()
{
bool loadedFromFile = await authService.LoadFromFileAsync();
if (!loadedFromFile)
{
IsAuthenticated = false;
AppendLog("No valid auth.json found.");
return false;
}
return await ValidateCurrentAuthAsync();
}
private async Task<bool> 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, 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<string, long> 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<string, object?> parsedValues = new(StringComparer.Ordinal);
Dictionary<string, ConfigFieldViewModel> 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<string, string> validationErrors = ConfigValidationService.Validate(config);
foreach (KeyValuePair<string, string> 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<System.Reflection.PropertyInfo> 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<string> ignoredUsersListNames = property.Name == nameof(Config.IgnoredUsersListName)
? _allLists.Keys
: [];
ConfigFields.Add(new ConfigFieldViewModel(
property,
value,
ignoredUsersListNames,
GetConfigHelpText(property.Name)));
}
IEnumerable<IGrouping<string, ConfigFieldViewModel>> grouped = ConfigFields
.GroupBy(field => GetConfigCategory(field.PropertyName))
.OrderBy(group => GetCategoryOrder(group.Key))
.ThenBy(group => group.Key);
int categoryIndex = 0;
foreach (IGrouping<string, ConfigFieldViewModel> group in grouped)
{
IEnumerable<ConfigFieldViewModel> 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()
{
IEnumerable<string> listNames = _allLists.Keys
.OrderBy(listName => listName, StringComparer.OrdinalIgnoreCase);
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();
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<MultiSelectOptionViewModel> 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 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<MultiSelectOptionViewModel> 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<Config>(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
};
}