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. 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 This document is for AI agents helping developers modify the project. It focuses on architecture, data flow, and the
most important change points. most important change points.
## Quick Flow ## 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. 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. 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. 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. 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. 6. `DownloadOrchestrationService` selects creators, prepares folders/DBs, and calls `DownloadService` per media type.
7. `DownloadService` downloads media, handles DRM, and records metadata in SQLite. 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 ## 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/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/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, - `OF DL.Core/Models/` holds configuration, auth, API request/response models, downloads/startup results, DTOs,
entities, and mapping helpers. entities, and mapping helpers.
@ -86,19 +90,25 @@ most important change points.
## Execution and Testing ## 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: - Build from the repo root:
```bash ```bash
dotnet build OF DL.sln dotnet build OF DL.sln
``` ```
- Run from source (runtime files are read from the current working directory): - Run from source (GUI mode, default):
```bash ```bash
dotnet run --project "OF DL/OF DL.csproj" 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. - If you want a local `rules.json` fallback, run from `OF DL/` or copy `OF DL/rules.json` into your working directory.
- Run tests: - Run tests:
@ -220,6 +230,8 @@ cookies/user-agent. Output is written to `{filename}_source.mp4`, then moved and
## Where to Look First ## Where to Look First
- `OF DL/Program.cs` for the execution path and menu flow. - `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/ApiService.cs` for OF API calls and header signing.
- `OF DL.Core/Services/DownloadService.cs` for downloads and DRM handling. - `OF DL.Core/Services/DownloadService.cs` for downloads and DRM handling.
- `OF DL.Core/Services/DownloadOrchestrationService.cs` for creator selection and flow control. - `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)) string cookies = string.Join(" ", mappedCookies.Keys.Where(key => _desiredCookies.Contains(key))
.Select(key => $"${key}={mappedCookies[key]};")); .Select(key => $"${key}={mappedCookies[key]};"));
await browser.CloseAsync();
return new Auth { Cookie = cookies, UserAgent = userAgent, UserId = userId, XBc = xBc }; return new Auth { Cookie = cookies, UserAgent = userAgent, UserId = userId, XBc = xBc };
} }
catch (Exception e) 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 EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Tests", "OF DL.Tests\OF DL.Tests.csproj", "{FF5EC4D7-6369-4A78-8C02-E370343E797C}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Tests", "OF DL.Tests\OF DL.Tests.csproj", "{FF5EC4D7-6369-4A78-8C02-E370343E797C}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Gui", "OF DL.Gui\OF DL.Gui.csproj", "{495749B1-DD15-4637-85AA-49841A86A510}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

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

View File

@ -1,6 +1,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using OF_DL.CLI; using OF_DL.CLI;
using OF_DL.Gui;
using OF_DL.Models; using OF_DL.Models;
using OF_DL.Enumerations; using OF_DL.Enumerations;
using OF_DL.Models.Config; using OF_DL.Models.Config;
@ -14,6 +15,9 @@ namespace OF_DL;
public class Program(IServiceProvider serviceProvider) public class Program(IServiceProvider serviceProvider)
{ {
private const string CliFlag = "--cli";
private const string NonInteractiveFlag = "--non-interactive";
private async Task LoadAuthFromBrowser() private async Task LoadAuthFromBrowser()
{ {
IAuthService authService = serviceProvider.GetRequiredService<IAuthService>(); IAuthService authService = serviceProvider.GetRequiredService<IAuthService>();
@ -65,13 +69,23 @@ public class Program(IServiceProvider serviceProvider)
await authService.SaveToFileAsync(); await authService.SaveToFileAsync();
} }
[STAThread]
public static async Task Main(string[] args) 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.Write(new FigletText("Welcome to OF-DL").Color(Color.Red));
AnsiConsole.Markup("Documentation: [link]https://docs.ofdl.tools/[/]\n"); AnsiConsole.Markup("Documentation: [link]https://docs.ofdl.tools/[/]\n");
AnsiConsole.Markup("Discord server: [link]https://discord.com/invite/6bUW8EJ53j[/]\n\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(); ServiceProvider serviceProvider = services.BuildServiceProvider();
// Get the Program instance and run // Get the Program instance and run
@ -79,6 +93,34 @@ public class Program(IServiceProvider serviceProvider)
await program.RunAsync(); 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) private static async Task<ServiceCollection> ConfigureServices(string[] args)
{ {
// Set up dependency injection with LoggingService and ConfigService // Set up dependency injection with LoggingService and ConfigService