Initial demo GUI

This commit is contained in:
whimsical-c4lic0 2026-02-13 03:38:44 -06:00
parent aee920a9f1
commit ec8bf47de5
23 changed files with 2421 additions and 6 deletions

View File

@ -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.

View File

@ -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)

9
OF DL.Gui/App.axaml Normal file
View File

@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fluent="clr-namespace:Avalonia.Themes.Fluent;assembly=Avalonia.Themes.Fluent"
RequestedThemeVariant="Light"
x:Class="OF_DL.Gui.App">
<Application.Styles>
<fluent:FluentTheme />
</Application.Styles>
</Application>

28
OF DL.Gui/App.axaml.cs Normal file
View File

@ -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>();
mainWindow.DataContext = _serviceProvider.GetRequiredService<MainWindowViewModel>();
desktop.MainWindow = mainWindow;
}
base.OnFrameworkInitializationCompleted();
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>OF_DL.Gui</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OF DL.Core\OF DL.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.10"/>
<PackageReference Include="Avalonia.Desktop" Version="11.0.10"/>
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.10"/>
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.10"/>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3"/>
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.10"/>
</ItemGroup>
</Project>

16
OF DL.Gui/Program.cs Normal file
View File

@ -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<App>()
.UsePlatformDetect()
.LogToTrace();
}

View File

@ -0,0 +1,103 @@
using OF_DL.Models.Downloads;
using OF_DL.Services;
namespace OF_DL.Gui.Services;
internal sealed class AvaloniaDownloadEventHandler(
Action<string> activitySink,
Action<string> progressStatusUpdate,
Action<string, long, bool> progressStart,
Action<long> progressIncrement,
Action progressStop,
Func<bool> isCancellationRequested) : IDownloadEventHandler
{
public async Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work)
{
ThrowIfCancellationRequested();
progressStart(statusMessage, 0, false);
try
{
AvaloniaStatusReporter statusReporter = new(progressStatusUpdate, isCancellationRequested);
return await work(statusReporter);
}
finally
{
progressStop();
}
}
public async Task<T> WithProgressAsync<T>(string description, long maxValue, bool showSize,
Func<IProgressReporter, Task<T>> 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.");
}
}
}

View File

@ -0,0 +1,21 @@
using OF_DL.Services;
namespace OF_DL.Gui.Services;
internal sealed class AvaloniaProgressReporter(
Action<long> reportAction,
Func<bool> isCancellationRequested) : IProgressReporter
{
public void ReportProgress(long increment)
{
if (isCancellationRequested())
{
throw new OperationCanceledException("Operation canceled by user.");
}
if (increment > 0)
{
reportAction(increment);
}
}
}

View File

@ -0,0 +1,18 @@
using OF_DL.Services;
namespace OF_DL.Gui.Services;
internal sealed class AvaloniaStatusReporter(
Action<string> statusAction,
Func<bool> isCancellationRequested) : IStatusReporter
{
public void ReportStatus(string message)
{
if (isCancellationRequested())
{
throw new OperationCanceledException("Operation canceled by user.");
}
statusAction(message);
}
}

View File

@ -0,0 +1,84 @@
using OF_DL.Models.Config;
namespace OF_DL.Gui.Services;
internal static class ConfigValidationService
{
public static IReadOnlyDictionary<string, string> Validate(Config config)
{
Dictionary<string, string> 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<string, CreatorConfig> 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<string, string> 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<string, string> 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.";
}
}
}

View File

@ -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<ILoggingService, LoggingService>();
services.AddSingleton<IConfigService, ConfigService>();
services.AddSingleton<IAuthService, AuthService>();
services.AddSingleton<IApiService, ApiService>();
services.AddSingleton<IDbService, DbService>();
services.AddSingleton<IDownloadService, DownloadService>();
services.AddSingleton<IFileNameService, FileNameService>();
services.AddSingleton<IStartupService, StartupService>();
services.AddSingleton<IDownloadOrchestrationService, DownloadOrchestrationService>();
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<MainWindow>();
return services;
}
}

View File

@ -0,0 +1,10 @@
namespace OF_DL.Gui.ViewModels;
public enum AppScreen
{
Loading,
Config,
Auth,
UserSelection,
Error
}

View File

