1704 lines
62 KiB
C#
1704 lines
62 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.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 const string UnknownToolVersion = "Not detected";
|
|
|
|
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.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",
|
|
["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",
|
|
["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 _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;
|
|
|
|
public bool IsLoadingScreen => CurrentScreen == AppScreen.Loading;
|
|
|
|
public bool IsConfigScreen => CurrentScreen == AppScreen.Config;
|
|
|
|
public bool IsAuthScreen => CurrentScreen == AppScreen.Auth;
|
|
|
|
public bool IsUserSelectionScreen => CurrentScreen == AppScreen.UserSelection;
|
|
|
|
public bool IsErrorScreen => CurrentScreen == AppScreen.Error;
|
|
|
|
public bool HasFfmpegPathError => !string.IsNullOrWhiteSpace(FfmpegPathError);
|
|
|
|
public bool HasFfprobePathError => !string.IsNullOrWhiteSpace(FfprobePathError);
|
|
|
|
public bool HasDownloadPathError => !string.IsNullOrWhiteSpace(DownloadPathError);
|
|
|
|
public bool HasMediaTypesError => !string.IsNullOrWhiteSpace(MediaTypesError);
|
|
|
|
public bool HasMediaSourcesError => !string.IsNullOrWhiteSpace(MediaSourcesError);
|
|
|
|
public string FfmpegPathDisplay =>
|
|
HidePrivateInfo && !string.IsNullOrWhiteSpace(FfmpegPath) ? "[Hidden for Privacy]" : FfmpegPath;
|
|
|
|
public string DownloadPathDisplay => HidePrivateInfo && !string.IsNullOrWhiteSpace(DownloadPath)
|
|
? "[Hidden for Privacy]"
|
|
: DownloadPath;
|
|
|
|
public string FfmpegPathHelpText => GetConfigHelpText(nameof(Config.FFmpegPath));
|
|
|
|
public string 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 SelectedUsersSummary =>
|
|
$"{AvailableUsers.Count(user => user.IsSelected)} / {AvailableUsers.Count} selected";
|
|
|
|
public bool? AllUsersSelected
|
|
{
|
|
get
|
|
{
|
|
if (AvailableUsers.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int selectedCount = AvailableUsers.Count(user => user.IsSelected);
|
|
if (selectedCount == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (selectedCount == AvailableUsers.Count)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
set
|
|
{
|
|
bool? current = AllUsersSelected;
|
|
bool shouldSelectAll;
|
|
|
|
if (current == true)
|
|
{
|
|
shouldSelectAll = false;
|
|
}
|
|
else if (current == false)
|
|
{
|
|
shouldSelectAll = true;
|
|
}
|
|
else
|
|
{
|
|
shouldSelectAll = value == true;
|
|
}
|
|
|
|
_isUpdatingAllUsersSelected = true;
|
|
try
|
|
{
|
|
foreach (SelectableUserViewModel user in AvailableUsers)
|
|
{
|
|
user.IsSelected = shouldSelectAll;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_isUpdatingAllUsersSelected = false;
|
|
}
|
|
|
|
OnPropertyChanged(nameof(SelectedUsersSummary));
|
|
DownloadSelectedCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
|
|
private bool _isUpdatingAllUsersSelected;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
if (HasInitialized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
HasInitialized = true;
|
|
await BeginStartupAsync();
|
|
}
|
|
|
|
public void SetFfmpegPath(string? path)
|
|
{
|
|
string normalizedPath = NormalizePathForDisplay(path);
|
|
_actualFfmpegPath = normalizedPath;
|
|
FfmpegPath = HidePrivateInfo && !string.IsNullOrWhiteSpace(normalizedPath)
|
|
? "[Hidden for Privacy]"
|
|
: normalizedPath;
|
|
FfmpegPathError = string.Empty;
|
|
}
|
|
|
|
public void 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;
|
|
|
|
AuthenticatedUserDisplay = "Not authenticated.";
|
|
AuthScreenMessage = "You have been logged out. Click 'Login with Browser' to authenticate.";
|
|
StatusMessage = "Logged out.";
|
|
CurrentScreen = AppScreen.Auth;
|
|
OnPropertyChanged(nameof(SelectedUsersSummary));
|
|
DownloadSelectedCommand.NotifyCanExecuteChanged();
|
|
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
|
|
SelectUsersFromListCommand.NotifyCanExecuteChanged();
|
|
RefreshUsersCommand.NotifyCanExecuteChanged();
|
|
RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void EditConfig()
|
|
{
|
|
if (CurrentScreen == AppScreen.Config)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_configReturnScreen = CurrentScreen;
|
|
BuildConfigFields(configService.CurrentConfig);
|
|
ConfigScreenMessage = "Edit configuration values and save to apply changes.";
|
|
StatusMessage = "Editing configuration.";
|
|
CurrentScreen = AppScreen.Config;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task CancelConfigAsync()
|
|
{
|
|
bool loaded = await configService.LoadConfigurationAsync([]);
|
|
BuildConfigFields(configService.CurrentConfig);
|
|
|
|
if (!loaded)
|
|
{
|
|
ConfigScreenMessage = "Configuration is still invalid.";
|
|
StatusMessage = ConfigScreenMessage;
|
|
CurrentScreen = AppScreen.Config;
|
|
return;
|
|
}
|
|
|
|
if (_configReturnScreen == AppScreen.UserSelection && _allUsers.Count > 0)
|
|
{
|
|
CurrentScreen = AppScreen.UserSelection;
|
|
StatusMessage = "Configuration changes canceled.";
|
|
return;
|
|
}
|
|
|
|
await BeginStartupAsync();
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanRefreshUsers))]
|
|
private async Task RefreshUsersAsync() => await LoadUsersAndListsAsync();
|
|
|
|
[RelayCommand(CanExecute = nameof(CanRefreshIgnoredUsersLists))]
|
|
private async Task RefreshIgnoredUsersListsAsync(ConfigFieldViewModel? field)
|
|
{
|
|
if (field == null || !field.IsIgnoredUsersListField)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ConfigScreenMessage = "Refreshing user lists...";
|
|
StatusMessage = ConfigScreenMessage;
|
|
|
|
try
|
|
{
|
|
Dictionary<string, long> latestLists = await downloadOrchestrationService.GetUserListsAsync();
|
|
_allLists = latestLists
|
|
.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(pair => pair.Key, pair => pair.Value);
|
|
|
|
UpdateUserListsCollection();
|
|
UpdateIgnoredUsersListFieldOptions();
|
|
|
|
ConfigScreenMessage = $"User lists refreshed ({_allLists.Count}).";
|
|
StatusMessage = ConfigScreenMessage;
|
|
AppendLog(ConfigScreenMessage);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ConfigScreenMessage = $"Could not refresh user lists: {ex.Message}";
|
|
StatusMessage = ConfigScreenMessage;
|
|
AppendLog(ConfigScreenMessage);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task SaveConfigAsync()
|
|
{
|
|
if (!TryBuildConfig(out Config newConfig))
|
|
{
|
|
StatusMessage = "Fix configuration validation errors and save again.";
|
|
return;
|
|
}
|
|
|
|
configService.UpdateConfig(newConfig);
|
|
await configService.SaveConfigurationAsync();
|
|
|
|
bool reloaded = await configService.LoadConfigurationAsync([]);
|
|
if (!reloaded)
|
|
{
|
|
ConfigScreenMessage = "config.conf could not be loaded after saving. Please review your values.";
|
|
CurrentScreen = AppScreen.Config;
|
|
StatusMessage = ConfigScreenMessage;
|
|
BuildConfigFields(configService.CurrentConfig);
|
|
return;
|
|
}
|
|
|
|
BuildConfigFields(configService.CurrentConfig);
|
|
ConfigScreenMessage = "Configuration saved.";
|
|
StatusMessage = "Configuration saved.";
|
|
|
|
if (!await ValidateEnvironmentAsync())
|
|
{
|
|
return;
|
|
}
|
|
|
|
await EnsureAuthenticationAndLoadUsersAsync();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void AddCreatorConfig()
|
|
{
|
|
Log.Information("=== AddCreatorConfig command called ===");
|
|
CreatorConfigEditor.AddCreatorCommand.Execute(null);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task StartBrowserLoginAsync()
|
|
{
|
|
if (configService.CurrentConfig.DisableBrowserAuth)
|
|
{
|
|
AuthScreenMessage = "Browser authentication is disabled in config.";
|
|
return;
|
|
}
|
|
|
|
SetLoading("Opening browser for authentication...");
|
|
AppendLog("Starting browser authentication flow.");
|
|
|
|
bool success = await authService.LoadFromBrowserAsync();
|
|
if (!success || authService.CurrentAuth == null)
|
|
{
|
|
AuthScreenMessage =
|
|
"Authentication failed. Log in using the opened browser window and retry.";
|
|
CurrentScreen = AppScreen.Auth;
|
|
StatusMessage = "Authentication failed.";
|
|
AppendLog("Browser authentication failed.");
|
|
return;
|
|
}
|
|
|
|
|
|
await authService.SaveToFileAsync();
|
|
bool isAuthValid = await ValidateCurrentAuthAsync();
|
|
if (!isAuthValid)
|
|
{
|
|
AuthScreenMessage = "Authentication is still invalid after login. Please retry.";
|
|
CurrentScreen = AppScreen.Auth;
|
|
StatusMessage = "Authentication failed.";
|
|
return;
|
|
}
|
|
|
|
await LoadUsersAndListsAsync();
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanApplySelectedList))]
|
|
private async Task SelectUsersFromListAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(SelectedListName))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isApplyingListSelection = true;
|
|
StartDownloadProgress($"Selecting users from list '{SelectedListName}'...", 0, false);
|
|
try
|
|
{
|
|
foreach (SelectableUserViewModel user in AvailableUsers)
|
|
{
|
|
user.IsSelected = false;
|
|
}
|
|
|
|
Dictionary<string, long> listUsers = await downloadOrchestrationService.GetUsersForListAsync(
|
|
SelectedListName,
|
|
_allUsers,
|
|
_allLists);
|
|
HashSet<string> selectedUsernames = listUsers.Keys.ToHashSet(StringComparer.Ordinal);
|
|
|
|
foreach (SelectableUserViewModel user in AvailableUsers)
|
|
{
|
|
user.IsSelected = selectedUsernames.Contains(user.Username);
|
|
}
|
|
|
|
StatusMessage = $"Selected {selectedUsernames.Count} users from list '{SelectedListName}'.";
|
|
OnPropertyChanged(nameof(SelectedUsersSummary));
|
|
OnPropertyChanged(nameof(AllUsersSelected));
|
|
DownloadSelectedCommand.NotifyCanExecuteChanged();
|
|
}
|
|
finally
|
|
{
|
|
_isApplyingListSelection = false;
|
|
StopDownloadProgress();
|
|
}
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanDownloadSelected))]
|
|
private async Task DownloadSelectedAsync() => await RunDownloadAsync(false);
|
|
|
|
[RelayCommand(CanExecute = nameof(CanDownloadPurchasedTab))]
|
|
private async Task DownloadPurchasedTabAsync() => await RunDownloadAsync(true);
|
|
|
|
[RelayCommand(CanExecute = nameof(CanStopWork))]
|
|
private void StopWork()
|
|
{
|
|
if (_workCancellationSource is { IsCancellationRequested: false })
|
|
{
|
|
_workCancellationSource.Cancel();
|
|
StatusMessage = "Stop requested. Waiting for current operation to cancel...";
|
|
UpdateProgressStatus("Stopping...");
|
|
AppendLog("Stop requested.");
|
|
}
|
|
}
|
|
|
|
private async Task RunDownloadAsync(bool downloadPurchasedTabOnly)
|
|
{
|
|
List<SelectableUserViewModel> selectedUsers = AvailableUsers.Where(user => user.IsSelected).ToList();
|
|
if (!downloadPurchasedTabOnly && selectedUsers.Count == 0)
|
|
{
|
|
StatusMessage = "Select at least one user before downloading.";
|
|
return;
|
|
}
|
|
|
|
if (downloadPurchasedTabOnly && _allUsers.Count == 0)
|
|
{
|
|
StatusMessage = "No users are loaded. Refresh users and retry.";
|
|
return;
|
|
}
|
|
|
|
IsDownloading = true;
|
|
_workCancellationSource?.Dispose();
|
|
_workCancellationSource = new CancellationTokenSource();
|
|
DownloadSelectedCommand.NotifyCanExecuteChanged();
|
|
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
|
|
StopWorkCommand.NotifyCanExecuteChanged();
|
|
|
|
DateTime start = DateTime.Now;
|
|
AppendLog(downloadPurchasedTabOnly
|
|
? "Starting Purchased Tab download."
|
|
: $"Starting download for {selectedUsers.Count} users.");
|
|
StatusMessage = downloadPurchasedTabOnly
|
|
? "Starting Purchased Tab download..."
|
|
: $"Starting download for {selectedUsers.Count} users...";
|
|
|
|
// Show progress bar immediately with indeterminate state
|
|
StartDownloadProgress(
|
|
downloadPurchasedTabOnly
|
|
? "Initializing Purchased Tab download..."
|
|
: $"Initializing download for {selectedUsers.Count} users...",
|
|
0,
|
|
false);
|
|
|
|
CancellationTokenSource cancellationSource = _workCancellationSource;
|
|
AvaloniaDownloadEventHandler eventHandler = new(
|
|
AppendLog,
|
|
UpdateProgressStatus,
|
|
StartDownloadProgress,
|
|
IncrementDownloadProgress,
|
|
StopDownloadProgress,
|
|
() => cancellationSource.IsCancellationRequested,
|
|
cancellationSource.Token);
|
|
|
|
try
|
|
{
|
|
if (downloadPurchasedTabOnly)
|
|
{
|
|
await downloadOrchestrationService.DownloadPurchasedTabAsync(_allUsers,
|
|
_startupResult.ClientIdBlobMissing,
|
|
_startupResult.DevicePrivateKeyMissing,
|
|
eventHandler);
|
|
}
|
|
else
|
|
{
|
|
foreach (SelectableUserViewModel user in selectedUsers)
|
|
{
|
|
ThrowIfStopRequested();
|
|
string path = downloadOrchestrationService.ResolveDownloadPath(user.Username);
|
|
await downloadOrchestrationService.DownloadCreatorContentAsync(user.Username, user.UserId, path,
|
|
_allUsers,
|
|
_startupResult.ClientIdBlobMissing,
|
|
_startupResult.DevicePrivateKeyMissing,
|
|
eventHandler);
|
|
}
|
|
}
|
|
|
|
ThrowIfStopRequested();
|
|
eventHandler.OnScrapeComplete(DateTime.Now - start);
|
|
StatusMessage = downloadPurchasedTabOnly
|
|
? "Purchased Tab download completed."
|
|
: "Download run completed.";
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
StatusMessage = "Operation canceled.";
|
|
AppendLog("Operation canceled.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppendLog($"Download failed: {ex.Message}");
|
|
StatusMessage = "Download failed. Check logs.";
|
|
}
|
|
finally
|
|
{
|
|
IsDownloading = false;
|
|
_workCancellationSource?.Dispose();
|
|
_workCancellationSource = null;
|
|
StopDownloadProgress();
|
|
DownloadSelectedCommand.NotifyCanExecuteChanged();
|
|
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
|
|
StopWorkCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
|
|
private bool CanApplySelectedList() =>
|
|
CurrentScreen == AppScreen.UserSelection &&
|
|
!string.IsNullOrWhiteSpace(SelectedListName) &&
|
|
!IsDownloading;
|
|
|
|
private bool CanDownloadSelected() =>
|
|
CurrentScreen == AppScreen.UserSelection &&
|
|
AvailableUsers.Any(user => user.IsSelected) &&
|
|
!IsDownloading;
|
|
|
|
private bool CanDownloadPurchasedTab() =>
|
|
CurrentScreen == AppScreen.UserSelection &&
|
|
_allUsers.Count > 0 &&
|
|
!IsDownloading;
|
|
|
|
private bool CanStopWork() => IsDownloading;
|
|
|
|
private bool CanRefreshUsers() =>
|
|
CurrentScreen == AppScreen.UserSelection && !IsDownloading;
|
|
|
|
private bool CanRefreshIgnoredUsersLists() =>
|
|
CurrentScreen == AppScreen.Config && IsAuthenticated && !IsDownloading;
|
|
|
|
private bool CanLogout() => IsAuthenticated && !IsDownloading;
|
|
|
|
partial void OnCurrentScreenChanged(AppScreen value)
|
|
{
|
|
OnPropertyChanged(nameof(IsLoadingScreen));
|
|
OnPropertyChanged(nameof(IsConfigScreen));
|
|
OnPropertyChanged(nameof(IsAuthScreen));
|
|
OnPropertyChanged(nameof(IsUserSelectionScreen));
|
|
OnPropertyChanged(nameof(IsErrorScreen));
|
|
OnPropertyChanged(nameof(SelectedUsersSummary));
|
|
DownloadSelectedCommand.NotifyCanExecuteChanged();
|
|
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
|
|
SelectUsersFromListCommand.NotifyCanExecuteChanged();
|
|
StopWorkCommand.NotifyCanExecuteChanged();
|
|
RefreshUsersCommand.NotifyCanExecuteChanged();
|
|
RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged();
|
|
LogoutCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
partial void OnIsDownloadingChanged(bool value)
|
|
{
|
|
DownloadSelectedCommand.NotifyCanExecuteChanged();
|
|
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
|
|
SelectUsersFromListCommand.NotifyCanExecuteChanged();
|
|
StopWorkCommand.NotifyCanExecuteChanged();
|
|
RefreshUsersCommand.NotifyCanExecuteChanged();
|
|
RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged();
|
|
LogoutCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
partial void OnIsAuthenticatedChanged(bool value)
|
|
{
|
|
LogoutCommand.NotifyCanExecuteChanged();
|
|
RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
partial void OnSelectedListNameChanged(string? value)
|
|
{
|
|
SelectUsersFromListCommand.NotifyCanExecuteChanged();
|
|
if (_isApplyingListSelection || IsDownloading || CurrentScreen != AppScreen.UserSelection ||
|
|
string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_ = SelectUsersFromListAsync();
|
|
}
|
|
|
|
partial void OnFfmpegPathChanged(string value)
|
|
{
|
|
if (value != "[Hidden for Privacy]")
|
|
{
|
|
_actualFfmpegPath = value;
|
|
}
|
|
|
|
FfmpegPathError = string.Empty;
|
|
}
|
|
|
|
partial void 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...");
|
|
BuildConfigFields(configService.CurrentConfig);
|
|
UserLists.Clear();
|
|
AvailableUsers.Clear();
|
|
|
|
bool configLoaded = await configService.LoadConfigurationAsync([]);
|
|
BuildConfigFields(configService.CurrentConfig);
|
|
if (!configLoaded)
|
|
{
|
|
ConfigScreenMessage =
|
|
"config.conf is invalid. Update all fields below and save to continue.";
|
|
CurrentScreen = AppScreen.Config;
|
|
StatusMessage = ConfigScreenMessage;
|
|
return;
|
|
}
|
|
|
|
if (!await ValidateEnvironmentAsync())
|
|
{
|
|
return;
|
|
}
|
|
|
|
await EnsureAuthenticationAndLoadUsersAsync();
|
|
}
|
|
|
|
private 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 hasValidAuth = await TryLoadAndValidateExistingAuthAsync();
|
|
if (!hasValidAuth)
|
|
{
|
|
if (configService.CurrentConfig.DisableBrowserAuth)
|
|
{
|
|
ShowError(
|
|
"Authentication is missing or invalid and browser auth is disabled. Enable browser auth in config or provide a valid auth.json.");
|
|
return;
|
|
}
|
|
|
|
AuthScreenMessage =
|
|
"Authentication is required. Click 'Login with Browser' and complete the OnlyFans login flow.";
|
|
CurrentScreen = AppScreen.Auth;
|
|
StatusMessage = "Authentication required.";
|
|
return;
|
|
}
|
|
|
|
await LoadUsersAndListsAsync();
|
|
}
|
|
|
|
private async Task<bool> ValidateEnvironmentAsync()
|
|
{
|
|
SetLoading("Validating environment...");
|
|
_startupResult = await startupService.ValidateEnvironmentAsync();
|
|
OnPropertyChanged(nameof(FfmpegVersion));
|
|
OnPropertyChanged(nameof(FfprobeVersion));
|
|
|
|
if (!_startupResult.IsWindowsVersionValid)
|
|
{
|
|
ShowError($"Unsupported Windows version detected: {_startupResult.OsVersionString}");
|
|
return false;
|
|
}
|
|
|
|
if (!_startupResult.FfmpegFound)
|
|
{
|
|
ConfigScreenMessage = "FFmpeg was not found. Set a valid FFmpegPath before continuing.";
|
|
CurrentScreen = AppScreen.Config;
|
|
StatusMessage = ConfigScreenMessage;
|
|
return false;
|
|
}
|
|
|
|
if (_startupResult.RulesJsonExists && !_startupResult.RulesJsonValid)
|
|
{
|
|
ShowError(
|
|
$"rules.json is invalid: {_startupResult.RulesJsonError}. Fix rules.json and retry startup.");
|
|
return false;
|
|
}
|
|
|
|
if (_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing)
|
|
{
|
|
AppendLog(
|
|
"Widevine device files are missing. Fallback decrypt services will be used for DRM protected videos.");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private async Task<bool> TryLoadAndValidateExistingAuthAsync()
|
|
{
|
|
bool loadedFromFile = await authService.LoadFromFileAsync();
|
|
if (!loadedFromFile)
|
|
{
|
|
IsAuthenticated = false;
|
|
AppendLog("No valid auth.json found.");
|
|
return false;
|
|
}
|
|
|
|
return await ValidateCurrentAuthAsync();
|
|
}
|
|
|
|
private async Task<bool> ValidateCurrentAuthAsync()
|
|
{
|
|
authService.ValidateCookieString();
|
|
UserEntities.User? user = await authService.ValidateAuthAsync();
|
|
if (user == null || (string.IsNullOrWhiteSpace(user.Name) && string.IsNullOrWhiteSpace(user.Username)))
|
|
{
|
|
authService.CurrentAuth = null;
|
|
IsAuthenticated = false;
|
|
if (File.Exists("auth.json") && !configService.CurrentConfig.DisableBrowserAuth)
|
|
{
|
|
File.Delete("auth.json");
|
|
}
|
|
|
|
AppendLog("Auth validation failed.");
|
|
return false;
|
|
}
|
|
|
|
string displayName = !string.IsNullOrWhiteSpace(user.Name) ? user.Name : "Unknown Name";
|
|
string displayUsername = !string.IsNullOrWhiteSpace(user.Username) ? user.Username : "Unknown Username";
|
|
|
|
if (HidePrivateInfo)
|
|
{
|
|
AuthenticatedUserDisplay = "[Hidden for Privacy]";
|
|
AppendLog("Authenticated as [Hidden for Privacy].");
|
|
}
|
|
else
|
|
{
|
|
AuthenticatedUserDisplay = $"{displayName} ({displayUsername})";
|
|
AppendLog($"Authenticated as {AuthenticatedUserDisplay}.");
|
|
}
|
|
|
|
IsAuthenticated = true;
|
|
return true;
|
|
}
|
|
|
|
private async Task LoadUsersAndListsAsync()
|
|
{
|
|
SetLoading("Fetching users and user lists...");
|
|
UserListResult listResult = await downloadOrchestrationService.GetAvailableUsersAsync();
|
|
|
|
_allUsers = listResult.Users
|
|
.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(pair => pair.Key, pair => pair.Value);
|
|
_allLists = listResult.Lists
|
|
.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(pair => pair.Key, pair => pair.Value);
|
|
|
|
foreach (SelectableUserViewModel user in AvailableUsers)
|
|
{
|
|
user.PropertyChanged -= OnSelectableUserPropertyChanged;
|
|
}
|
|
|
|
AvailableUsers.Clear();
|
|
foreach (KeyValuePair<string, long> user in _allUsers)
|
|
{
|
|
SelectableUserViewModel userViewModel = new(user.Key, user.Value);
|
|
userViewModel.PropertyChanged += OnSelectableUserPropertyChanged;
|
|
AvailableUsers.Add(userViewModel);
|
|
}
|
|
|
|
OnPropertyChanged(nameof(SelectedUsersSummary));
|
|
OnPropertyChanged(nameof(AllUsersSelected));
|
|
|
|
UpdateUserListsCollection();
|
|
UpdateIgnoredUsersListFieldOptions();
|
|
CreatorConfigEditor.UpdateAvailableUsers(_allUsers.Keys);
|
|
|
|
SelectedListName = null;
|
|
|
|
if (!string.IsNullOrWhiteSpace(listResult.IgnoredListError))
|
|
{
|
|
AppendLog(listResult.IgnoredListError);
|
|
}
|
|
|
|
CurrentScreen = AppScreen.UserSelection;
|
|
StatusMessage = $"Loaded {_allUsers.Count} users and {_allLists.Count} lists.";
|
|
AppendLog(StatusMessage);
|
|
DownloadSelectedCommand.NotifyCanExecuteChanged();
|
|
DownloadPurchasedTabCommand.NotifyCanExecuteChanged();
|
|
SelectUsersFromListCommand.NotifyCanExecuteChanged();
|
|
RefreshUsersCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
private bool TryBuildConfig(out Config config)
|
|
{
|
|
config = CloneConfig(configService.CurrentConfig);
|
|
ClearSpecialConfigErrors();
|
|
Dictionary<string, object?> parsedValues = new(StringComparer.Ordinal);
|
|
Dictionary<string, ConfigFieldViewModel> fieldMap = ConfigFields
|
|
.ToDictionary(field => field.PropertyName, field => field, StringComparer.Ordinal);
|
|
|
|
foreach (ConfigFieldViewModel field in ConfigFields)
|
|
{
|
|
field.ClearError();
|
|
if (!field.TryGetTypedValue(out object? value, out string? error))
|
|
{
|
|
field.SetError(error ?? "Invalid value.");
|
|
continue;
|
|
}
|
|
|
|
parsedValues[field.PropertyName] = value;
|
|
}
|
|
|
|
bool hasFieldErrors = ConfigFields.Any(field => field.HasError);
|
|
if (hasFieldErrors)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (ConfigFieldViewModel field in ConfigFields)
|
|
{
|
|
if (!parsedValues.TryGetValue(field.PropertyName, out object? value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
field.PropertyInfo.SetValue(config, value);
|
|
}
|
|
|
|
ApplySpecialConfigValues(config);
|
|
config.CreatorConfigs = CreatorConfigEditor.ToDictionary();
|
|
ValidateSpecialConfigValues();
|
|
if (HasSpecialConfigErrors())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
IReadOnlyDictionary<string, string> validationErrors = ConfigValidationService.Validate(config);
|
|
foreach (KeyValuePair<string, string> error in validationErrors)
|
|
{
|
|
if (error.Key == nameof(Config.FFmpegPath))
|
|
{
|
|
FfmpegPathError = error.Value;
|
|
continue;
|
|
}
|
|
|
|
if (error.Key == nameof(Config.DownloadPath))
|
|
{
|
|
DownloadPathError = error.Value;
|
|
continue;
|
|
}
|
|
|
|
if (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);
|
|
|
|
int categoryIndex = 0;
|
|
foreach (IGrouping<string, ConfigFieldViewModel> group in grouped)
|
|
{
|
|
IEnumerable<ConfigFieldViewModel> orderedFields = group.Key == "File Naming"
|
|
? group.OrderBy(field => GetFieldOrder(group.Key, field.PropertyName))
|
|
: group.OrderBy(field => field.DisplayName);
|
|
|
|
ConfigCategoryViewModel category = new(group.Key, orderedFields);
|
|
ConfigCategories.Add(category);
|
|
if (categoryIndex % 2 == 0)
|
|
{
|
|
ConfigCategoriesLeft.Add(category);
|
|
}
|
|
else
|
|
{
|
|
ConfigCategoriesRight.Add(category);
|
|
}
|
|
|
|
categoryIndex++;
|
|
}
|
|
}
|
|
|
|
private static int GetFieldOrder(string categoryName, string propertyName)
|
|
{
|
|
if (categoryName == "File Naming")
|
|
{
|
|
return propertyName switch
|
|
{
|
|
nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected) => 0,
|
|
nameof(Config.PostFileNameFormat) => 1,
|
|
nameof(Config.PaidPostFileNameFormat) => 2,
|
|
nameof(Config.MessageFileNameFormat) => 3,
|
|
nameof(Config.PaidMessageFileNameFormat) => 4,
|
|
nameof(Config.CreatorConfigs) => 5,
|
|
_ => 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 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;
|
|
StatusMessage = message;
|
|
CurrentScreen = AppScreen.Loading;
|
|
}
|
|
|
|
private void ShowError(string message)
|
|
{
|
|
ErrorMessage = message;
|
|
StatusMessage = message;
|
|
CurrentScreen = AppScreen.Error;
|
|
AppendLog(message);
|
|
}
|
|
|
|
private void AppendLog(string message)
|
|
{
|
|
if (Dispatcher.UIThread.CheckAccess())
|
|
{
|
|
AddLogEntry(message);
|
|
return;
|
|
}
|
|
|
|
Dispatcher.UIThread.Post(() => AddLogEntry(message));
|
|
}
|
|
|
|
private void AddLogEntry(string message)
|
|
{
|
|
ActivityLog.Add($"{DateTime.Now:HH:mm:ss} {message}");
|
|
if (ActivityLog.Count > 500)
|
|
{
|
|
ActivityLog.RemoveAt(0);
|
|
}
|
|
}
|
|
|
|
private void OnSelectableUserPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(SelectableUserViewModel.IsSelected))
|
|
{
|
|
OnPropertyChanged(nameof(SelectedUsersSummary));
|
|
if (!_isUpdatingAllUsersSelected)
|
|
{
|
|
OnPropertyChanged(nameof(AllUsersSelected));
|
|
}
|
|
|
|
DownloadSelectedCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
|
|
private void StartDownloadProgress(string description, long maxValue, bool showSize) =>
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
DownloadProgressDescription = description;
|
|
DownloadProgressMaximum = Math.Max(1, maxValue);
|
|
DownloadProgressValue = 0;
|
|
IsDownloadProgressIndeterminate = maxValue <= 0;
|
|
IsDownloadProgressVisible = true;
|
|
});
|
|
|
|
private void IncrementDownloadProgress(long increment) =>
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (IsDownloadProgressIndeterminate)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DownloadProgressValue = Math.Min(DownloadProgressMaximum, DownloadProgressValue + increment);
|
|
});
|
|
|
|
private void UpdateProgressStatus(string message) =>
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (IsDownloadProgressVisible)
|
|
{
|
|
DownloadProgressDescription = message;
|
|
}
|
|
});
|
|
|
|
private void StopDownloadProgress() =>
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
DownloadProgressDescription = string.Empty;
|
|
DownloadProgressValue = 0;
|
|
DownloadProgressMaximum = 1;
|
|
IsDownloadProgressIndeterminate = false;
|
|
IsDownloadProgressVisible = false;
|
|
});
|
|
|
|
private void ThrowIfStopRequested()
|
|
{
|
|
if (_workCancellationSource?.IsCancellationRequested == true)
|
|
{
|
|
throw new OperationCanceledException("Operation canceled by user.");
|
|
}
|
|
}
|
|
|
|
private static Config CloneConfig(Config source)
|
|
{
|
|
string json = JsonConvert.SerializeObject(source);
|
|
return JsonConvert.DeserializeObject<Config>(json) ?? new Config();
|
|
}
|
|
|
|
private static bool IsHiddenConfigField(string propertyName) =>
|
|
propertyName is nameof(Config.NonInteractiveMode)
|
|
or nameof(Config.NonInteractiveModeListName)
|
|
or nameof(Config.NonInteractiveModePurchasedTab)
|
|
or nameof(Config.DisableBrowserAuth)
|
|
or nameof(Config.FFmpegPath)
|
|
or nameof(Config.FFprobePath)
|
|
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);
|
|
|
|
private static string GetConfigCategory(string propertyName) =>
|
|
propertyName switch
|
|
{
|
|
nameof(Config.DisableBrowserAuth) => "Auth",
|
|
nameof(Config.FFmpegPath) => "External",
|
|
|
|
nameof(Config.DownloadAvatarHeaderPhoto) => "Download Media Types",
|
|
nameof(Config.DownloadPaidPosts) => "Download Media Types",
|
|
nameof(Config.DownloadPosts) => "Download Media Types",
|
|
nameof(Config.DownloadArchived) => "Download Media Types",
|
|
nameof(Config.DownloadStreams) => "Download Media Types",
|
|
nameof(Config.DownloadStories) => "Download Media Types",
|
|
nameof(Config.DownloadHighlights) => "Download Media Types",
|
|
nameof(Config.DownloadMessages) => "Download Media Types",
|
|
nameof(Config.DownloadPaidMessages) => "Download Media Types",
|
|
nameof(Config.DownloadImages) => "Download Media Types",
|
|
nameof(Config.DownloadVideos) => "Download Media Types",
|
|
nameof(Config.DownloadAudios) => "Download Media Types",
|
|
|
|
nameof(Config.IgnoreOwnMessages) => "Download Behavior",
|
|
nameof(Config.DownloadPostsIncrementally) => "Download Behavior",
|
|
nameof(Config.BypassContentForCreatorsWhoNoLongerExist) => "Download Behavior",
|
|
nameof(Config.DownloadDuplicatedMedia) => "Download Behavior",
|
|
nameof(Config.SkipAds) => "Download Behavior",
|
|
nameof(Config.DownloadPath) => "Download Behavior",
|
|
nameof(Config.DownloadOnlySpecificDates) => "Download Behavior",
|
|
nameof(Config.DownloadDateSelection) => "Download Behavior",
|
|
nameof(Config.CustomDate) => "Download Behavior",
|
|
nameof(Config.ShowScrapeSize) => "Download Behavior",
|
|
nameof(Config.DisableTextSanitization) => "Download Behavior",
|
|
nameof(Config.DownloadVideoResolution) => "Download Behavior",
|
|
|
|
nameof(Config.PaidPostFileNameFormat) => "File Naming",
|
|
nameof(Config.PostFileNameFormat) => "File Naming",
|
|
nameof(Config.PaidMessageFileNameFormat) => "File Naming",
|
|
nameof(Config.MessageFileNameFormat) => "File Naming",
|
|
nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected) => "File Naming",
|
|
nameof(Config.CreatorConfigs) => "File Naming",
|
|
|
|
nameof(Config.FolderPerPaidPost) => "Folder Structure",
|
|
nameof(Config.FolderPerPost) => "Folder Structure",
|
|
nameof(Config.FolderPerPaidMessage) => "Folder Structure",
|
|
nameof(Config.FolderPerMessage) => "Folder Structure",
|
|
|
|
nameof(Config.IncludeExpiredSubscriptions) => "Subscriptions",
|
|
nameof(Config.IncludeRestrictedSubscriptions) => "Subscriptions",
|
|
nameof(Config.IgnoredUsersListName) => "Subscriptions",
|
|
|
|
nameof(Config.Timeout) => "Performance",
|
|
nameof(Config.LimitDownloadRate) => "Performance",
|
|
nameof(Config.DownloadLimitInMbPerSec) => "Performance",
|
|
|
|
nameof(Config.Theme) => "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
|
|
};
|
|
}
|