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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OF DL.Gui/Views/MainWindow.axaml.cs b/OF DL.Gui/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..f3756e3
--- /dev/null
+++ b/OF DL.Gui/Views/MainWindow.axaml.cs
@@ -0,0 +1,103 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using OF_DL.Gui.ViewModels;
+
+namespace OF_DL.Gui.Views;
+
+public partial class MainWindow : Window
+{
+ private bool _hasInitialized;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ Opened += OnOpened;
+ }
+
+ private async void OnOpened(object? sender, EventArgs e)
+ {
+ if (_hasInitialized)
+ {
+ return;
+ }
+
+ _hasInitialized = true;
+ if (DataContext is MainWindowViewModel vm)
+ {
+ await vm.InitializeAsync();
+ }
+ }
+
+ private async void OnBrowseFfmpegPathClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel vm)
+ {
+ return;
+ }
+
+ TopLevel? topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.StorageProvider == null)
+ {
+ return;
+ }
+
+ IReadOnlyList selectedFiles = await topLevel.StorageProvider.OpenFilePickerAsync(
+ new FilePickerOpenOptions
+ {
+ Title = "Select FFmpeg executable",
+ AllowMultiple = false
+ });
+
+ IStorageFile? selectedFile = selectedFiles.FirstOrDefault();
+ if (selectedFile == null)
+ {
+ return;
+ }
+
+ string? localPath = selectedFile.TryGetLocalPath();
+ if (!string.IsNullOrWhiteSpace(localPath))
+ {
+ vm.SetFfmpegPath(localPath);
+ return;
+ }
+
+ vm.SetFfmpegPath(selectedFile.Name);
+ }
+
+ private async void OnBrowseDownloadPathClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel vm)
+ {
+ return;
+ }
+
+ TopLevel? topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.StorageProvider == null)
+ {
+ return;
+ }
+
+ IReadOnlyList selectedFolders = await topLevel.StorageProvider.OpenFolderPickerAsync(
+ new FolderPickerOpenOptions
+ {
+ Title = "Select download folder",
+ AllowMultiple = false
+ });
+
+ IStorageFolder? selectedFolder = selectedFolders.FirstOrDefault();
+ if (selectedFolder == null)
+ {
+ return;
+ }
+
+ string? localPath = selectedFolder.TryGetLocalPath();
+ if (!string.IsNullOrWhiteSpace(localPath))
+ {
+ vm.SetDownloadPath(localPath);
+ return;
+ }
+
+ vm.SetDownloadPath(selectedFolder.Name);
+ }
+}
diff --git a/OF DL.sln b/OF DL.sln
index 6beeff9..7253651 100644
--- a/OF DL.sln
+++ b/OF DL.sln
@@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Core", "OF DL.Core\OF
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Tests", "OF DL.Tests\OF DL.Tests.csproj", "{FF5EC4D7-6369-4A78-8C02-E370343E797C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Gui", "OF DL.Gui\OF DL.Gui.csproj", "{495749B1-DD15-4637-85AA-49841A86A510}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -27,6 +29,10 @@ Global
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {495749B1-DD15-4637-85AA-49841A86A510}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {495749B1-DD15-4637-85AA-49841A86A510}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {495749B1-DD15-4637-85AA-49841A86A510}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {495749B1-DD15-4637-85AA-49841A86A510}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/OF DL/OF DL.csproj b/OF DL/OF DL.csproj
index f4c4eb8..602daa6 100644
--- a/OF DL/OF DL.csproj
+++ b/OF DL/OF DL.csproj
@@ -15,6 +15,7 @@
+
diff --git a/OF DL/Program.cs b/OF DL/Program.cs
index f43d269..e14c45b 100644
--- a/OF DL/Program.cs
+++ b/OF DL/Program.cs
@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using OF_DL.CLI;
+using OF_DL.Gui;
using OF_DL.Models;
using OF_DL.Enumerations;
using OF_DL.Models.Config;
@@ -14,6 +15,9 @@ namespace OF_DL;
public class Program(IServiceProvider serviceProvider)
{
+ private const string CliFlag = "--cli";
+ private const string NonInteractiveFlag = "--non-interactive";
+
private async Task LoadAuthFromBrowser()
{
IAuthService authService = serviceProvider.GetRequiredService();
@@ -65,13 +69,23 @@ public class Program(IServiceProvider serviceProvider)
await authService.SaveToFileAsync();
}
+ [STAThread]
public static async Task Main(string[] args)
{
+ bool runCli = await ShouldRunCliModeAsync(args);
+ string[] cleanArgs = args.Where(a => !a.Equals(CliFlag, StringComparison.OrdinalIgnoreCase)).ToArray();
+
+ if (!runCli)
+ {
+ GuiLauncher.Run(cleanArgs);
+ return;
+ }
+
AnsiConsole.Write(new FigletText("Welcome to OF-DL").Color(Color.Red));
AnsiConsole.Markup("Documentation: [link]https://docs.ofdl.tools/[/]\n");
AnsiConsole.Markup("Discord server: [link]https://discord.com/invite/6bUW8EJ53j[/]\n\n");
- ServiceCollection services = await ConfigureServices(args);
+ ServiceCollection services = await ConfigureServices(cleanArgs);
ServiceProvider serviceProvider = services.BuildServiceProvider();
// Get the Program instance and run
@@ -79,6 +93,34 @@ public class Program(IServiceProvider serviceProvider)
await program.RunAsync();
}
+ private static async Task ShouldRunCliModeAsync(string[] args)
+ {
+ if (args.Any(a => a.Equals(CliFlag, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ if (args.Any(a => a.Equals(NonInteractiveFlag, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ ServiceCollection services = new();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ await using ServiceProvider provider = services.BuildServiceProvider();
+ IConfigService configService = provider.GetRequiredService();
+
+ bool loaded = await configService.LoadConfigurationAsync(args);
+ if (!loaded)
+ {
+ return false;
+ }
+
+ return configService.IsCliNonInteractive || configService.CurrentConfig.NonInteractiveMode;
+ }
+
private static async Task ConfigureServices(string[] args)
{
// Set up dependency injection with LoggingService and ConfigService