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

2286 lines
84 KiB
C#

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Reflection;
using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Media;
using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Avalonia.Threading;
using Newtonsoft.Json;
using OF_DL.Enumerations;
using OF_DL.Gui.Services;
using OF_DL.Helpers;
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 enum SingleDownloadType
{
Post,
PaidMessage
}
private readonly record struct SingleDownloadRequest(
SingleDownloadType Type,
long ContentId,
string Username,
long? UserId);
private const string UnknownToolVersion = "Not detected";
private static readonly Regex s_singlePostUrlRegex = new(
@"^https://onlyfans\.com/(?<postId>\d+)/(?<username>[A-Za-z0-9_.-]+)/?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex s_singlePaidMessageUrlRegex = new(
@"^https://onlyfans\.com/my/chats/chat/(?<userId>\d+)/?\?firstId=(?<messageId>\d+)$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly string s_defaultDownloadPath = Path.GetFullPath(
Path.Combine(Directory.GetCurrentDirectory(), "__user_data__", "sites", "OnlyFans"));
private static readonly string s_programVersion = ResolveProgramVersion();
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 =
[
("Free Posts", nameof(Config.DownloadPosts)),
("Paid Posts", nameof(Config.DownloadPaidPosts)),
("Free Messages", nameof(Config.DownloadMessages)),
("Paid Messages", nameof(Config.DownloadPaidMessages)),
("Archived", nameof(Config.DownloadArchived)),
("Streams", nameof(Config.DownloadStreams)),
("Stories", nameof(Config.DownloadStories)),
("Highlights", nameof(Config.DownloadHighlights)),
("Avatar/Header Photo", nameof(Config.DownloadAvatarHeaderPhoto))
];
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.FFprobePath)] =
"Path to the FFprobe executable. If blank, OF-DL will try FFmpeg's directory, the app directory, and PATH.",
[nameof(Config.DownloadPath)] =
"Base download folder. If blank, OF-DL uses __user_data__/sites/OnlyFans/{username}.",
[nameof(Config.DrmVideoDurationMatchThreshold)] =
"Minimum DRM video duration match threshold. Higher values are stricter. 100% requires an exact duration match. 98% is the recommended value.",
[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.Theme)] =
"GUI theme for the configuration and download screens.",
[nameof(Config.HideMissingCdmKeysWarning)] =
"Hide the missing CDM keys warning before downloads start.",
[nameof(Config.LoggingLevel)] =
"Log verbosity written to logs/OFDL.txt."
};
private static readonly Dictionary<string, string> s_lightThemeBrushes = new(StringComparer.Ordinal)
{
["WindowBackgroundBrush"] = "#EEF3FB",
["SurfaceBackgroundBrush"] = "#FFFFFF",
["SurfaceBorderBrush"] = "#DDE5F3",
["PrimaryButtonBackgroundBrush"] = "#2E6EEA",
["PrimaryButtonForegroundBrush"] = "#FFFFFF",
["SecondaryButtonBackgroundBrush"] = "#FFFFFF",
["SecondaryButtonForegroundBrush"] = "#1F2A44",
["SecondaryButtonBorderBrush"] = "#CFD9EB",
["TopBarBackgroundBrush"] = "#DDEAFF",
["TopBarBorderBrush"] = "#CFD9EB",
["TopBarTextBrush"] = "#304261",
["TextPrimaryBrush"] = "#1F2A44",
["TextSecondaryBrush"] = "#4A5B78",
["HelpBadgeBackgroundBrush"] = "#EAF0FB",
["HelpBadgeBorderBrush"] = "#C5D4EC",
["ErrorTextBrush"] = "#FF5A5A",
["SuccessTextBrush"] = "#2C8A4B",
["WarningTextBrush"] = "#B16A00",
["PreviewBackgroundBrush"] = "#F5F8FE",
["PreviewBorderBrush"] = "#D8E3F4",
["DangerSoftBackgroundBrush"] = "#FFE8E8",
["DangerSoftBorderBrush"] = "#E8C5C5",
["DangerButtonBackgroundBrush"] = "#D84E4E",
["OverlayBackgroundBrush"] = "#80000000",
["ModalBackgroundBrush"] = "#FFFFFF",
["ModalBorderBrush"] = "#DDE5F3"
};
private static readonly Dictionary<string, string> s_darkThemeBrushes = new(StringComparer.Ordinal)
{
["WindowBackgroundBrush"] = "#0F141D",
["SurfaceBackgroundBrush"] = "#151C28",
["SurfaceBorderBrush"] = "#2A3445",
["PrimaryButtonBackgroundBrush"] = "#4C8DFF",
["PrimaryButtonForegroundBrush"] = "#FFFFFF",
["SecondaryButtonBackgroundBrush"] = "#1C2533",
["SecondaryButtonForegroundBrush"] = "#DCE6F7",
["SecondaryButtonBorderBrush"] = "#33425A",
["TopBarBackgroundBrush"] = "#1A2433",
["TopBarBorderBrush"] = "#33425A",
["TopBarTextBrush"] = "#C7D6EE",
["TextPrimaryBrush"] = "#DCE6F7",
["TextSecondaryBrush"] = "#A8B8D2",
["HelpBadgeBackgroundBrush"] = "#233145",
["HelpBadgeBorderBrush"] = "#3A4E6A",
["ErrorTextBrush"] = "#FF8C8C",
["SuccessTextBrush"] = "#6BD98A",
["WarningTextBrush"] = "#FFB357",
["PreviewBackgroundBrush"] = "#1B2636",
["PreviewBorderBrush"] = "#314359",
["DangerSoftBackgroundBrush"] = "#3A2024",
["DangerSoftBorderBrush"] = "#6A3A40",
["DangerButtonBackgroundBrush"] = "#CC4A4A",
["OverlayBackgroundBrush"] = "#99000000",
["ModalBackgroundBrush"] = "#151C28",
["ModalBorderBrush"] = "#2A3445"
};
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;
private ObservableCollection<ConfigFieldViewModel> ConfigFields { get; } = [];
private static ObservableCollection<ConfigCategoryViewModel> ConfigCategories => [];
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 _manualAuthScreenMessage = string.Empty;
[ObservableProperty] private string _manualAuthInstructionsText = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(GoBackToAuthScreenCommand))]
[NotifyCanExecuteChangedFor(nameof(ContinueWithManualAuthCommand))]
private bool _isManualAuthValidationInProgress;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartBrowserLoginCommand))]
[NotifyCanExecuteChangedFor(nameof(OpenManualAuthScreenCommand))]
private bool _isBrowserLoginInProgress;
[ObservableProperty] private string _errorMessage = string.Empty;
private string _actualFfmpegPath = string.Empty;
private string _actualFfprobePath = 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(HasFfprobePathError))]
private string _ffprobePath = string.Empty;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasFfprobePathError))]
private string _ffprobePathError = string.Empty;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(DownloadPathDisplay))]
private string _downloadPath = string.Empty;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasDownloadPathError))]
private string _downloadPathError = string.Empty;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(DrmVideoDurationMatchThresholdPercentLabel))]
private double _drmVideoDurationMatchThresholdPercent = 98;
[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;
public string ProgramVersion => s_programVersion;
public string FfmpegVersion => string.IsNullOrWhiteSpace(_startupResult.FfmpegVersion)
? UnknownToolVersion
: _startupResult.FfmpegVersion;
public string FfprobeVersion => string.IsNullOrWhiteSpace(_startupResult.FfprobeVersion)
? UnknownToolVersion
: _startupResult.FfprobeVersion;
[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;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(OpenSinglePostOrMessageModalCommand))]
[NotifyCanExecuteChangedFor(nameof(SubmitSinglePostOrMessageCommand))]
[NotifyCanExecuteChangedFor(nameof(DownloadSelectedCommand))]
[NotifyCanExecuteChangedFor(nameof(DownloadPurchasedTabCommand))]
private bool _isSinglePostOrMessageModalOpen;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(OpenSinglePostOrMessageModalCommand))]
[NotifyCanExecuteChangedFor(nameof(SubmitSinglePostOrMessageCommand))]
[NotifyCanExecuteChangedFor(nameof(DownloadSelectedCommand))]
[NotifyCanExecuteChangedFor(nameof(DownloadPurchasedTabCommand))]
private bool _isMissingCdmWarningModalOpen;
[ObservableProperty] private string _missingCdmWarningMessage = string.Empty;
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SubmitSinglePostOrMessageCommand))]
private string _singlePostOrMessageUrl = string.Empty;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasSinglePostOrMessageUrlError))]
private string _singlePostOrMessageUrlError = string.Empty;
public bool IsLoadingScreen => CurrentScreen == AppScreen.Loading;
public bool IsConfigScreen => CurrentScreen == AppScreen.Config;
public bool IsAuthScreen => CurrentScreen == AppScreen.Auth;
public bool IsManualAuthScreen => CurrentScreen == AppScreen.ManualAuth;
public bool IsUserSelectionScreen => CurrentScreen == AppScreen.UserSelection;
public bool IsErrorScreen => CurrentScreen == AppScreen.Error;
public bool HasFfmpegPathError => !string.IsNullOrWhiteSpace(FfmpegPathError);
public bool HasFfprobePathError => !string.IsNullOrWhiteSpace(FfprobePathError);
public bool HasDownloadPathError => !string.IsNullOrWhiteSpace(DownloadPathError);
public bool HasMediaTypesError => !string.IsNullOrWhiteSpace(MediaTypesError);
public bool HasMediaSourcesError => !string.IsNullOrWhiteSpace(MediaSourcesError);
public bool HasSinglePostOrMessageUrlError => !string.IsNullOrWhiteSpace(SinglePostOrMessageUrlError);
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 FfprobePathHelpText => GetConfigHelpText(nameof(Config.FFprobePath));
public string DownloadPathHelpText => GetConfigHelpText(nameof(Config.DownloadPath));
public string DrmVideoDurationMatchThresholdHelpText =>
GetConfigHelpText(nameof(Config.DrmVideoDurationMatchThreshold));
public string DrmVideoDurationMatchThresholdPercentLabel =>
$"{Math.Round(DrmVideoDurationMatchThresholdPercent)}%";
public string HideMissingCdmKeysWarningStatusText =>
_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing
? "(CDM keys missing)"
: "(CDM keys detected)";
public IBrush HideMissingCdmKeysWarningStatusBrush =>
ResolveThemeBrush(
_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing
? "WarningTextBrush"
: "SuccessTextBrush",
_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing
? "#FFB357"
: "#6BD98A");
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
{
if (!string.IsNullOrWhiteSpace(SelectedListName))
{
SelectedListName = null;
}
foreach (SelectableUserViewModel user in AvailableUsers)
{
user.IsSelected = shouldSelectAll;
}
}
finally
{
_isUpdatingAllUsersSelected = false;
}
OnPropertyChanged(nameof(SelectedUsersSummary));
DownloadSelectedCommand.NotifyCanExecuteChanged();
}
}
private bool _isUpdatingAllUsersSelected;
private TaskCompletionSource<bool>? _missingCdmWarningCompletionSource;
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 SetFfprobePath(string? path)
{
string normalizedPath = NormalizePathForDisplay(path);
_actualFfprobePath = normalizedPath;
FfprobePath = HidePrivateInfo && !string.IsNullOrWhiteSpace(normalizedPath)
? "[Hidden for Privacy]"
: normalizedPath;
FfprobePathError = 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;
IsManualAuthValidationInProgress = false;
ManualAuthScreenMessage = string.Empty;
AuthenticatedUserDisplay = "Not authenticated.";
AuthScreenMessage =
"You have been logged out. OF DL needs access to your OnlyFans account.\nAn included web browser can be used to sign-in, or you can create an \"auth.json\" file manually.";
CurrentScreen = AppScreen.Auth;
OnPropertyChanged(nameof(SelectedUsersSummary));
DownloadSelectedCommand.NotifyCanExecuteChanged();
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
SelectUsersFromListCommand.NotifyCanExecuteChanged();
RefreshUsersCommand.NotifyCanExecuteChanged();
RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(CanEditConfig))]
private void EditConfig()
{
if (CurrentScreen == AppScreen.Config)
{
return;
}
_configReturnScreen = CurrentScreen;
EnforceGuiOnlyConfigValues(configService.CurrentConfig);
BuildConfigFields(configService.CurrentConfig);
ConfigScreenMessage = "Edit configuration values and save to apply changes.";
CurrentScreen = AppScreen.Config;
}
[RelayCommand]
private async Task CancelConfigAsync()
{
bool loaded = await configService.LoadConfigurationAsync([]);
EnforceGuiOnlyConfigValues(configService.CurrentConfig);
BuildConfigFields(configService.CurrentConfig);
if (!loaded)
{
ConfigScreenMessage = "Configuration is still invalid.";
CurrentScreen = AppScreen.Config;
return;
}
if (_configReturnScreen == AppScreen.UserSelection && _allUsers.Count > 0)
{
CurrentScreen = AppScreen.UserSelection;
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...";
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}).";
AppendLog(ConfigScreenMessage);
}
catch (Exception ex)
{
AppendLog($"Could not refresh user lists: {ex.Message}");
}
}
[RelayCommand]
private async Task SaveConfigAsync()
{
if (!TryBuildConfig(out Config newConfig))
{
ConfigScreenMessage = "Fix configuration validation errors and save again.";
return;
}
EnforceGuiOnlyConfigValues(newConfig);
configService.UpdateConfig(newConfig);
await configService.SaveConfigurationAsync();
bool reloaded = await configService.LoadConfigurationAsync([]);
EnforceGuiOnlyConfigValues(configService.CurrentConfig);
if (!reloaded)
{
ConfigScreenMessage = "config.conf could not be loaded after saving. Please review your values.";
CurrentScreen = AppScreen.Config;
BuildConfigFields(configService.CurrentConfig);
return;
}
BuildConfigFields(configService.CurrentConfig);
ConfigScreenMessage = "Configuration saved.";
if (!await ValidateEnvironmentAsync(false))
{
return;
}
await EnsureAuthenticationAndLoadUsersAsync(false);
}
[RelayCommand]
private void AddCreatorConfig()
{
Log.Information("=== AddCreatorConfig command called ===");
CreatorConfigEditor.AddCreatorCommand.Execute(null);
}
[RelayCommand(CanExecute = nameof(CanStartBrowserLogin))]
private async Task StartBrowserLoginAsync()
{
IsBrowserLoginInProgress = true;
bool success;
try
{
success = await authService.LoadFromBrowserAsync(message =>
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
Dispatcher.UIThread.Post(() => SetLoading(message));
});
}
finally
{
IsBrowserLoginInProgress = false;
}
if (!success || authService.CurrentAuth == null)
{
AuthScreenMessage =
"Authentication failed. Retry the browser based login, or use\n'Manual Authentication' to continue.";
CurrentScreen = AppScreen.Auth;
return;
}
await authService.SaveToFileAsync();
bool isAuthValid = await ValidateCurrentAuthAsync();
if (!isAuthValid)
{
AuthScreenMessage =
"Authentication is still invalid after login.\nPlease retry, or use 'Manual Authentication' to continue.";
CurrentScreen = AppScreen.Auth;
return;
}
await LoadUsersAndListsAsync();
}
[RelayCommand(CanExecute = nameof(CanOpenManualAuthScreen))]
private void OpenManualAuthScreen()
{
IsManualAuthValidationInProgress = false;
ManualAuthScreenMessage = string.Empty;
string configFolder = EnvironmentHelper.IsRunningInDocker()
? "your OF DL config folder"
: "the same folder as OF DL";
ManualAuthInstructionsText =
$"An \"auth.json\" file is required to use OF DL. See the documentation for creating this file manually." +
$"\nOnce you've created the \"auth.json\", save it to {configFolder}.";
CurrentScreen = AppScreen.ManualAuth;
}
[RelayCommand(CanExecute = nameof(CanGoBackToAuthScreen))]
private void GoBackToAuthScreen()
{
IsManualAuthValidationInProgress = false;
ManualAuthScreenMessage = string.Empty;
AuthScreenMessage =
"OF DL needs access to your OnlyFans account.\nAn included web browser can be used to sign-in, or you can create an \"auth.json\" file manually.";
CurrentScreen = AppScreen.Auth;
}
[RelayCommand(CanExecute = nameof(CanContinueWithManualAuth))]
private async Task ContinueWithManualAuthAsync()
{
IsManualAuthValidationInProgress = true;
ManualAuthScreenMessage = "Validating auth.json...";
try
{
bool loadedFromFile = await authService.LoadFromFileAsync();
if (!loadedFromFile)
{
authService.CurrentAuth = null;
IsAuthenticated = false;
AuthenticatedUserDisplay = "Not authenticated.";
ManualAuthScreenMessage =
"auth.json was not found or could not be read. Update auth.json and try again.";
return;
}
bool isValid = await ValidateCurrentAuthAsync();
if (!isValid)
{
ManualAuthScreenMessage =
"auth.json failed validation. Update the file and try again.";
return;
}
await LoadUsersAndListsAsync();
}
finally
{
IsManualAuthValidationInProgress = false;
}
}
[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);
}
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(CanOpenSinglePostOrMessageModal))]
private void OpenSinglePostOrMessageModal()
{
SinglePostOrMessageUrl = string.Empty;
SinglePostOrMessageUrlError = string.Empty;
IsSinglePostOrMessageModalOpen = true;
}
[RelayCommand(CanExecute = nameof(CanSubmitSinglePostOrMessage))]
private async Task SubmitSinglePostOrMessageAsync()
{
if (!TryParseSinglePostOrMessageUrl(SinglePostOrMessageUrl, out SingleDownloadRequest request,
out string validationError))
{
SinglePostOrMessageUrlError = validationError;
return;
}
IsSinglePostOrMessageModalOpen = false;
SinglePostOrMessageUrlError = string.Empty;
await RunSinglePostOrMessageDownloadAsync(request);
}
[RelayCommand]
private void CancelSinglePostOrMessage()
{
SinglePostOrMessageUrlError = string.Empty;
IsSinglePostOrMessageModalOpen = false;
}
[RelayCommand]
private void ConfirmMissingCdmWarning()
{
IsMissingCdmWarningModalOpen = false;
_missingCdmWarningCompletionSource?.TrySetResult(true);
}
[RelayCommand]
private void CancelMissingCdmWarning()
{
IsMissingCdmWarningModalOpen = false;
_missingCdmWarningCompletionSource?.TrySetResult(false);
}
[RelayCommand(CanExecute = nameof(CanStopWork))]
private void StopWork()
{
if (_workCancellationSource is { IsCancellationRequested: false })
{
_workCancellationSource.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)
{
return;
}
if (downloadPurchasedTabOnly && _allUsers.Count == 0)
{
return;
}
if (!await EnsureMissingCdmWarningConfirmedAsync())
{
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.");
// 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);
}
catch (OperationCanceledException)
{
AppendLog("Operation canceled.");
}
catch (Exception ex)
{
AppendLog($"Download failed: {ex.Message}");
}
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) &&
!IsMissingCdmWarningModalOpen &&
!IsDownloading;
private bool CanDownloadPurchasedTab() =>
CurrentScreen == AppScreen.UserSelection &&
_allUsers.Count > 0 &&
!IsMissingCdmWarningModalOpen &&
!IsDownloading;
private bool CanOpenSinglePostOrMessageModal() =>
CurrentScreen == AppScreen.UserSelection &&
!IsDownloading &&
!IsMissingCdmWarningModalOpen &&
!IsSinglePostOrMessageModalOpen;
private bool CanSubmitSinglePostOrMessage() =>
IsSinglePostOrMessageModalOpen &&
!IsMissingCdmWarningModalOpen &&
!IsDownloading &&
!string.IsNullOrWhiteSpace(SinglePostOrMessageUrl);
private bool CanStopWork() => IsDownloading;
private bool CanRefreshUsers() =>
CurrentScreen == AppScreen.UserSelection && !IsDownloading;
private bool CanRefreshIgnoredUsersLists() =>
CurrentScreen == AppScreen.Config && IsAuthenticated && !IsDownloading;
private bool CanLogout() => IsAuthenticated && !IsDownloading;
private bool CanEditConfig() =>
CurrentScreen != AppScreen.Config &&
CurrentScreen != AppScreen.Loading &&
!IsDownloading;
private bool CanStartBrowserLogin() =>
CurrentScreen == AppScreen.Auth &&
!IsDownloading &&
!IsBrowserLoginInProgress;
private bool CanOpenManualAuthScreen() =>
CurrentScreen == AppScreen.Auth &&
!IsDownloading &&
!IsBrowserLoginInProgress;
private bool CanGoBackToAuthScreen() =>
CurrentScreen == AppScreen.ManualAuth &&
!IsDownloading &&
!IsManualAuthValidationInProgress;
private bool CanContinueWithManualAuth() =>
CurrentScreen == AppScreen.ManualAuth &&
!IsDownloading &&
!IsManualAuthValidationInProgress;
partial void OnCurrentScreenChanged(AppScreen value)
{
OnPropertyChanged(nameof(IsLoadingScreen));
OnPropertyChanged(nameof(IsConfigScreen));
OnPropertyChanged(nameof(IsAuthScreen));
OnPropertyChanged(nameof(IsManualAuthScreen));
OnPropertyChanged(nameof(IsUserSelectionScreen));
OnPropertyChanged(nameof(IsErrorScreen));
OnPropertyChanged(nameof(SelectedUsersSummary));
DownloadSelectedCommand.NotifyCanExecuteChanged();
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
SelectUsersFromListCommand.NotifyCanExecuteChanged();
StopWorkCommand.NotifyCanExecuteChanged();
RefreshUsersCommand.NotifyCanExecuteChanged();
RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged();
LogoutCommand.NotifyCanExecuteChanged();
EditConfigCommand.NotifyCanExecuteChanged();
StartBrowserLoginCommand.NotifyCanExecuteChanged();
OpenManualAuthScreenCommand.NotifyCanExecuteChanged();
GoBackToAuthScreenCommand.NotifyCanExecuteChanged();
ContinueWithManualAuthCommand.NotifyCanExecuteChanged();
OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged();
SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged();
}
partial void OnIsDownloadingChanged(bool value)
{
DownloadSelectedCommand.NotifyCanExecuteChanged();
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
SelectUsersFromListCommand.NotifyCanExecuteChanged();
StopWorkCommand.NotifyCanExecuteChanged();
RefreshUsersCommand.NotifyCanExecuteChanged();
RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged();
LogoutCommand.NotifyCanExecuteChanged();
StartBrowserLoginCommand.NotifyCanExecuteChanged();
OpenManualAuthScreenCommand.NotifyCanExecuteChanged();
GoBackToAuthScreenCommand.NotifyCanExecuteChanged();
ContinueWithManualAuthCommand.NotifyCanExecuteChanged();
OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged();
SubmitSinglePostOrMessageCommand.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 OnSinglePostOrMessageUrlChanged(string value) => SinglePostOrMessageUrlError = string.Empty;
partial void OnFfmpegPathChanged(string value)
{
if (value != "[Hidden for Privacy]")
{
_actualFfmpegPath = value;
}
FfmpegPathError = string.Empty;
}
partial void OnFfprobePathChanged(string value)
{
if (value != "[Hidden for Privacy]")
{
_actualFfprobePath = value;
}
FfprobePathError = string.Empty;
}
partial void OnDownloadPathChanged(string value)
{
if (value != "[Hidden for Privacy]")
{
_actualDownloadPath = value;
}
DownloadPathError = string.Empty;
}
private async Task BeginStartupAsync()
{
ApplyThemeFromConfigFileIfAvailable();
_configReturnScreen = CurrentScreen;
SetLoading("Loading configuration...");
EnforceGuiOnlyConfigValues(configService.CurrentConfig);
BuildConfigFields(configService.CurrentConfig);
UserLists.Clear();
AvailableUsers.Clear();
bool configLoaded = await configService.LoadConfigurationAsync([]);
EnforceGuiOnlyConfigValues(configService.CurrentConfig);
BuildConfigFields(configService.CurrentConfig);
if (!configLoaded)
{
ConfigScreenMessage =
"config.conf is invalid. Update all fields below and save to continue.";
CurrentScreen = AppScreen.Config;
return;
}
if (!ValidateConfiguredToolPathsOnStartup())
{
return;
}
if (!await ValidateEnvironmentAsync(true))
{
return;
}
await EnsureAuthenticationAndLoadUsersAsync(true);
}
private async Task RunSinglePostOrMessageDownloadAsync(SingleDownloadRequest request)
{
if (_allUsers.Count == 0)
{
return;
}
if (!await EnsureMissingCdmWarningConfirmedAsync())
{
return;
}
IsDownloading = true;
_workCancellationSource?.Dispose();
_workCancellationSource = new CancellationTokenSource();
DownloadSelectedCommand.NotifyCanExecuteChanged();
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged();
SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged();
StopWorkCommand.NotifyCanExecuteChanged();
DateTime start = DateTime.Now;
string label = request.Type == SingleDownloadType.Post ? "single post" : "single paid message";
AppendLog($"Starting {label} download from URL.");
StartDownloadProgress($"Initializing {label} download...", 0, false);
CancellationTokenSource cancellationSource = _workCancellationSource;
AvaloniaDownloadEventHandler eventHandler = new(
AppendLog,
UpdateProgressStatus,
StartDownloadProgress,
IncrementDownloadProgress,
StopDownloadProgress,
() => cancellationSource.IsCancellationRequested,
cancellationSource.Token);
try
{
if (request.Type == SingleDownloadType.Post)
{
if (!_allUsers.TryGetValue(request.Username, out long userId))
{
AppendLog(
$"Creator '{request.Username}' is not in loaded users. Refresh users and ensure you are subscribed.");
return;
}
string path = downloadOrchestrationService.ResolveDownloadPath(request.Username);
await downloadOrchestrationService.PrepareUserFolderAsync(request.Username, userId, path);
await downloadOrchestrationService.DownloadSinglePostAsync(
request.Username,
request.ContentId,
path,
_allUsers,
_startupResult.ClientIdBlobMissing,
_startupResult.DevicePrivateKeyMissing,
eventHandler);
}
else
{
long userId = request.UserId ?? 0;
string? resolvedUsername = await downloadOrchestrationService.ResolveUsernameAsync(userId);
if (string.IsNullOrWhiteSpace(resolvedUsername))
{
AppendLog($"Could not resolve username for user ID {userId}.");
return;
}
Dictionary<string, long> usersForDownload = new(_allUsers, StringComparer.Ordinal);
if (!usersForDownload.ContainsKey(resolvedUsername))
{
usersForDownload[resolvedUsername] = userId;
}
string path = downloadOrchestrationService.ResolveDownloadPath(resolvedUsername);
await downloadOrchestrationService.PrepareUserFolderAsync(resolvedUsername, userId, path);
await downloadOrchestrationService.DownloadSinglePaidMessageAsync(
resolvedUsername,
request.ContentId,
path,
usersForDownload,
_startupResult.ClientIdBlobMissing,
_startupResult.DevicePrivateKeyMissing,
eventHandler);
}
ThrowIfStopRequested();
eventHandler.OnScrapeComplete(DateTime.Now - start);
}
catch (OperationCanceledException)
{
AppendLog("Operation canceled.");
}
catch (Exception ex)
{
AppendLog($"Single item download failed: {ex.Message}");
}
finally
{
IsDownloading = false;
_workCancellationSource?.Dispose();
_workCancellationSource = null;
StopDownloadProgress();
DownloadSelectedCommand.NotifyCanExecuteChanged();
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged();
SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged();
StopWorkCommand.NotifyCanExecuteChanged();
}
}
private async Task<bool> EnsureMissingCdmWarningConfirmedAsync()
{
bool hasMissingCdmKeys = _startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing;
if (!hasMissingCdmKeys || configService.CurrentConfig.HideMissingCdmKeysWarning)
{
return true;
}
List<string> missingFiles = [];
if (_startupResult.ClientIdBlobMissing)
{
missingFiles.Add("device_client_id_blob");
}
if (_startupResult.DevicePrivateKeyMissing)
{
missingFiles.Add("device_private_key");
}
string missingSummary = string.Join(" and ", missingFiles);
MissingCdmWarningMessage =
$"Missing CDM key file(s): {missingSummary}\n\n" +
"CDM keys are recommended to decrypt DRM-protected videos. Without these keys, the application will use a fallback online decryption service, which may be slower and less reliable.\n\n" +
"You can hide this warning in the future by enabling \"Hide Missing CDM Keys Warning\" in the configuration settings.\n\n";
_missingCdmWarningCompletionSource =
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
IsMissingCdmWarningModalOpen = true;
bool confirmed;
try
{
confirmed = await _missingCdmWarningCompletionSource.Task;
}
finally
{
_missingCdmWarningCompletionSource = null;
IsMissingCdmWarningModalOpen = false;
}
if (!confirmed)
{
AppendLog("Download canceled after missing CDM keys warning.");
}
return confirmed;
}
private static bool TryParseSinglePostOrMessageUrl(
string url,
out SingleDownloadRequest request,
out string validationError)
{
request = default;
validationError = string.Empty;
string trimmedUrl = url.Trim();
Match postMatch = s_singlePostUrlRegex.Match(trimmedUrl);
if (postMatch.Success &&
long.TryParse(postMatch.Groups["postId"].Value, out long postId) &&
!string.IsNullOrWhiteSpace(postMatch.Groups["username"].Value))
{
request = new SingleDownloadRequest(
SingleDownloadType.Post,
postId,
postMatch.Groups["username"].Value,
null);
return true;
}
Match paidMessageMatch = s_singlePaidMessageUrlRegex.Match(trimmedUrl);
if (paidMessageMatch.Success &&
long.TryParse(paidMessageMatch.Groups["messageId"].Value, out long messageId) &&
long.TryParse(paidMessageMatch.Groups["userId"].Value, out long userId))
{
request = new SingleDownloadRequest(
SingleDownloadType.PaidMessage,
messageId,
string.Empty,
userId);
return true;
}
validationError = "Please enter a valid post URL or paid message URL.";
return false;
}
private static void ApplyThemeFromConfigFileIfAvailable()
{
const string configPath = "config.conf";
if (!File.Exists(configPath))
{
return;
}
try
{
string configText = File.ReadAllText(configPath);
Match match = Regex.Match(configText, @"(?im)^\s*Theme\s*=\s*""(light|dark)""");
if (!match.Success)
{
return;
}
if (Enum.TryParse(match.Groups[1].Value, true, out Theme parsedTheme))
{
ApplyConfiguredTheme(parsedTheme);
}
}
catch
{
// Ignore theme parsing errors here; full config validation happens in ConfigService.
}
}
private async Task EnsureAuthenticationAndLoadUsersAsync(bool logAuthenticationMessage)
{
bool hasValidAuth = await TryLoadAndValidateExistingAuthAsync(logAuthenticationMessage);
if (!hasValidAuth)
{
IsManualAuthValidationInProgress = false;
ManualAuthScreenMessage = string.Empty;
AuthScreenMessage =
"OF DL needs access to your OnlyFans account.\nAn included web browser can be used to sign-in, or you can create an \"auth.json\" file manually.";
CurrentScreen = AppScreen.Auth;
return;
}
await LoadUsersAndListsAsync();
}
private async Task<bool> ValidateEnvironmentAsync(bool logMissingCdmKeysWarning)
{
SetLoading("Validating environment...");
_startupResult = await startupService.ValidateEnvironmentAsync();
OnPropertyChanged(nameof(FfmpegVersion));
OnPropertyChanged(nameof(FfprobeVersion));
OnPropertyChanged(nameof(HideMissingCdmKeysWarningStatusText));
OnPropertyChanged(nameof(HideMissingCdmKeysWarningStatusBrush));
FfmpegPathError = string.Empty;
FfprobePathError = string.Empty;
if (!_startupResult.IsWindowsVersionValid)
{
ShowError($"Unsupported Windows version detected: {_startupResult.OsVersionString}");
return false;
}
if (!_startupResult.FfmpegFound)
{
FfmpegPathError = BuildToolPathError(
nameof(Config.FFmpegPath),
configService.CurrentConfig.FFmpegPath,
"FFmpeg");
ConfigScreenMessage = "FFmpeg was not found. Fix FFmpeg Path and save to continue.";
BuildConfigFields(configService.CurrentConfig);
FfmpegPathError = BuildToolPathError(
nameof(Config.FFmpegPath),
configService.CurrentConfig.FFmpegPath,
"FFmpeg");
CurrentScreen = AppScreen.Config;
return false;
}
if (!_startupResult.FfprobeFound)
{
FfprobePathError = BuildToolPathError(
nameof(Config.FFprobePath),
configService.CurrentConfig.FFprobePath,
"FFprobe");
ConfigScreenMessage = "FFprobe was not found. Fix FFprobe Path and save to continue.";
BuildConfigFields(configService.CurrentConfig);
FfprobePathError = BuildToolPathError(
nameof(Config.FFprobePath),
configService.CurrentConfig.FFprobePath,
"FFprobe");
CurrentScreen = AppScreen.Config;
return false;
}
if (_startupResult.RulesJsonExists && !_startupResult.RulesJsonValid)
{
ShowError(
$"rules.json is invalid: {_startupResult.RulesJsonError}. Fix rules.json and retry startup.");
return false;
}
if (logMissingCdmKeysWarning &&
(_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing))
{
AppendLog(
"CDM key files are missing. Fallback decrypt services will be used for DRM protected videos.");
}
return true;
}
private async Task<bool> TryLoadAndValidateExistingAuthAsync(bool logAuthenticationMessage)
{
bool loadedFromFile = await authService.LoadFromFileAsync();
if (loadedFromFile)
{
return await ValidateCurrentAuthAsync();
}
IsAuthenticated = false;
return false;
}
private bool ValidateConfiguredToolPathsOnStartup()
{
IReadOnlyDictionary<string, string> validationErrors =
ConfigValidationService.Validate(configService.CurrentConfig);
bool hasToolPathErrors = false;
FfmpegPathError = string.Empty;
FfprobePathError = string.Empty;
if (validationErrors.TryGetValue(nameof(Config.FFmpegPath), out string? ffmpegError))
{
FfmpegPathError = ffmpegError;
hasToolPathErrors = true;
}
if (validationErrors.TryGetValue(nameof(Config.FFprobePath), out string? ffprobeError))
{
FfprobePathError = ffprobeError;
hasToolPathErrors = true;
}
if (!hasToolPathErrors)
{
return true;
}
ConfigScreenMessage = "Configuration has invalid FFmpeg/FFprobe path values. Fix and save to continue.";
CurrentScreen = AppScreen.Config;
return false;
}
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;
return false;
}
string displayName = !string.IsNullOrWhiteSpace(user.Name) ? user.Name : "Unknown Name";
string displayUsername = !string.IsNullOrWhiteSpace(user.Username) ? user.Username : "Unknown Username";
AuthenticatedUserDisplay = HidePrivateInfo ? "[Hidden for Privacy]" : $"{displayName} ({displayUsername})";
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<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;
AppendLog($"Loaded {_allUsers.Count} users and {_allLists.Count} lists.");
DownloadSelectedCommand.NotifyCanExecuteChanged();
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
SelectUsersFromListCommand.NotifyCanExecuteChanged();
RefreshUsersCommand.NotifyCanExecuteChanged();
}
private bool TryBuildConfig(out Config config)
{
config = CloneConfig(configService.CurrentConfig);
EnforceGuiOnlyConfigValues(config);
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);
EnforceGuiOnlyConfigValues(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 (error.Key == nameof(Config.FFprobePath))
{
FfprobePathError = 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)
{
ApplyConfiguredTheme(config.Theme);
ConfigFields.Clear();
ConfigCategories.Clear();
ConfigCategoriesLeft.Clear();
ConfigCategoriesRight.Clear();
BuildSpecialConfigInputs(config);
CreatorConfigEditor = new CreatorConfigEditorViewModel(_allUsers.Keys);
CreatorConfigEditor.LoadFromConfig(config.CreatorConfigs);
IEnumerable<PropertyInfo> properties = typeof(Config)
.GetProperties()
.Where(property => property.CanRead && property.CanWrite)
.Where(property => !IsHiddenConfigField(property.Name))
.OrderBy(property => property.Name);
foreach (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);
List<ConfigCategoryViewModel> orderedCategories = [];
foreach (IGrouping<string, ConfigFieldViewModel> group in grouped)
{
IEnumerable<ConfigFieldViewModel> orderedFields =
group.Key is "File Naming" or "Download Behavior" or "Appearance"
? group.OrderBy(field => GetFieldOrder(group.Key, field.PropertyName))
: group.OrderBy(field => field.DisplayName);
ConfigCategoryViewModel category = new(group.Key, orderedFields);
orderedCategories.Add(category);
ConfigCategories.Add(category);
}
foreach (ConfigCategoryViewModel category in orderedCategories
.Where(category => IsLeftColumnCategory(category.CategoryName))
.OrderBy(category => GetLeftColumnCategoryOrder(category.CategoryName)))
{
ConfigCategoriesLeft.Add(category);
}
foreach (ConfigCategoryViewModel category in orderedCategories
.Where(category => IsRightColumnCategory(category.CategoryName))
.OrderBy(category => GetRightColumnCategoryOrder(category.CategoryName)))
{
ConfigCategoriesRight.Add(category);
}
foreach (ConfigCategoryViewModel category in orderedCategories.Where(category =>
!IsLeftColumnCategory(category.CategoryName) && !IsRightColumnCategory(category.CategoryName)))
{
ConfigCategoriesLeft.Add(category);
}
}
private static int GetFieldOrder(string categoryName, string propertyName)
{
if (categoryName == "File Naming")
{
return propertyName switch
{
nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected) => 0,
nameof(Config.PostFileNameFormat) => 1,
nameof(Config.PaidPostFileNameFormat) => 2,
nameof(Config.MessageFileNameFormat) => 3,
nameof(Config.PaidMessageFileNameFormat) => 4,
nameof(Config.CreatorConfigs) => 5,
_ => 100
};
}
if (categoryName == "Download Behavior")
{
return propertyName switch
{
nameof(Config.DownloadVideoResolution) => 0,
nameof(Config.DownloadPostsIncrementally) => 1,
nameof(Config.DownloadDuplicatedMedia) => 2,
nameof(Config.SkipAds) => 3,
nameof(Config.IgnoreOwnMessages) => 4,
nameof(Config.DisableTextSanitization) => 5,
nameof(Config.BypassContentForCreatorsWhoNoLongerExist) => 6,
nameof(Config.ShowScrapeSize) => 7,
_ => 100
};
}
if (categoryName == "Appearance")
{
return propertyName switch
{
nameof(Config.Theme) => 0,
nameof(Config.HideMissingCdmKeysWarning) => 1,
_ => 100
};
}
return 0;
}
private void UpdateIgnoredUsersListFieldOptions()
{
List<string> 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 ffprobePath = NormalizePathForDisplay(config.FFprobePath);
_actualFfprobePath = ffprobePath;
FfprobePath = HidePrivateInfo && !string.IsNullOrWhiteSpace(ffprobePath) ? "[Hidden for Privacy]" : ffprobePath;
string downloadPath = ResolveDownloadPathForDisplay(config.DownloadPath);
_actualDownloadPath = downloadPath;
DownloadPath = HidePrivateInfo && !string.IsNullOrWhiteSpace(downloadPath)
? "[Hidden for Privacy]"
: downloadPath;
DrmVideoDurationMatchThresholdPercent = Math.Clamp(config.DrmVideoDurationMatchThreshold * 100, 0, 100);
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)
{
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 ffprobePathToUse = HidePrivateInfo ? _actualFfprobePath : FfprobePath;
string normalizedFfprobePath = NormalizePathForDisplay(ffprobePathToUse);
config.FFprobePath = string.IsNullOrWhiteSpace(normalizedFfprobePath)
? string.Empty
: EscapePathForConfig(normalizedFfprobePath);
string downloadPathToUse = HidePrivateInfo ? _actualDownloadPath : DownloadPath;
string normalizedDownloadPath = NormalizePathForDisplay(downloadPathToUse);
config.DownloadPath = string.IsNullOrWhiteSpace(normalizedDownloadPath)
? EscapePathForConfig(s_defaultDownloadPath)
: EscapePathForConfig(normalizedDownloadPath);
config.DrmVideoDurationMatchThreshold =
Math.Clamp(Math.Round(DrmVideoDurationMatchThresholdPercent / 100d, 2), 0d, 1d);
ApplySelectionOptionsToConfig(config, MediaTypeOptions);
ApplySelectionOptionsToConfig(config, MediaSourceOptions);
}
private static void ApplySelectionOptionsToConfig(
Config config,
IEnumerable<MultiSelectOptionViewModel> options)
{
foreach (MultiSelectOptionViewModel option in options)
{
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;
FfprobePathError = string.Empty;
DownloadPathError = string.Empty;
MediaTypesError = string.Empty;
MediaSourcesError = string.Empty;
}
private bool HasSpecialConfigErrors() =>
HasFfmpegPathError || HasFfprobePathError || 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 IBrush ResolveThemeBrush(string resourceKey, string fallbackColor)
{
if (Application.Current?.Resources.TryGetValue(resourceKey, out object? resource) == true &&
resource is IBrush brush)
{
return brush;
}
return new SolidColorBrush(Color.Parse(fallbackColor));
}
private static string BuildToolPathError(string propertyName, string? configuredPath, string toolName)
{
string normalizedPath = NormalizePathForDisplay(configuredPath);
if (string.IsNullOrWhiteSpace(normalizedPath))
{
return
$"{toolName} was not found automatically. Set {propertyName} to a valid executable path or add {toolName.ToLowerInvariant()} to PATH.";
}
return $"{propertyName} does not point to an existing file: {normalizedPath}";
}
private static string ResolveProgramVersion()
{
Version? version = Assembly.GetEntryAssembly()?.GetName().Version
?? typeof(MainWindowViewModel).Assembly.GetName().Version;
return version == null
? "Unknown"
: $"{version.Major}.{version.Minor}.{version.Build}";
}
private static void ApplyConfiguredTheme(Theme theme)
{
if (Application.Current == null)
{
return;
}
bool useDarkTheme = theme == Theme.dark;
Application.Current.RequestedThemeVariant = useDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light;
Dictionary<string, string> palette = useDarkTheme ? s_darkThemeBrushes : s_lightThemeBrushes;
foreach (KeyValuePair<string, string> brush in palette)
{
Application.Current.Resources[brush.Key] = new SolidColorBrush(Color.Parse(brush.Value));
}
}
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;
CurrentScreen = AppScreen.Loading;
}
private void ShowError(string message)
{
ErrorMessage = 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))
{
if (!_isApplyingListSelection && !string.IsNullOrWhiteSpace(SelectedListName))
{
SelectedListName = null;
}
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 void EnforceGuiOnlyConfigValues(Config config) => config.ShowScrapeSize = false;
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.DownloadPath)
or nameof(Config.DrmVideoDurationMatchThreshold)
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)
or nameof(Config.ShowScrapeSize);
private static string GetConfigCategory(string propertyName) =>
propertyName switch
{
nameof(Config.DisableBrowserAuth) => "Auth",
nameof(Config.FFmpegPath) => "External",
nameof(Config.FFprobePath) => "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.Theme) => "Appearance",
nameof(Config.HideMissingCdmKeysWarning) => "Appearance",
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,
"Appearance" => 8,
"Logging" => 9,
_ => 100
};
private static bool IsLeftColumnCategory(string categoryName) =>
categoryName is "Appearance" or "Logging" or "External" or "Download Behavior" or "Subscriptions";
private static bool IsRightColumnCategory(string categoryName) =>
categoryName is "File Naming" or "Folder Structure" or "Performance";
private static int GetLeftColumnCategoryOrder(string categoryName) =>
categoryName switch
{
"Appearance" => 1,
"Logging" => 2,
"External" => 3,
"Download Behavior" => 4,
"Subscriptions" => 5,
_ => 100
};
private static int GetRightColumnCategoryOrder(string categoryName) =>
categoryName switch
{
"File Naming" => 1,
"Folder Structure" => 2,
"Performance" => 3,
_ => 100
};
}