@ -0,0 +1,22 @@
using System.Collections.ObjectModel;
namespace OF_DL.Gui.ViewModels;
public sealed class ConfigCategoryViewModel : ViewModelBase
{
public ConfigCategoryViewModel(string categoryName, IEnumerable<ConfigFieldViewModel> 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<ConfigFieldViewModel> Fields { get; } = [];
}

View File

@ -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<string, CreatorConfig>);
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<string> 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<string, CreatorConfig>))
{
if (string.IsNullOrWhiteSpace(TextValue))
{
value = new Dictionary<string, CreatorConfig>();
return true;
}
try
{
Dictionary<string, CreatorConfig>? parsed =
JsonConvert.DeserializeObject<Dictionary<string, CreatorConfig>>(TextValue);
value = parsed ?? new Dictionary<string, CreatorConfig>();
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<string, CreatorConfig>))
{
Dictionary<string, CreatorConfig> creatorConfigs =
initialValue as Dictionary<string, CreatorConfig> ?? new Dictionary<string, CreatorConfig>();
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()));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace OF_DL.Gui.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View File

@ -0,0 +1,394 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:OF_DL.Gui.ViewModels"
x:Class="OF_DL.Gui.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="1320"
Height="860"
MinWidth="900"
MinHeight="700"
Title="OF DL"
Background="#EEF3FB"
mc:Ignorable="d">
<Window.Styles>
<Style Selector="Border.surface">
<Setter Property="Background" Value="#FFFFFF" />
<Setter Property="BorderBrush" Value="#DDE5F3" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="#2E6EEA" />
<Setter Property="Foreground" Value="#FFFFFF" />
<Setter Property="Padding" Value="14,8" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.secondary">
<Setter Property="Background" Value="#FFFFFF" />
<Setter Property="Foreground" Value="#1F2A44" />
<Setter Property="Padding" Value="14,8" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="BorderBrush" Value="#CFD9EB" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,Auto,*">
<Menu Grid.Row="0">
<MenuItem Header="_File">
<MenuItem Header="_Logout"
IsVisible="{Binding IsAuthenticated}"
Command="{Binding LogoutCommand}" />
<MenuItem Header="E_xit" Command="{Binding ExitApplicationCommand}" />
</MenuItem>
<MenuItem Header="_Edit">
<MenuItem Header="_Refresh Users" Command="{Binding RefreshUsersCommand}" />
<MenuItem Header="Edit _Config" Command="{Binding EditConfigCommand}" />
</MenuItem>
</Menu>
<Border Grid.Row="1"
Padding="16"
BorderThickness="0,0,0,1"
BorderBrush="#CFD9EB"
Background="#DDEAFF">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0"
Foreground="#304261"
FontWeight="SemiBold"
Text="{Binding AuthenticatedUserDisplay}"
TextWrapping="Wrap"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="8"
HorizontalAlignment="Right">
<Button IsVisible="{Binding IsDownloading}"
Classes="primary"
Background="#D84E4E"
Command="{Binding StopWorkCommand}"
Content="Stop" />
<Button Classes="secondary"
IsVisible="{Binding IsAuthenticated}"
Command="{Binding LogoutCommand}"
Content="Logout" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="2" Margin="14">
<StackPanel IsVisible="{Binding IsLoadingScreen}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12"
Width="420">
<TextBlock HorizontalAlignment="Center"
FontSize="22"
FontWeight="SemiBold"
Text="Loading" />
<ProgressBar IsIndeterminate="True" Height="14" />
<TextBlock HorizontalAlignment="Center" Text="{Binding LoadingMessage}" TextWrapping="Wrap" />
</StackPanel>
<ScrollViewer IsVisible="{Binding IsConfigScreen}">
<StackPanel Spacing="14" Margin="2,0,4,0">
<TextBlock FontSize="22" FontWeight="SemiBold" Text="Configuration" />
<Grid ColumnDefinitions="3*,5*" Margin="0,0,0,2">
<Border Grid.Column="0" Classes="surface" Padding="12" Margin="0,0,10,0">
<StackPanel Spacing="8">
<TextBlock FontSize="16" FontWeight="Bold" Foreground="#1F2A44" Text="External" />
<TextBlock FontWeight="SemiBold"
Foreground="#1F2A44"
Text="FFmpeg Path" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
VerticalAlignment="Center"
Text="{Binding FfmpegPath}"
Watermark="Select ffmpeg executable" />
<Button Grid.Column="1"
Margin="8,0,0,0"
Classes="secondary"
Content="Browse..."
Click="OnBrowseFfmpegPathClick" />
</Grid>
<TextBlock IsVisible="{Binding HasFfmpegPathError}"
Foreground="#FF5A5A"
Text="{Binding FfmpegPathError}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Grid.Column="1" Classes="surface" Padding="12">
<StackPanel Spacing="8">
<TextBlock FontSize="16"
FontWeight="Bold"
Foreground="#1F2A44"
Text="Download Media Types" />
<StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Foreground="#1F2A44" Text="Media Types" />
<ItemsControl ItemsSource="{Binding MediaTypeOptions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MultiSelectOptionViewModel">
<CheckBox Margin="0,0,14,6"
Content="{Binding DisplayName}"
IsChecked="{Binding IsSelected}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock IsVisible="{Binding HasMediaTypesError}"
Foreground="#FF5A5A"
Text="{Binding MediaTypesError}"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Foreground="#1F2A44" Text="Sources" />
<ItemsControl ItemsSource="{Binding MediaSourceOptions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MultiSelectOptionViewModel">
<CheckBox Margin="0,0,14,6"
Content="{Binding DisplayName}"
IsChecked="{Binding IsSelected}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock IsVisible="{Binding HasMediaSourcesError}"
Foreground="#FF5A5A"
Text="{Binding MediaSourcesError}"
TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
</Border>
</Grid>
<Border Classes="surface" Padding="12">
<ItemsControl ItemsSource="{Binding ConfigCategories}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConfigCategoryViewModel">
<Border Classes="surface"
Width="600"
Margin="0,0,12,12"
Padding="10">
<StackPanel Spacing="8">
<TextBlock FontSize="16"
FontWeight="Bold"
Foreground="#1F2A44"
Text="{Binding CategoryName}" />
<StackPanel IsVisible="{Binding IsDownloadBehavior}"
Spacing="4"
Margin="0,0,0,10"
x:CompileBindings="False">
<TextBlock FontWeight="SemiBold"
Foreground="#1F2A44"
Text="Download Path" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
VerticalAlignment="Center"
HorizontalAlignment="Stretch"
Text="{Binding DataContext.DownloadPath, RelativeSource={RelativeSource AncestorType=Window}}"
Watermark="Select download folder" />
<Button Grid.Column="1"
Margin="8,0,0,0"
Classes="secondary"
Content="Browse..."
Click="OnBrowseDownloadPathClick" />
</Grid>
<TextBlock IsVisible="{Binding DataContext.HasDownloadPathError, RelativeSource={RelativeSource AncestorType=Window}}"
Foreground="#FF5A5A"
Text="{Binding DataContext.DownloadPathError, RelativeSource={RelativeSource AncestorType=Window}}"
TextWrapping="Wrap" />
</StackPanel>
<ItemsControl ItemsSource="{Binding Fields}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConfigFieldViewModel">
<Grid Margin="0,0,0,10" ColumnDefinitions="190,*">
<TextBlock Grid.Column="0"
Margin="0,6,10,0"
FontWeight="SemiBold"
Foreground="#1F2A44"
Text="{Binding DisplayName}"
TextWrapping="Wrap" />
<StackPanel Grid.Column="1" Spacing="4">
<CheckBox IsVisible="{Binding IsBoolean}"
IsChecked="{Binding BoolValue}" />
<ComboBox IsVisible="{Binding IsEnum}"
HorizontalAlignment="Stretch"
ItemsSource="{Binding EnumOptions}"
SelectedItem="{Binding EnumValue}" />
<DatePicker IsVisible="{Binding IsDate}"
HorizontalAlignment="Stretch"
SelectedDate="{Binding DateValue}" />
<NumericUpDown IsVisible="{Binding IsNumeric}"
HorizontalAlignment="Stretch"
Value="{Binding NumericValue}"
Increment="1"
FormatString="N0" />
<TextBox IsVisible="{Binding IsTextInput}"
HorizontalAlignment="Stretch"
AcceptsReturn="{Binding IsMultiline}"
TextWrapping="Wrap"
MinHeight="{Binding TextBoxMinHeight}"
Text="{Binding TextValue}" />
<TextBlock IsVisible="{Binding HasError}"
Foreground="#FF5A5A"
Text="{Binding ErrorMessage}"
TextWrapping="Wrap" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Content="Save Configuration"
Classes="primary"
Command="{Binding SaveConfigCommand}" />
<Button Content="Cancel"
Classes="secondary"
Command="{Binding CancelConfigCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
<StackPanel IsVisible="{Binding IsAuthScreen}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="640"
Spacing="14">
<TextBlock HorizontalAlignment="Center"
FontSize="24"
FontWeight="SemiBold"
Text="Authentication Required" />
<TextBlock TextWrapping="Wrap"
TextAlignment="Center"
HorizontalAlignment="Center"
Text="{Binding AuthScreenMessage}" />
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
<Button Content="Login with Browser"
Classes="primary"
Command="{Binding StartBrowserLoginCommand}" />
</StackPanel>
</StackPanel>
<Grid IsVisible="{Binding IsUserSelectionScreen}" RowDefinitions="Auto,*,Auto" ColumnDefinitions="2*,3*">
<Border Grid.Row="0"
Grid.ColumnSpan="2"
Padding="10"
Margin="0,0,0,10"
Classes="surface">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Content="Select All" Classes="secondary" Command="{Binding SelectAllUsersCommand}" />
<Button Content="Select None" Classes="secondary" Command="{Binding SelectNoUsersCommand}" />
<ComboBox Width="280"
PlaceholderText="Select a list of users"
ItemsSource="{Binding UserLists}"
SelectedItem="{Binding SelectedListName}" />
<Button Content="Download Purchased Tab"
Classes="secondary"
Command="{Binding DownloadPurchasedTabCommand}" />
<Button Content="Download Selected"
Classes="primary"
Command="{Binding DownloadSelectedCommand}" />
</StackPanel>
</Border>
<Border Grid.Row="1"
Grid.Column="0"
Margin="0,0,8,0"
Padding="10"
Classes="surface">
<Grid RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock FontWeight="SemiBold" Foreground="#1F2A44" Text="Users" />
<TextBlock Foreground="#4A5B78" Text="{Binding SelectedUsersSummary}" />
</StackPanel>
<ListBox Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding AvailableUsers}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectableUserViewModel">
<CheckBox Content="{Binding Username}" IsChecked="{Binding IsSelected}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
<Border Grid.Row="1"
Grid.Column="1"
Margin="8,0,0,0"
Padding="10"
Classes="surface">
<Grid RowDefinitions="Auto,*">
<TextBlock Grid.Row="0" FontWeight="SemiBold" Foreground="#1F2A44" Text="Activity Log" />
<ListBox Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding ActivityLog}" />
</Grid>
</Border>
<Border Grid.Row="2"
Grid.ColumnSpan="2"
Margin="0,10,0,0"
Padding="10"
Classes="surface"
IsVisible="{Binding IsDownloadProgressVisible}">
<StackPanel Spacing="8">
<TextBlock FontWeight="SemiBold" Foreground="#1F2A44"
Text="{Binding DownloadProgressDescription}" />
<ProgressBar IsIndeterminate="{Binding IsDownloadProgressIndeterminate}"
Minimum="0"
Maximum="{Binding DownloadProgressMaximum}"
Value="{Binding DownloadProgressValue}" />
</StackPanel>
</Border>
</Grid>
<StackPanel IsVisible="{Binding IsErrorScreen}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="640"
Spacing="14">
<TextBlock HorizontalAlignment="Center"
FontSize="24"
FontWeight="SemiBold"
Text="Startup Error" />
<TextBlock TextWrapping="Wrap" Text="{Binding ErrorMessage}" />
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
<Button Content="Retry Startup" Classes="primary" Command="{Binding RetryStartupCommand}" />
</StackPanel>
</StackPanel>
</Grid>
</Grid>
</Window>

View File

@ -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<IStorageFile> 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<IStorageFolder> 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);
}
}

View File

@ -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

View File

@ -15,6 +15,7 @@
<ItemGroup>
<ProjectReference Include="..\OF DL.Core\OF DL.Core.csproj"/>
<ProjectReference Include="..\OF DL.Gui\OF DL.Gui.csproj"/>
</ItemGroup>
<ItemGroup>

View File

@ -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<IAuthService>();
@ -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<bool> 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<ILoggingService, LoggingService>();
services.AddSingleton<IConfigService, ConfigService>();
await using ServiceProvider provider = services.BuildServiceProvider();
IConfigService configService = provider.GetRequiredService<IConfigService>();
bool loaded = await configService.LoadConfigurationAsync(args);
if (!loaded)
{
return false;
}
return configService.IsCliNonInteractive || configService.CurrentConfig.NonInteractiveMode;
}
private static async Task<ServiceCollection> ConfigureServices(string[] args)
{
// Set up dependency injection with LoggingService and ConfigService