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

2368 lines
87 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. Leave blank to auto-detect from the app folder or PATH.",
[nameof(Config.FFprobePath)] =
"Path to the FFprobe executable. Leave blank to auto-detect from FFmpeg's folder, the app folder, or PATH.",
[nameof(Config.DownloadPath)] =
"Base download folder. Leave blank to use __user_data__/sites/OnlyFans/{username}.",
[nameof(Config.DrmVideoDurationMatchThreshold)] =
"Minimum DRM duration match required. 98% is recommended; 100% requires an exact match.",
[nameof(Config.DownloadVideos)] = "Include videos in downloads.",
[nameof(Config.DownloadImages)] = "Include images in downloads.",
[nameof(Config.DownloadAudios)] = "Include audio files in downloads.",
[nameof(Config.DownloadAvatarHeaderPhoto)] =
"Include creator avatar and header images.",
[nameof(Config.DownloadPosts)] = "Include free posts.",
[nameof(Config.DownloadPaidPosts)] = "Include unlocked PPV posts.",
[nameof(Config.DownloadArchived)] = "Include archived posts.",
[nameof(Config.DownloadStreams)] = "Include stream posts from the Streams tab.",
[nameof(Config.DownloadStories)] = "Include stories.",
[nameof(Config.DownloadHighlights)] = "Include highlights.",
[nameof(Config.DownloadMessages)] =
"Include free message media and paid-message preview media.",
[nameof(Config.DownloadPaidMessages)] =
"Include unlocked PPV message media (excluding preview media).",
[nameof(Config.IgnoreOwnMessages)] =
"Skip messages sent by your account and any media attached to them.",
[nameof(Config.DownloadPostsIncrementally)] =
"Only fetch posts newer than the latest saved post in metadata.",
[nameof(Config.BypassContentForCreatorsWhoNoLongerExist)] =
"Try downloading accessible purchased content even when a creator account no longer exists.",
[nameof(Config.DownloadDuplicatedMedia)] =
"Allow duplicate media to be downloaded instead of skipped.",
[nameof(Config.SkipAds)] =
"Skip posts and messages that contain #ad or free-trial links.",
[nameof(Config.DownloadOnlySpecificDates)] =
"Enable post date filtering using Date Selection and Custom Date.",
[nameof(Config.DownloadDateSelection)] =
"Choose whether post filtering uses content before or after Custom Date.",
[nameof(Config.CustomDate)] =
"Date used for post filtering (yyyy-MM-dd).",
[nameof(Config.ShowScrapeSize)] =
"Show estimated total bytes instead of item counts while preparing downloads.",
[nameof(Config.DisableTextSanitization)] =
"Store post and message text as-is without XML stripping.",
[nameof(Config.DownloadVideoResolution)] =
"Preferred video resolution when alternatives are available.",
[nameof(Config.PaidPostFileNameFormat)] =
"Filename pattern for unlocked PPV posts.",
[nameof(Config.PostFileNameFormat)] =
"Filename pattern for free posts, archived posts, and streams.",
[nameof(Config.PaidMessageFileNameFormat)] =
"Filename pattern for unlocked PPV message media.",
[nameof(Config.MessageFileNameFormat)] =
"Filename pattern for free message media.",
[nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected)] =
"Rename existing downloaded files to match current custom filename formats.",
[nameof(Config.CreatorConfigs)] =
"Per-creator filename format overrides in JSON. These override global formats.",
[nameof(Config.FolderPerPaidPost)] =
"Create a separate folder for each unlocked PPV post.",
[nameof(Config.FolderPerPost)] =
"Create a separate folder for each free post.",
[nameof(Config.FolderPerPaidMessage)] =
"Create a separate folder for each unlocked PPV message.",
[nameof(Config.FolderPerMessage)] =
"Create a separate folder for each free message.",
[nameof(Config.IncludeExpiredSubscriptions)] =
"Show expired subscriptions in creator selection.",
[nameof(Config.IncludeRestrictedSubscriptions)] =
"Include restricted creators during scraping and downloads.",
[nameof(Config.IgnoredUsersListName)] =
"Skip creators that belong to this OnlyFans list.",
[nameof(Config.Timeout)] =
"HTTP timeout in seconds. Use -1 for default behavior.",
[nameof(Config.LimitDownloadRate)] =
"Enable download speed limiting.",
[nameof(Config.DownloadLimitInMbPerSec)] =
"Maximum download speed in MB/s when rate limiting is enabled.",
[nameof(Config.Theme)] =
"Choose the GUI theme.",
[nameof(Config.HideMissingCdmKeysWarning)] =
"Skip the missing CDM keys confirmation before downloads start.",
[nameof(Config.LoggingLevel)] =
"Minimum log level 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(OpenSinglePostOrMessageModalCommand))]
[NotifyCanExecuteChangedFor(nameof(SubmitSinglePostOrMessageCommand))]
[NotifyCanExecuteChangedFor(nameof(DownloadSelectedCommand))]
[NotifyCanExecuteChangedFor(nameof(DownloadPurchasedTabCommand))]
private bool _isDownloadSelectionWarningModalOpen;
[ObservableProperty] private string _downloadSelectionWarningMessage = 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;
private TaskCompletionSource<bool>? _downloadSelectionWarningCompletionSource;
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]
private void ConfirmDownloadSelectionWarning()
{
IsDownloadSelectionWarningModalOpen = false;
_downloadSelectionWarningCompletionSource?.TrySetResult(true);
}
[RelayCommand]
private void CancelDownloadSelectionWarning()
{
IsDownloadSelectionWarningModalOpen = false;
_downloadSelectionWarningCompletionSource?.TrySetResult(false);
}
[RelayCommand]
private void OpenConfigurationFromDownloadSelectionWarning()
{
IsDownloadSelectionWarningModalOpen = false;
_downloadSelectionWarningCompletionSource?.TrySetResult(false);
EditConfig();
}
[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 EnsureDownloadSelectionWarningConfirmedAsync())
{
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) &&
!IsDownloadSelectionWarningModalOpen &&
!IsMissingCdmWarningModalOpen &&
!IsDownloading;
private bool CanDownloadPurchasedTab() =>
CurrentScreen == AppScreen.UserSelection &&
_allUsers.Count > 0 &&
!IsDownloadSelectionWarningModalOpen &&
!IsMissingCdmWarningModalOpen &&
!IsDownloading;
private bool CanOpenSinglePostOrMessageModal() =>
CurrentScreen == AppScreen.UserSelection &&
!IsDownloading &&
!IsDownloadSelectionWarningModalOpen &&
!IsMissingCdmWarningModalOpen &&
!IsSinglePostOrMessageModalOpen;
private bool CanSubmitSinglePostOrMessage() =>
IsSinglePostOrMessageModalOpen &&
!IsDownloadSelectionWarningModalOpen &&
!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 async Task<bool> EnsureDownloadSelectionWarningConfirmedAsync()
{
bool hasEnabledMediaType = configService.CurrentConfig.DownloadVideos ||
configService.CurrentConfig.DownloadImages ||
configService.CurrentConfig.DownloadAudios;
bool hasEnabledSource = configService.CurrentConfig.DownloadPosts ||
configService.CurrentConfig.DownloadPaidPosts ||
configService.CurrentConfig.DownloadMessages ||
configService.CurrentConfig.DownloadPaidMessages ||
configService.CurrentConfig.DownloadArchived ||
configService.CurrentConfig.DownloadStreams ||
configService.CurrentConfig.DownloadStories ||
configService.CurrentConfig.DownloadHighlights ||
configService.CurrentConfig.DownloadAvatarHeaderPhoto;
if (hasEnabledMediaType && hasEnabledSource)
{
return true;
}
DownloadSelectionWarningMessage =
"No files will be downloaded unless at least one media type and at least one source are enabled in \"Download Media Types\" on the Configuration page.\n\n" +
"Without enabling these options, only metadata will be saved.";
_downloadSelectionWarningCompletionSource =
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
IsDownloadSelectionWarningModalOpen = true;
bool confirmed;
try
{
confirmed = await _downloadSelectionWarningCompletionSource.Task;
}
finally
{
_downloadSelectionWarningCompletionSource = null;
IsDownloadSelectionWarningModalOpen = false;
}
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 and Sources",
nameof(Config.DownloadPaidPosts) => "Download Media Types and Sources",
nameof(Config.DownloadPosts) => "Download Media Types and Sources",
nameof(Config.DownloadArchived) => "Download Media Types and Sources",
nameof(Config.DownloadStreams) => "Download Media Types and Sources",
nameof(Config.DownloadStories) => "Download Media Types and Sources",
nameof(Config.DownloadHighlights) => "Download Media Types and Sources",
nameof(Config.DownloadMessages) => "Download Media Types and Sources",
nameof(Config.DownloadPaidMessages) => "Download Media Types and Sources",
nameof(Config.DownloadImages) => "Download Media Types and Sources",
nameof(Config.DownloadVideos) => "Download Media Types and Sources",
nameof(Config.DownloadAudios) => "Download Media Types and Sources",
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 and Sources" => 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
};
}