From ec8bf47de534c0fd9d99acd8f1c15e8e2039d753 Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Fri, 13 Feb 2026 03:38:44 -0600 Subject: [PATCH] Initial demo GUI --- AGENTS.md | 22 +- OF DL.Core/Services/AuthService.cs | 2 + OF DL.Gui/App.axaml | 9 + OF DL.Gui/App.axaml.cs | 28 + OF DL.Gui/OF DL.Gui.csproj | 28 + OF DL.Gui/Program.cs | 16 + .../Services/AvaloniaDownloadEventHandler.cs | 103 ++ .../Services/AvaloniaProgressReporter.cs | 21 + OF DL.Gui/Services/AvaloniaStatusReporter.cs | 18 + OF DL.Gui/Services/ConfigValidationService.cs | 84 ++ .../Services/ServiceCollectionFactory.cs | 29 + OF DL.Gui/ViewModels/AppScreen.cs | 10 + .../ViewModels/ConfigCategoryViewModel.cs | 22 + OF DL.Gui/ViewModels/ConfigFieldViewModel.cs | 251 ++++ OF DL.Gui/ViewModels/MainWindowViewModel.cs | 1198 +++++++++++++++++ .../ViewModels/MultiSelectOptionViewModel.cs | 19 + .../ViewModels/SelectableUserViewModel.cs | 12 + OF DL.Gui/ViewModels/ViewModelBase.cs | 7 + OF DL.Gui/Views/MainWindow.axaml | 394 ++++++ OF DL.Gui/Views/MainWindow.axaml.cs | 103 ++ OF DL.sln | 6 + OF DL/OF DL.csproj | 1 + OF DL/Program.cs | 44 +- 23 files changed, 2421 insertions(+), 6 deletions(-) create mode 100644 OF DL.Gui/App.axaml create mode 100644 OF DL.Gui/App.axaml.cs create mode 100644 OF DL.Gui/OF DL.Gui.csproj create mode 100644 OF DL.Gui/Program.cs create mode 100644 OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs create mode 100644 OF DL.Gui/Services/AvaloniaProgressReporter.cs create mode 100644 OF DL.Gui/Services/AvaloniaStatusReporter.cs create mode 100644 OF DL.Gui/Services/ConfigValidationService.cs create mode 100644 OF DL.Gui/Services/ServiceCollectionFactory.cs create mode 100644 OF DL.Gui/ViewModels/AppScreen.cs create mode 100644 OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs create mode 100644 OF DL.Gui/ViewModels/ConfigFieldViewModel.cs create mode 100644 OF DL.Gui/ViewModels/MainWindowViewModel.cs create mode 100644 OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs create mode 100644 OF DL.Gui/ViewModels/SelectableUserViewModel.cs create mode 100644 OF DL.Gui/ViewModels/ViewModelBase.cs create mode 100644 OF DL.Gui/Views/MainWindow.axaml create mode 100644 OF DL.Gui/Views/MainWindow.axaml.cs diff --git a/AGENTS.md b/AGENTS.md index 368b337..a2f6d1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,24 +2,28 @@ Note: Keep AGENTS.md updated as project structure, key services, or workflows change. -This repo is **OF DL** (also known as OF-DL), a C# console app that downloads media from a user's OnlyFans account(s). +This repo is **OF DL** (also known as OF-DL), a C# app suite (console + Avalonia desktop GUI) that downloads media +from a user's OnlyFans account(s). This document is for AI agents helping developers modify the project. It focuses on architecture, data flow, and the most important change points. ## Quick Flow -1. `Program.Main` builds DI, loads `config.conf`, and runs the interactive flow. +1. `Program.Main` is the app entrypoint: default launch is GUI; `--cli` switches to CLI mode. 2. `StartupService.CheckVersionAsync` checks the latest release tag (`OFDLV*`) from `git.ofdl.tools` when not in DEBUG. 3. `StartupService.ValidateEnvironmentAsync` validates OS, FFmpeg, `rules.json`, and Widevine device files. 4. `AuthService` loads `auth.json` or opens a browser login (PuppeteerSharp) and persists auth data. 5. `ApiService` signs every API request with dynamic rules and the current auth. 6. `DownloadOrchestrationService` selects creators, prepares folders/DBs, and calls `DownloadService` per media type. 7. `DownloadService` downloads media, handles DRM, and records metadata in SQLite. +8. `OF DL.Gui` starts with config validation, then auth validation/browser login, then loads users/lists and provides + multi-select download controls. ## Project Layout -- `OF DL/Program.cs` orchestrates startup, config/auth loading, and the interactive flow (CLI entrypoint). +- `OF DL/Program.cs` is the single entrypoint and routes between GUI (default) and CLI (`--cli`). - `OF DL/CLI/` contains Spectre.Console UI helpers and progress reporting (CLI-only). +- `OF DL.Gui/` contains the Avalonia desktop UI (`App`, `MainWindow`, MVVM view models, and GUI event handlers). - `OF DL.Core/Services/` contains application services (API, auth, download, config, DB, startup, logging, filenames). - `OF DL.Core/Models/` holds configuration, auth, API request/response models, downloads/startup results, DTOs, entities, and mapping helpers. @@ -86,19 +90,25 @@ most important change points. ## Execution and Testing -- .NET SDK: 8.x (`net8.0` for all projects). +- .NET SDK: 10.x (`net10.0` for all projects). - Build from the repo root: ```bash dotnet build OF DL.sln ``` -- Run from source (runtime files are read from the current working directory): +- Run from source (GUI mode, default): ```bash dotnet run --project "OF DL/OF DL.csproj" ``` +- Run CLI mode: + +```bash +dotnet run --project "OF DL/OF DL.csproj" -- --cli +``` + - If you want a local `rules.json` fallback, run from `OF DL/` or copy `OF DL/rules.json` into your working directory. - Run tests: @@ -220,6 +230,8 @@ cookies/user-agent. Output is written to `{filename}_source.mp4`, then moved and ## Where to Look First - `OF DL/Program.cs` for the execution path and menu flow. +- `OF DL.Gui/ViewModels/MainWindowViewModel.cs` for GUI startup flow (config -> auth -> users/lists -> selection). +- `OF DL.Gui/Views/MainWindow.axaml` for GUI layout and interaction points. - `OF DL.Core/Services/ApiService.cs` for OF API calls and header signing. - `OF DL.Core/Services/DownloadService.cs` for downloads and DRM handling. - `OF DL.Core/Services/DownloadOrchestrationService.cs` for creator selection and flow control. diff --git a/OF DL.Core/Services/AuthService.cs b/OF DL.Core/Services/AuthService.cs index a8b725c..7b9a7a8 100644 --- a/OF DL.Core/Services/AuthService.cs +++ b/OF DL.Core/Services/AuthService.cs @@ -294,6 +294,8 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService string cookies = string.Join(" ", mappedCookies.Keys.Where(key => _desiredCookies.Contains(key)) .Select(key => $"${key}={mappedCookies[key]};")); + await browser.CloseAsync(); + return new Auth { Cookie = cookies, UserAgent = userAgent, UserId = userId, XBc = xBc }; } catch (Exception e) diff --git a/OF DL.Gui/App.axaml b/OF DL.Gui/App.axaml new file mode 100644 index 0000000..1cb3638 --- /dev/null +++ b/OF DL.Gui/App.axaml @@ -0,0 +1,9 @@ + + + + + diff --git a/OF DL.Gui/App.axaml.cs b/OF DL.Gui/App.axaml.cs new file mode 100644 index 0000000..901b34c --- /dev/null +++ b/OF DL.Gui/App.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Microsoft.Extensions.DependencyInjection; +using OF_DL.Gui.Services; +using OF_DL.Gui.ViewModels; +using OF_DL.Gui.Views; + +namespace OF_DL.Gui; + +public class App : Application +{ + private readonly ServiceProvider _serviceProvider = ServiceCollectionFactory.Create().BuildServiceProvider(); + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + MainWindow mainWindow = _serviceProvider.GetRequiredService(); + mainWindow.DataContext = _serviceProvider.GetRequiredService(); + desktop.MainWindow = mainWindow; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/OF DL.Gui/OF DL.Gui.csproj b/OF DL.Gui/OF DL.Gui.csproj new file mode 100644 index 0000000..1050cfd --- /dev/null +++ b/OF DL.Gui/OF DL.Gui.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + OF_DL.Gui + enable + enable + true + + + + + + + + + + + + + + + + + + + + diff --git a/OF DL.Gui/Program.cs b/OF DL.Gui/Program.cs new file mode 100644 index 0000000..c664b27 --- /dev/null +++ b/OF DL.Gui/Program.cs @@ -0,0 +1,16 @@ +using Avalonia; + +namespace OF_DL.Gui; + +public static class GuiLauncher +{ + public static void Run(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + private static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); +} diff --git a/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs b/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs new file mode 100644 index 0000000..cb42640 --- /dev/null +++ b/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs @@ -0,0 +1,103 @@ +using OF_DL.Models.Downloads; +using OF_DL.Services; + +namespace OF_DL.Gui.Services; + +internal sealed class AvaloniaDownloadEventHandler( + Action activitySink, + Action progressStatusUpdate, + Action progressStart, + Action progressIncrement, + Action progressStop, + Func isCancellationRequested) : IDownloadEventHandler +{ + public async Task WithStatusAsync(string statusMessage, Func> work) + { + ThrowIfCancellationRequested(); + progressStart(statusMessage, 0, false); + try + { + AvaloniaStatusReporter statusReporter = new(progressStatusUpdate, isCancellationRequested); + return await work(statusReporter); + } + finally + { + progressStop(); + } + } + + public async Task WithProgressAsync(string description, long maxValue, bool showSize, + Func> work) + { + ThrowIfCancellationRequested(); + progressStart(description, maxValue, showSize); + try + { + AvaloniaProgressReporter reporter = new(progressIncrement, isCancellationRequested); + return await work(reporter); + } + finally + { + progressStop(); + } + } + + public void OnContentFound(string contentType, int mediaCount, int objectCount) + { + ThrowIfCancellationRequested(); + progressStatusUpdate($"Found {mediaCount} media from {objectCount} {contentType}."); + } + + public void OnNoContentFound(string contentType) + { + ThrowIfCancellationRequested(); + progressStatusUpdate($"Found 0 {contentType}."); + } + + public void OnDownloadComplete(string contentType, DownloadResult result) + { + ThrowIfCancellationRequested(); + progressStatusUpdate( + $"{contentType} complete. Existing: {result.ExistingDownloads}, New: {result.NewDownloads}, Total: {result.TotalCount}."); + } + + public void OnUserStarting(string username) + { + ThrowIfCancellationRequested(); + activitySink($"Starting scrape for {username}."); + progressStatusUpdate($"Scraping data for {username}..."); + } + + public void OnUserComplete(string username, CreatorDownloadResult result) + { + ThrowIfCancellationRequested(); + activitySink( + $"Completed {username}. PaidPosts={result.PaidPostCount}, Posts={result.PostCount}, Archived={result.ArchivedCount}, Streams={result.StreamsCount}, Stories={result.StoriesCount}, Highlights={result.HighlightsCount}, Messages={result.MessagesCount}, PaidMessages={result.PaidMessagesCount}."); + } + + public void OnPurchasedTabUserComplete(string username, int paidPostCount, int paidMessagesCount) + { + ThrowIfCancellationRequested(); + activitySink($"Purchased tab complete for {username}. PaidPosts={paidPostCount}, PaidMessages={paidMessagesCount}."); + } + + public void OnScrapeComplete(TimeSpan elapsed) + { + ThrowIfCancellationRequested(); + activitySink($"Scrape completed in {elapsed.TotalMinutes:0.00} minutes."); + } + + public void OnMessage(string message) + { + ThrowIfCancellationRequested(); + progressStatusUpdate(message); + } + + private void ThrowIfCancellationRequested() + { + if (isCancellationRequested()) + { + throw new OperationCanceledException("Operation canceled by user."); + } + } +} diff --git a/OF DL.Gui/Services/AvaloniaProgressReporter.cs b/OF DL.Gui/Services/AvaloniaProgressReporter.cs new file mode 100644 index 0000000..59205be --- /dev/null +++ b/OF DL.Gui/Services/AvaloniaProgressReporter.cs @@ -0,0 +1,21 @@ +using OF_DL.Services; + +namespace OF_DL.Gui.Services; + +internal sealed class AvaloniaProgressReporter( + Action reportAction, + Func isCancellationRequested) : IProgressReporter +{ + public void ReportProgress(long increment) + { + if (isCancellationRequested()) + { + throw new OperationCanceledException("Operation canceled by user."); + } + + if (increment > 0) + { + reportAction(increment); + } + } +} diff --git a/OF DL.Gui/Services/AvaloniaStatusReporter.cs b/OF DL.Gui/Services/AvaloniaStatusReporter.cs new file mode 100644 index 0000000..c88cdee --- /dev/null +++ b/OF DL.Gui/Services/AvaloniaStatusReporter.cs @@ -0,0 +1,18 @@ +using OF_DL.Services; + +namespace OF_DL.Gui.Services; + +internal sealed class AvaloniaStatusReporter( + Action statusAction, + Func isCancellationRequested) : IStatusReporter +{ + public void ReportStatus(string message) + { + if (isCancellationRequested()) + { + throw new OperationCanceledException("Operation canceled by user."); + } + + statusAction(message); + } +} diff --git a/OF DL.Gui/Services/ConfigValidationService.cs b/OF DL.Gui/Services/ConfigValidationService.cs new file mode 100644 index 0000000..bf83c89 --- /dev/null +++ b/OF DL.Gui/Services/ConfigValidationService.cs @@ -0,0 +1,84 @@ +using OF_DL.Models.Config; + +namespace OF_DL.Gui.Services; + +internal static class ConfigValidationService +{ + public static IReadOnlyDictionary Validate(Config config) + { + Dictionary errors = new(StringComparer.Ordinal); + + ValidatePath(config.DownloadPath, nameof(Config.DownloadPath), errors, requireExistingFile: false); + ValidatePath(config.FFmpegPath, nameof(Config.FFmpegPath), errors, requireExistingFile: true); + + if (config.Timeout.HasValue && config.Timeout.Value <= 0 && config.Timeout.Value != -1) + { + errors[nameof(Config.Timeout)] = "Timeout must be -1 or greater than 0."; + } + + if (config.LimitDownloadRate && config.DownloadLimitInMbPerSec <= 0) + { + errors[nameof(Config.DownloadLimitInMbPerSec)] = + "DownloadLimitInMbPerSec must be greater than 0 when LimitDownloadRate is enabled."; + } + + if (config.DownloadOnlySpecificDates && !config.CustomDate.HasValue) + { + errors[nameof(Config.CustomDate)] = "CustomDate is required when DownloadOnlySpecificDates is enabled."; + } + + ValidateFileNameFormat(config.PaidPostFileNameFormat, nameof(Config.PaidPostFileNameFormat), errors); + ValidateFileNameFormat(config.PostFileNameFormat, nameof(Config.PostFileNameFormat), errors); + ValidateFileNameFormat(config.PaidMessageFileNameFormat, nameof(Config.PaidMessageFileNameFormat), errors); + ValidateFileNameFormat(config.MessageFileNameFormat, nameof(Config.MessageFileNameFormat), errors); + + foreach (KeyValuePair creatorConfig in config.CreatorConfigs) + { + ValidateFileNameFormat(creatorConfig.Value.PaidPostFileNameFormat, + $"{nameof(Config.CreatorConfigs)}.{creatorConfig.Key}.PaidPostFileNameFormat", errors); + ValidateFileNameFormat(creatorConfig.Value.PostFileNameFormat, + $"{nameof(Config.CreatorConfigs)}.{creatorConfig.Key}.PostFileNameFormat", errors); + ValidateFileNameFormat(creatorConfig.Value.PaidMessageFileNameFormat, + $"{nameof(Config.CreatorConfigs)}.{creatorConfig.Key}.PaidMessageFileNameFormat", errors); + ValidateFileNameFormat(creatorConfig.Value.MessageFileNameFormat, + $"{nameof(Config.CreatorConfigs)}.{creatorConfig.Key}.MessageFileNameFormat", errors); + } + + return errors; + } + + private static void ValidatePath(string? path, string fieldName, IDictionary errors, + bool requireExistingFile) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + if (path.Any(character => Path.GetInvalidPathChars().Contains(character))) + { + errors[fieldName] = "Path contains invalid characters."; + return; + } + + if (requireExistingFile && !File.Exists(path)) + { + errors[fieldName] = "Path must point to an existing file."; + } + } + + private static void ValidateFileNameFormat(string? format, string fieldName, IDictionary errors) + { + if (string.IsNullOrWhiteSpace(format)) + { + return; + } + + bool hasUniqueToken = format.Contains("{mediaId}", StringComparison.OrdinalIgnoreCase) || + format.Contains("{filename}", StringComparison.OrdinalIgnoreCase); + if (!hasUniqueToken) + { + errors[fieldName] = "Format must include {mediaId} or {filename} to avoid file collisions."; + } + } +} diff --git a/OF DL.Gui/Services/ServiceCollectionFactory.cs b/OF DL.Gui/Services/ServiceCollectionFactory.cs new file mode 100644 index 0000000..4ce805e --- /dev/null +++ b/OF DL.Gui/Services/ServiceCollectionFactory.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using OF_DL.Gui.ViewModels; +using OF_DL.Gui.Views; +using OF_DL.Services; + +namespace OF_DL.Gui.Services; + +internal static class ServiceCollectionFactory +{ + public static IServiceCollection Create() + { + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/OF DL.Gui/ViewModels/AppScreen.cs b/OF DL.Gui/ViewModels/AppScreen.cs new file mode 100644 index 0000000..159021a --- /dev/null +++ b/OF DL.Gui/ViewModels/AppScreen.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Gui.ViewModels; + +public enum AppScreen +{ + Loading, + Config, + Auth, + UserSelection, + Error +} diff --git a/OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs b/OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs new file mode 100644 index 0000000..896ae3e --- /dev/null +++ b/OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs @@ -0,0 +1,22 @@ +using System.Collections.ObjectModel; + +namespace OF_DL.Gui.ViewModels; + +public sealed class ConfigCategoryViewModel : ViewModelBase +{ + public ConfigCategoryViewModel(string categoryName, IEnumerable fields) + { + CategoryName = categoryName; + foreach (ConfigFieldViewModel field in fields) + { + Fields.Add(field); + } + } + + public string CategoryName { get; } + + public bool IsDownloadBehavior => + string.Equals(CategoryName, "Download Behavior", StringComparison.Ordinal); + + public ObservableCollection Fields { get; } = []; +} diff --git a/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs new file mode 100644 index 0000000..33d2895 --- /dev/null +++ b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs @@ -0,0 +1,251 @@ +using System.Collections.ObjectModel; +using System.Reflection; +using CommunityToolkit.Mvvm.ComponentModel; +using Newtonsoft.Json; +using OF_DL.Models.Config; + +namespace OF_DL.Gui.ViewModels; + +public partial class ConfigFieldViewModel : ViewModelBase +{ + public ConfigFieldViewModel(PropertyInfo propertyInfo, object? initialValue) + { + PropertyInfo = propertyInfo; + PropertyName = propertyInfo.Name; + DisplayName = ToDisplayName(propertyInfo.Name); + PropertyType = propertyInfo.PropertyType; + + IsBoolean = PropertyType == typeof(bool); + IsEnum = PropertyType.IsEnum; + IsDate = PropertyType == typeof(DateTime?); + IsNumeric = PropertyType == typeof(int) || PropertyType == typeof(int?); + IsMultiline = PropertyType == typeof(Dictionary); + IsTextInput = !IsBoolean && !IsEnum && !IsDate && !IsNumeric; + + if (IsEnum) + { + foreach (string enumName in Enum.GetNames(PropertyType)) + { + EnumOptions.Add(enumName); + } + } + + LoadInitialValue(initialValue); + } + + public PropertyInfo PropertyInfo { get; } + + public string PropertyName { get; } + + public string DisplayName { get; } + + public Type PropertyType { get; } + + public bool IsBoolean { get; } + + public bool IsEnum { get; } + + public bool IsDate { get; } + + public bool IsNumeric { get; } + + public bool IsTextInput { get; } + + public bool IsMultiline { get; } + + public double TextBoxMinHeight => IsMultiline ? 150 : 36; + + public ObservableCollection EnumOptions { get; } = []; + + [ObservableProperty] private bool _boolValue; + + [ObservableProperty] private string? _enumValue; + + [ObservableProperty] private DateTimeOffset? _dateValue; + + [ObservableProperty] private decimal? _numericValue; + + [ObservableProperty] private string _textValue = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string _errorMessage = string.Empty; + + public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage); + + public bool TryGetTypedValue(out object? value, out string? error) + { + value = null; + error = null; + + if (IsBoolean) + { + value = BoolValue; + return true; + } + + if (IsEnum) + { + if (string.IsNullOrWhiteSpace(EnumValue)) + { + error = $"{DisplayName} is required."; + return false; + } + + if (!Enum.TryParse(PropertyType, EnumValue, true, out object? enumResult)) + { + error = $"{DisplayName} must be one of: {string.Join(", ", EnumOptions)}."; + return false; + } + + value = enumResult; + return true; + } + + if (PropertyType == typeof(string)) + { + value = TextValue.Trim(); + return true; + } + + if (PropertyType == typeof(int)) + { + if (!NumericValue.HasValue) + { + error = $"{DisplayName} must be a whole number."; + return false; + } + + if (decimal.Truncate(NumericValue.Value) != NumericValue.Value) + { + error = $"{DisplayName} must be a whole number."; + return false; + } + + value = (int)NumericValue.Value; + return true; + } + + if (PropertyType == typeof(int?)) + { + if (!NumericValue.HasValue) + { + value = null; + return true; + } + + if (decimal.Truncate(NumericValue.Value) != NumericValue.Value) + { + error = $"{DisplayName} must be a whole number."; + return false; + } + + value = (int)NumericValue.Value; + return true; + } + + if (PropertyType == typeof(DateTime?)) + { + if (!DateValue.HasValue) + { + value = null; + return true; + } + + value = DateValue.Value.DateTime; + return true; + } + + if (PropertyType == typeof(Dictionary)) + { + if (string.IsNullOrWhiteSpace(TextValue)) + { + value = new Dictionary(); + return true; + } + + try + { + Dictionary? parsed = + JsonConvert.DeserializeObject>(TextValue); + value = parsed ?? new Dictionary(); + return true; + } + catch (JsonException) + { + error = $"{DisplayName} must be valid JSON."; + return false; + } + } + + error = $"{DisplayName} has an unsupported field type."; + return false; + } + + public void ClearError() + { + ErrorMessage = string.Empty; + } + + public void SetError(string message) + { + ErrorMessage = message; + } + + private void LoadInitialValue(object? initialValue) + { + if (IsBoolean) + { + BoolValue = initialValue is bool boolValue && boolValue; + return; + } + + if (IsEnum) + { + EnumValue = initialValue?.ToString() ?? EnumOptions.FirstOrDefault(); + return; + } + + if (PropertyType == typeof(Dictionary)) + { + Dictionary creatorConfigs = + initialValue as Dictionary ?? new Dictionary(); + TextValue = JsonConvert.SerializeObject(creatorConfigs, Formatting.Indented); + return; + } + + if (PropertyType == typeof(DateTime?)) + { + DateTime? date = initialValue is DateTime dt ? dt : null; + DateValue = date.HasValue ? new DateTimeOffset(date.Value) : null; + return; + } + + if (PropertyType == typeof(int)) + { + NumericValue = initialValue is int intValue ? intValue : 0; + return; + } + + if (PropertyType == typeof(int?)) + { + NumericValue = initialValue is int nullableIntValue ? nullableIntValue : null; + return; + } + + TextValue = initialValue?.ToString() ?? string.Empty; + } + + private static string ToDisplayName(string propertyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + return propertyName; + } + + return string.Concat(propertyName.Select((character, index) => + index > 0 && char.IsUpper(character) && !char.IsUpper(propertyName[index - 1]) + ? $" {character}" + : character.ToString())); + } +} diff --git a/OF DL.Gui/ViewModels/MainWindowViewModel.cs b/OF DL.Gui/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..2efff39 --- /dev/null +++ b/OF DL.Gui/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,1198 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Avalonia.Threading; +using Newtonsoft.Json; +using OF_DL.Gui.Services; +using OF_DL.Models; +using OF_DL.Models.Config; +using OF_DL.Models.Downloads; +using OF_DL.Services; +using UserEntities = OF_DL.Models.Entities.Users; + +namespace OF_DL.Gui.ViewModels; + +public partial class MainWindowViewModel( + IConfigService configService, + IAuthService authService, + IStartupService startupService, + IDownloadOrchestrationService downloadOrchestrationService) : ViewModelBase +{ + private static readonly string s_defaultDownloadPath = Path.GetFullPath( + Path.Combine(Directory.GetCurrentDirectory(), "__user_data__", "sites", "OnlyFans")); + + private static readonly (string DisplayName, string PropertyName)[] s_mediaTypeOptions = + [ + ("Videos", nameof(Config.DownloadVideos)), + ("Images", nameof(Config.DownloadImages)), + ("Audios", nameof(Config.DownloadAudios)) + ]; + + private static readonly (string DisplayName, string PropertyName)[] s_mediaSourceOptions = + [ + ("Avatar/Header Photo", nameof(Config.DownloadAvatarHeaderPhoto)), + ("Posts", nameof(Config.DownloadPosts)), + ("Paid Posts", nameof(Config.DownloadPaidPosts)), + ("Archived", nameof(Config.DownloadArchived)), + ("Streams", nameof(Config.DownloadStreams)), + ("Stories", nameof(Config.DownloadStories)), + ("Highlights", nameof(Config.DownloadHighlights)), + ("Messages", nameof(Config.DownloadMessages)), + ("Paid Messages", nameof(Config.DownloadPaidMessages)) + ]; + + private Dictionary _allUsers = []; + private Dictionary _allLists = []; + private StartupResult _startupResult = new(); + private CancellationTokenSource? _workCancellationSource; + private AppScreen _configReturnScreen = AppScreen.Loading; + private bool _isApplyingListSelection; + + public ObservableCollection ConfigFields { get; } = []; + + public ObservableCollection ConfigCategories { get; } = []; + + public ObservableCollection MediaTypeOptions { get; } = []; + + public ObservableCollection MediaSourceOptions { get; } = []; + + public ObservableCollection AvailableUsers { get; } = []; + + public ObservableCollection UserLists { get; } = []; + + public ObservableCollection ActivityLog { get; } = []; + + [ObservableProperty] private AppScreen _currentScreen = AppScreen.Loading; + + [ObservableProperty] private string _statusMessage = "Initializing..."; + + [ObservableProperty] private string _loadingMessage = "Initializing..."; + + [ObservableProperty] private string _configScreenMessage = string.Empty; + + [ObservableProperty] private string _authScreenMessage = string.Empty; + + [ObservableProperty] private string _errorMessage = string.Empty; + + [ObservableProperty] private string _ffmpegPath = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasFfmpegPathError))] + private string _ffmpegPathError = string.Empty; + + [ObservableProperty] private string _downloadPath = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasDownloadPathError))] + private string _downloadPathError = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasMediaTypesError))] + private string _mediaTypesError = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasMediaSourcesError))] + private string _mediaSourcesError = string.Empty; + + [ObservableProperty] private string _authenticatedUserDisplay = "Not authenticated."; + + [ObservableProperty] private bool _isAuthenticated; + + [ObservableProperty] private string? _selectedListName; + + [ObservableProperty] private bool _hasInitialized; + + [ObservableProperty] private bool _isDownloading; + + [ObservableProperty] private bool _isDownloadProgressVisible; + + [ObservableProperty] private bool _isDownloadProgressIndeterminate; + + [ObservableProperty] private double _downloadProgressValue; + + [ObservableProperty] private double _downloadProgressMaximum = 1; + + [ObservableProperty] private string _downloadProgressDescription = string.Empty; + + public bool IsLoadingScreen => CurrentScreen == AppScreen.Loading; + + public bool IsConfigScreen => CurrentScreen == AppScreen.Config; + + public bool IsAuthScreen => CurrentScreen == AppScreen.Auth; + + public bool IsUserSelectionScreen => CurrentScreen == AppScreen.UserSelection; + + public bool IsErrorScreen => CurrentScreen == AppScreen.Error; + + public bool HasFfmpegPathError => !string.IsNullOrWhiteSpace(FfmpegPathError); + + public bool HasDownloadPathError => !string.IsNullOrWhiteSpace(DownloadPathError); + + public bool HasMediaTypesError => !string.IsNullOrWhiteSpace(MediaTypesError); + + public bool HasMediaSourcesError => !string.IsNullOrWhiteSpace(MediaSourcesError); + + public string SelectedUsersSummary => + $"{AvailableUsers.Count(user => user.IsSelected)} / {AvailableUsers.Count} selected"; + + public async Task InitializeAsync() + { + if (HasInitialized) + { + return; + } + + HasInitialized = true; + await BeginStartupAsync(); + } + + public void SetFfmpegPath(string? path) + { + FfmpegPath = NormalizePathForDisplay(path); + FfmpegPathError = string.Empty; + } + + public void SetDownloadPath(string? path) + { + DownloadPath = NormalizePathForDisplay(path); + DownloadPathError = string.Empty; + } + + [RelayCommand] + private async Task RetryStartupAsync() + { + await BeginStartupAsync(); + } + + [RelayCommand] + private void ExitApplication() + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(); + return; + } + + Environment.Exit(0); + } + + [RelayCommand(CanExecute = nameof(CanLogout))] + private void Logout() + { + authService.Logout(); + authService.CurrentAuth = null; + IsAuthenticated = false; + + foreach (SelectableUserViewModel user in AvailableUsers) + { + user.PropertyChanged -= OnSelectableUserPropertyChanged; + } + + _allUsers = []; + _allLists = []; + AvailableUsers.Clear(); + UserLists.Clear(); + SelectedListName = null; + + AuthenticatedUserDisplay = "Not authenticated."; + AuthScreenMessage = "You have been logged out. Click 'Login with Browser' to authenticate."; + StatusMessage = "Logged out."; + CurrentScreen = AppScreen.Auth; + OnPropertyChanged(nameof(SelectedUsersSummary)); + DownloadSelectedCommand.NotifyCanExecuteChanged(); + DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); + SelectUsersFromListCommand.NotifyCanExecuteChanged(); + RefreshUsersCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void EditConfig() + { + _configReturnScreen = CurrentScreen; + BuildConfigFields(configService.CurrentConfig); + ConfigScreenMessage = "Edit configuration values and save to apply changes."; + StatusMessage = "Editing configuration."; + CurrentScreen = AppScreen.Config; + } + + [RelayCommand] + private async Task CancelConfigAsync() + { + bool loaded = await configService.LoadConfigurationAsync([]); + BuildConfigFields(configService.CurrentConfig); + + if (!loaded) + { + ConfigScreenMessage = "Configuration is still invalid."; + StatusMessage = ConfigScreenMessage; + CurrentScreen = AppScreen.Config; + return; + } + + if (_configReturnScreen == AppScreen.UserSelection && _allUsers.Count > 0) + { + CurrentScreen = AppScreen.UserSelection; + StatusMessage = "Configuration changes canceled."; + return; + } + + await BeginStartupAsync(); + } + + [RelayCommand(CanExecute = nameof(CanRefreshUsers))] + private async Task RefreshUsersAsync() + { + await LoadUsersAndListsAsync(); + } + + [RelayCommand] + 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 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] + private void SelectAllUsers() + { + foreach (SelectableUserViewModel user in AvailableUsers) + { + user.IsSelected = true; + } + + OnPropertyChanged(nameof(SelectedUsersSummary)); + AppendLog($"Selected all users ({AvailableUsers.Count})."); + } + + [RelayCommand] + private void SelectNoUsers() + { + foreach (SelectableUserViewModel user in AvailableUsers) + { + user.IsSelected = false; + } + + OnPropertyChanged(nameof(SelectedUsersSummary)); + } + + [RelayCommand(CanExecute = nameof(CanApplySelectedList))] + private async Task SelectUsersFromListAsync() + { + if (string.IsNullOrWhiteSpace(SelectedListName)) + { + return; + } + + _isApplyingListSelection = true; + StartDownloadProgress($"Selecting users from list '{SelectedListName}'...", 0, false); + try + { + foreach (SelectableUserViewModel user in AvailableUsers) + { + user.IsSelected = false; + } + + Dictionary listUsers = await downloadOrchestrationService.GetUsersForListAsync( + SelectedListName, + _allUsers, + _allLists); + HashSet selectedUsernames = listUsers.Keys.ToHashSet(StringComparer.Ordinal); + + foreach (SelectableUserViewModel user in AvailableUsers) + { + user.IsSelected = selectedUsernames.Contains(user.Username); + } + + StatusMessage = $"Selected {selectedUsernames.Count} users from list '{SelectedListName}'."; + OnPropertyChanged(nameof(SelectedUsersSummary)); + DownloadSelectedCommand.NotifyCanExecuteChanged(); + } + finally + { + _isApplyingListSelection = false; + StopDownloadProgress(); + } + } + + [RelayCommand(CanExecute = nameof(CanDownloadSelected))] + private async Task DownloadSelectedAsync() + { + await RunDownloadAsync(downloadPurchasedTabOnly: false); + } + + [RelayCommand(CanExecute = nameof(CanDownloadPurchasedTab))] + private async Task DownloadPurchasedTabAsync() + { + await RunDownloadAsync(downloadPurchasedTabOnly: true); + } + + [RelayCommand(CanExecute = nameof(CanStopWork))] + private void StopWork() + { + if (_workCancellationSource is { IsCancellationRequested: false }) + { + _workCancellationSource.Cancel(); + StatusMessage = "Stop requested. Waiting for current operation to cancel..."; + UpdateProgressStatus("Stopping..."); + AppendLog("Stop requested."); + } + } + + private async Task RunDownloadAsync(bool downloadPurchasedTabOnly) + { + List selectedUsers = AvailableUsers.Where(user => user.IsSelected).ToList(); + if (!downloadPurchasedTabOnly && selectedUsers.Count == 0) + { + StatusMessage = "Select at least one user before downloading."; + return; + } + + if (downloadPurchasedTabOnly && _allUsers.Count == 0) + { + StatusMessage = "No users are loaded. Refresh users and retry."; + return; + } + + IsDownloading = true; + _workCancellationSource?.Dispose(); + _workCancellationSource = new CancellationTokenSource(); + DownloadSelectedCommand.NotifyCanExecuteChanged(); + DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); + StopWorkCommand.NotifyCanExecuteChanged(); + + DateTime start = DateTime.Now; + AppendLog(downloadPurchasedTabOnly + ? "Starting Purchased Tab download." + : $"Starting download for {selectedUsers.Count} users."); + + AvaloniaDownloadEventHandler eventHandler = new( + AppendLog, + UpdateProgressStatus, + StartDownloadProgress, + IncrementDownloadProgress, + StopDownloadProgress, + () => _workCancellationSource?.IsCancellationRequested == true); + + 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 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(); + LogoutCommand.NotifyCanExecuteChanged(); + } + + partial void OnIsDownloadingChanged(bool value) + { + DownloadSelectedCommand.NotifyCanExecuteChanged(); + DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); + SelectUsersFromListCommand.NotifyCanExecuteChanged(); + StopWorkCommand.NotifyCanExecuteChanged(); + RefreshUsersCommand.NotifyCanExecuteChanged(); + LogoutCommand.NotifyCanExecuteChanged(); + } + + partial void OnIsAuthenticatedChanged(bool value) + { + LogoutCommand.NotifyCanExecuteChanged(); + } + + partial void OnSelectedListNameChanged(string? value) + { + SelectUsersFromListCommand.NotifyCanExecuteChanged(); + if (_isApplyingListSelection || IsDownloading || CurrentScreen != AppScreen.UserSelection || + string.IsNullOrWhiteSpace(value)) + { + return; + } + + _ = SelectUsersFromListAsync(); + } + + partial void OnFfmpegPathChanged(string value) + { + FfmpegPathError = string.Empty; + } + + partial void OnDownloadPathChanged(string value) + { + DownloadPathError = string.Empty; + } + + private async Task BeginStartupAsync() + { + _configReturnScreen = CurrentScreen; + SetLoading("Loading configuration..."); + BuildConfigFields(configService.CurrentConfig); + UserLists.Clear(); + AvailableUsers.Clear(); + + bool configLoaded = await configService.LoadConfigurationAsync([]); + BuildConfigFields(configService.CurrentConfig); + if (!configLoaded) + { + ConfigScreenMessage = + "config.conf is invalid. Update all fields below and save to continue."; + CurrentScreen = AppScreen.Config; + StatusMessage = ConfigScreenMessage; + return; + } + + if (!await ValidateEnvironmentAsync()) + { + return; + } + + await EnsureAuthenticationAndLoadUsersAsync(); + } + + private async Task EnsureAuthenticationAndLoadUsersAsync() + { + bool hasValidAuth = await TryLoadAndValidateExistingAuthAsync(); + if (!hasValidAuth) + { + if (configService.CurrentConfig.DisableBrowserAuth) + { + ShowError( + "Authentication is missing or invalid and browser auth is disabled. Enable browser auth in config or provide a valid auth.json."); + return; + } + + AuthScreenMessage = + "Authentication is required. Click 'Login with Browser' and complete the OnlyFans login flow."; + CurrentScreen = AppScreen.Auth; + StatusMessage = "Authentication required."; + return; + } + + await LoadUsersAndListsAsync(); + } + + private async Task ValidateEnvironmentAsync() + { + SetLoading("Validating environment..."); + _startupResult = await startupService.ValidateEnvironmentAsync(); + + if (!_startupResult.IsWindowsVersionValid) + { + ShowError($"Unsupported Windows version detected: {_startupResult.OsVersionString}"); + return false; + } + + if (!_startupResult.FfmpegFound) + { + ConfigScreenMessage = "FFmpeg was not found. Set a valid FFmpegPath before continuing."; + CurrentScreen = AppScreen.Config; + StatusMessage = ConfigScreenMessage; + return false; + } + + if (_startupResult.RulesJsonExists && !_startupResult.RulesJsonValid) + { + ShowError( + $"rules.json is invalid: {_startupResult.RulesJsonError}. Fix rules.json and retry startup."); + return false; + } + + if (_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing) + { + AppendLog( + "Widevine device files are missing. Fallback decrypt services will be used for DRM protected videos."); + } + + return true; + } + + private async Task TryLoadAndValidateExistingAuthAsync() + { + bool loadedFromFile = await authService.LoadFromFileAsync(); + if (!loadedFromFile) + { + IsAuthenticated = false; + AppendLog("No valid auth.json found."); + return false; + } + + return await ValidateCurrentAuthAsync(); + } + + private async Task ValidateCurrentAuthAsync() + { + authService.ValidateCookieString(); + UserEntities.User? user = await authService.ValidateAuthAsync(); + if (user == null || (string.IsNullOrWhiteSpace(user.Name) && string.IsNullOrWhiteSpace(user.Username))) + { + authService.CurrentAuth = null; + IsAuthenticated = false; + if (File.Exists("auth.json") && !configService.CurrentConfig.DisableBrowserAuth) + { + File.Delete("auth.json"); + } + + AppendLog("Auth validation failed."); + return false; + } + + string displayName = !string.IsNullOrWhiteSpace(user.Name) ? user.Name : "Unknown Name"; + string displayUsername = !string.IsNullOrWhiteSpace(user.Username) ? user.Username : "Unknown Username"; + AuthenticatedUserDisplay = $"{displayName} ({displayUsername})"; + IsAuthenticated = true; + AppendLog($"Authenticated as {AuthenticatedUserDisplay}."); + return true; + } + + private async Task LoadUsersAndListsAsync() + { + SetLoading("Fetching users and user lists..."); + UserListResult listResult = await downloadOrchestrationService.GetAvailableUsersAsync(); + + _allUsers = listResult.Users.OrderBy(pair => pair.Key).ToDictionary(pair => pair.Key, pair => pair.Value); + _allLists = listResult.Lists.OrderBy(pair => pair.Key).ToDictionary(pair => pair.Key, pair => pair.Value); + + foreach (SelectableUserViewModel user in AvailableUsers) + { + user.PropertyChanged -= OnSelectableUserPropertyChanged; + } + + AvailableUsers.Clear(); + foreach (KeyValuePair user in _allUsers) + { + SelectableUserViewModel userViewModel = new(user.Key, user.Value); + userViewModel.PropertyChanged += OnSelectableUserPropertyChanged; + AvailableUsers.Add(userViewModel); + } + OnPropertyChanged(nameof(SelectedUsersSummary)); + + UserLists.Clear(); + foreach (string listName in _allLists.Keys) + { + UserLists.Add(listName); + } + + SelectedListName = null; + + if (!string.IsNullOrWhiteSpace(listResult.IgnoredListError)) + { + AppendLog(listResult.IgnoredListError); + } + + CurrentScreen = AppScreen.UserSelection; + StatusMessage = $"Loaded {_allUsers.Count} users and {_allLists.Count} lists."; + AppendLog(StatusMessage); + DownloadSelectedCommand.NotifyCanExecuteChanged(); + DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); + SelectUsersFromListCommand.NotifyCanExecuteChanged(); + RefreshUsersCommand.NotifyCanExecuteChanged(); + } + + private bool TryBuildConfig(out Config config) + { + config = CloneConfig(configService.CurrentConfig); + ClearSpecialConfigErrors(); + Dictionary parsedValues = new(StringComparer.Ordinal); + Dictionary fieldMap = ConfigFields + .ToDictionary(field => field.PropertyName, field => field, StringComparer.Ordinal); + + foreach (ConfigFieldViewModel field in ConfigFields) + { + field.ClearError(); + if (!field.TryGetTypedValue(out object? value, out string? error)) + { + field.SetError(error ?? "Invalid value."); + continue; + } + + parsedValues[field.PropertyName] = value; + } + + bool hasFieldErrors = ConfigFields.Any(field => field.HasError); + if (hasFieldErrors) + { + return false; + } + + foreach (ConfigFieldViewModel field in ConfigFields) + { + if (!parsedValues.TryGetValue(field.PropertyName, out object? value)) + { + continue; + } + + field.PropertyInfo.SetValue(config, value); + } + + ApplySpecialConfigValues(config); + ValidateSpecialConfigValues(); + if (HasSpecialConfigErrors()) + { + return false; + } + + IReadOnlyDictionary validationErrors = ConfigValidationService.Validate(config); + foreach (KeyValuePair error in validationErrors) + { + if (error.Key == nameof(Config.FFmpegPath)) + { + FfmpegPathError = error.Value; + continue; + } + + if (error.Key == nameof(Config.DownloadPath)) + { + DownloadPathError = error.Value; + continue; + } + + if (fieldMap.TryGetValue(error.Key, out ConfigFieldViewModel? field)) + { + field.SetError(error.Value); + continue; + } + + if (error.Key.StartsWith($"{nameof(Config.CreatorConfigs)}.", StringComparison.Ordinal) && + fieldMap.TryGetValue(nameof(Config.CreatorConfigs), out ConfigFieldViewModel? creatorConfigsField)) + { + creatorConfigsField.SetError(error.Value); + } + } + + return !ConfigFields.Any(field => field.HasError) && !HasSpecialConfigErrors(); + } + + private void BuildConfigFields(Config config) + { + ConfigFields.Clear(); + ConfigCategories.Clear(); + BuildSpecialConfigInputs(config); + + IEnumerable properties = typeof(Config) + .GetProperties() + .Where(property => property.CanRead && property.CanWrite) + .Where(property => !IsHiddenConfigField(property.Name)) + .OrderBy(property => property.Name); + + foreach (System.Reflection.PropertyInfo property in properties) + { + object? value = property.GetValue(config); + ConfigFields.Add(new ConfigFieldViewModel(property, value)); + } + + IEnumerable> grouped = ConfigFields + .GroupBy(field => GetConfigCategory(field.PropertyName)) + .OrderBy(group => GetCategoryOrder(group.Key)) + .ThenBy(group => group.Key); + + foreach (IGrouping group in grouped) + { + ConfigCategories.Add(new ConfigCategoryViewModel(group.Key, group.OrderBy(field => field.DisplayName))); + } + } + + private void BuildSpecialConfigInputs(Config config) + { + UnsubscribeSpecialSelectionEvents(); + FfmpegPath = NormalizePathForDisplay(config.FFmpegPath); + DownloadPath = ResolveDownloadPathForDisplay(config.DownloadPath); + ClearSpecialConfigErrors(); + + PopulateSelectionOptions(MediaTypeOptions, s_mediaTypeOptions, config); + PopulateSelectionOptions(MediaSourceOptions, s_mediaSourceOptions, config); + SubscribeSpecialSelectionEvents(); + } + + private static void PopulateSelectionOptions( + ObservableCollection 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))); + } + } + + private static bool GetBooleanConfigValue(Config config, string propertyName) + { + System.Reflection.PropertyInfo? property = typeof(Config).GetProperty(propertyName); + if (property == null) + { + return false; + } + + return property.GetValue(config) is bool currentValue && currentValue; + } + + private void ApplySpecialConfigValues(Config config) + { + string normalizedFfmpegPath = NormalizePathForDisplay(FfmpegPath); + config.FFmpegPath = string.IsNullOrWhiteSpace(normalizedFfmpegPath) + ? string.Empty + : EscapePathForConfig(normalizedFfmpegPath); + + string normalizedDownloadPath = NormalizePathForDisplay(DownloadPath); + config.DownloadPath = string.IsNullOrWhiteSpace(normalizedDownloadPath) + ? EscapePathForConfig(s_defaultDownloadPath) + : EscapePathForConfig(normalizedDownloadPath); + + ApplySelectionOptionsToConfig(config, MediaTypeOptions); + ApplySelectionOptionsToConfig(config, MediaSourceOptions); + } + + private static void ApplySelectionOptionsToConfig( + Config config, + IEnumerable options) + { + foreach (MultiSelectOptionViewModel option in options) + { + System.Reflection.PropertyInfo? property = typeof(Config).GetProperty(option.PropertyName); + if (property?.PropertyType == typeof(bool)) + { + property.SetValue(config, option.IsSelected); + } + } + } + + private void ValidateSpecialConfigValues() + { + if (!MediaTypeOptions.Any(option => option.IsSelected)) + { + MediaTypesError = "Select at least one media type."; + } + + if (!MediaSourceOptions.Any(option => option.IsSelected)) + { + MediaSourcesError = "Select at least one source."; + } + } + + private void ClearSpecialConfigErrors() + { + FfmpegPathError = string.Empty; + DownloadPathError = string.Empty; + MediaTypesError = string.Empty; + MediaSourcesError = string.Empty; + } + + private bool HasSpecialConfigErrors() => + HasFfmpegPathError || HasDownloadPathError || HasMediaTypesError || HasMediaSourcesError; + + private static string ResolveDownloadPathForDisplay(string? configuredPath) + { + string normalized = NormalizePathForDisplay(configuredPath); + return string.IsNullOrWhiteSpace(normalized) ? s_defaultDownloadPath : normalized; + } + + private static string NormalizePathForDisplay(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + string normalized = path.Trim(); + if (!normalized.Contains(@"\\", StringComparison.Ordinal)) + { + return normalized; + } + + if (normalized.StartsWith(@"\\", StringComparison.Ordinal)) + { + return @"\\" + normalized[2..].Replace(@"\\", @"\"); + } + + return normalized.Replace(@"\\", @"\"); + } + + private static string EscapePathForConfig(string path) => + path.Replace(@"\", @"\\"); + + private 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)); + DownloadSelectedCommand.NotifyCanExecuteChanged(); + } + } + + private void StartDownloadProgress(string description, long maxValue, bool showSize) + { + Dispatcher.UIThread.Post(() => + { + DownloadProgressDescription = description; + DownloadProgressMaximum = Math.Max(1, maxValue); + DownloadProgressValue = 0; + IsDownloadProgressIndeterminate = maxValue <= 0; + IsDownloadProgressVisible = true; + }); + } + + private void IncrementDownloadProgress(long increment) + { + Dispatcher.UIThread.Post(() => + { + if (IsDownloadProgressIndeterminate) + { + return; + } + + DownloadProgressValue = Math.Min(DownloadProgressMaximum, DownloadProgressValue + increment); + }); + } + + private void UpdateProgressStatus(string message) + { + Dispatcher.UIThread.Post(() => + { + if (IsDownloadProgressVisible) + { + DownloadProgressDescription = message; + } + }); + } + + private void StopDownloadProgress() + { + Dispatcher.UIThread.Post(() => + { + DownloadProgressDescription = string.Empty; + DownloadProgressValue = 0; + DownloadProgressMaximum = 1; + IsDownloadProgressIndeterminate = false; + IsDownloadProgressVisible = false; + }); + } + + private void ThrowIfStopRequested() + { + if (_workCancellationSource?.IsCancellationRequested == true) + { + throw new OperationCanceledException("Operation canceled by user."); + } + } + + private static Config CloneConfig(Config source) + { + string json = JsonConvert.SerializeObject(source); + return JsonConvert.DeserializeObject(json) ?? new Config(); + } + + private static bool IsHiddenConfigField(string propertyName) => + propertyName is nameof(Config.NonInteractiveMode) + or nameof(Config.NonInteractiveModeListName) + or nameof(Config.NonInteractiveModePurchasedTab) + or nameof(Config.DisableBrowserAuth) + or nameof(Config.FFmpegPath) + or nameof(Config.DownloadPath) + or nameof(Config.DownloadVideos) + or nameof(Config.DownloadImages) + or nameof(Config.DownloadAudios) + or nameof(Config.DownloadAvatarHeaderPhoto) + or nameof(Config.DownloadPaidPosts) + or nameof(Config.DownloadPosts) + or nameof(Config.DownloadArchived) + or nameof(Config.DownloadStreams) + or nameof(Config.DownloadStories) + or nameof(Config.DownloadHighlights) + or nameof(Config.DownloadMessages) + or nameof(Config.DownloadPaidMessages); + + private static string GetConfigCategory(string propertyName) => + propertyName switch + { + nameof(Config.DisableBrowserAuth) => "Auth", + nameof(Config.FFmpegPath) => "External", + + nameof(Config.DownloadAvatarHeaderPhoto) => "Download Media Types", + nameof(Config.DownloadPaidPosts) => "Download Media Types", + nameof(Config.DownloadPosts) => "Download Media Types", + nameof(Config.DownloadArchived) => "Download Media Types", + nameof(Config.DownloadStreams) => "Download Media Types", + nameof(Config.DownloadStories) => "Download Media Types", + nameof(Config.DownloadHighlights) => "Download Media Types", + nameof(Config.DownloadMessages) => "Download Media Types", + nameof(Config.DownloadPaidMessages) => "Download Media Types", + nameof(Config.DownloadImages) => "Download Media Types", + nameof(Config.DownloadVideos) => "Download Media Types", + nameof(Config.DownloadAudios) => "Download Media Types", + + nameof(Config.IgnoreOwnMessages) => "Download Behavior", + nameof(Config.DownloadPostsIncrementally) => "Download Behavior", + nameof(Config.BypassContentForCreatorsWhoNoLongerExist) => "Download Behavior", + nameof(Config.DownloadDuplicatedMedia) => "Download Behavior", + nameof(Config.SkipAds) => "Download Behavior", + nameof(Config.DownloadPath) => "Download Behavior", + nameof(Config.DownloadOnlySpecificDates) => "Download Behavior", + nameof(Config.DownloadDateSelection) => "Download Behavior", + nameof(Config.CustomDate) => "Download Behavior", + nameof(Config.ShowScrapeSize) => "Download Behavior", + nameof(Config.DisableTextSanitization) => "Download Behavior", + nameof(Config.DownloadVideoResolution) => "Download Behavior", + + nameof(Config.PaidPostFileNameFormat) => "File Naming", + nameof(Config.PostFileNameFormat) => "File Naming", + nameof(Config.PaidMessageFileNameFormat) => "File Naming", + nameof(Config.MessageFileNameFormat) => "File Naming", + nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected) => "File Naming", + nameof(Config.CreatorConfigs) => "File Naming", + + nameof(Config.FolderPerPaidPost) => "Folder Structure", + nameof(Config.FolderPerPost) => "Folder Structure", + nameof(Config.FolderPerPaidMessage) => "Folder Structure", + nameof(Config.FolderPerMessage) => "Folder Structure", + + nameof(Config.IncludeExpiredSubscriptions) => "Subscriptions", + nameof(Config.IncludeRestrictedSubscriptions) => "Subscriptions", + nameof(Config.IgnoredUsersListName) => "Subscriptions", + + nameof(Config.Timeout) => "Performance", + nameof(Config.LimitDownloadRate) => "Performance", + nameof(Config.DownloadLimitInMbPerSec) => "Performance", + + nameof(Config.LoggingLevel) => "Logging", + + _ => "Other" + }; + + private static int GetCategoryOrder(string categoryName) => + categoryName switch + { + "Auth" => 0, + "External" => 1, + "Download Media Types" => 2, + "Download Behavior" => 3, + "File Naming" => 4, + "Folder Structure" => 5, + "Subscriptions" => 6, + "Performance" => 7, + "Logging" => 8, + _ => 100 + }; +} diff --git a/OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs b/OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs new file mode 100644 index 0000000..d79e643 --- /dev/null +++ b/OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs @@ -0,0 +1,19 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace OF_DL.Gui.ViewModels; + +public partial class MultiSelectOptionViewModel : ViewModelBase +{ + public MultiSelectOptionViewModel(string displayName, string propertyName, bool isSelected) + { + DisplayName = displayName; + PropertyName = propertyName; + IsSelected = isSelected; + } + + public string DisplayName { get; } + + public string PropertyName { get; } + + [ObservableProperty] private bool _isSelected; +} diff --git a/OF DL.Gui/ViewModels/SelectableUserViewModel.cs b/OF DL.Gui/ViewModels/SelectableUserViewModel.cs new file mode 100644 index 0000000..1a62801 --- /dev/null +++ b/OF DL.Gui/ViewModels/SelectableUserViewModel.cs @@ -0,0 +1,12 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace OF_DL.Gui.ViewModels; + +public partial class SelectableUserViewModel(string username, long userId) : ViewModelBase +{ + public string Username { get; } = username; + + public long UserId { get; } = userId; + + [ObservableProperty] private bool _isSelected; +} diff --git a/OF DL.Gui/ViewModels/ViewModelBase.cs b/OF DL.Gui/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..63c3c96 --- /dev/null +++ b/OF DL.Gui/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace OF_DL.Gui.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/OF DL.Gui/Views/MainWindow.axaml b/OF DL.Gui/Views/MainWindow.axaml new file mode 100644 index 0000000..77a45ff --- /dev/null +++ b/OF DL.Gui/Views/MainWindow.axaml @@ -0,0 +1,394 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +