Compare commits

..

No commits in common. "add-initial-gui" and "master" have entirely different histories.

76 changed files with 221 additions and 7837 deletions

View File

@ -29,19 +29,19 @@ jobs:
- name: Build for Windows and Linux
run: |
dotnet publish "OF DL.Cli/OF DL.Cli.csproj" -p:Version=${{ steps.version.outputs.version }} \
dotnet publish "OF DL/OF DL.csproj" -p:Version=${{ steps.version.outputs.version }} \
-p:PackageVersion=${{ steps.version.outputs.version }} \
-p:WarningLevel=0 -c Release -r win-x86 \
--self-contained true -p:PublishSingleFile=true -o outwin
dotnet publish "OF DL.Cli/OF DL.Cli.csproj" -p:Version=${{ steps.version.outputs.version }} \
dotnet publish "OF DL/OF DL.csproj" -p:Version=${{ steps.version.outputs.version }} \
-p:PackageVersion=${{ steps.version.outputs.version }} \
-p:WarningLevel=0 -c Release -r linux-x64 \
--self-contained true -p:PublishSingleFile=true -o outlin
- name: Copy and patch extra files
run: |
cp ./OF\ DL.Cli/rules.json outwin/
cp ./OF\ DL/rules.json outwin/
chmod +x ./outlin/OF\ DL
cd outwin

View File

@ -2,36 +2,30 @@
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# app suite (console + Avalonia desktop GUI) that downloads media
from a user's OnlyFans account(s).
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 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` is the app entrypoint: default launch is GUI; `--cli` switches to CLI mode.
1. `Program.Main` builds DI, loads `config.conf`, and runs the interactive flow.
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.Cli/CLI/` contains Spectre.Console UI helpers and progress reporting (CLI-only).
- `OF DL.Gui/` contains the Avalonia desktop UI (`App`, `MainWindow`, `AboutWindow`, `FaqWindow`, MVVM view models, and
GUI event handlers).
- `OF DL.Gui/Helpers/` contains GUI-specific utility helpers (for example, Docker-aware web-link behavior shared across
windows).
- `OF DL/Program.cs` orchestrates startup, config/auth loading, and the interactive flow (CLI entrypoint).
- `OF DL/CLI/` contains Spectre.Console UI helpers and progress reporting (CLI-only).
- `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.
- `OF DL.Core/Widevine/` implements Widevine CDM handling and key derivation.
- `OF DL.Core/Helpers/`, `OF DL.Core/Utils/`, `OF DL.Core/Crypto/`, `OF DL.Core/Enumerations/` contain shared core
logic. `OF DL.Core/Helpers/EnvironmentHelper.cs` centralizes environment checks (Docker and Windows).
logic.
- `docs/` and `mkdocs.yml` define the documentation site.
- `site/` is generated MkDocs output and should not be edited by hand.
- `docker/` contains container entrypoint and supervisor configuration.
@ -92,27 +86,20 @@ most important change points.
## Execution and Testing
- .NET SDK: 10.x (`net10.0` for all projects).
- .NET SDK: 8.x (`net8.0` for all projects).
- Build from the repo root:
```bash
dotnet build OF DL.sln
```
- Run from source (GUI mode, default):
- Run from source (runtime files are read from the current working directory):
```bash
dotnet run --project "OF DL/OF DL.Cli.csproj"
dotnet run --project "OF DL/OF DL.csproj"
```
- Run CLI mode:
```bash
dotnet run --project "OF DL/OF DL.Cli.csproj" -- --cli
```
- If you want a local `rules.json` fallback, run from `OF DL.Cli/` or copy `OF DL.Cli/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:
```bash
@ -233,16 +220,12 @@ 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.Gui/Views/AboutWindow.axaml` and `OF DL.Gui/Views/FaqWindow.axaml` for Help menu windows.
- `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.
- `OF DL.Core/Widevine/` for CDM key generation and license parsing.
- `OF DL.Core/Models/Config/Config.cs` and `OF DL.Core/Services/ConfigService.cs` for config shape and parsing.
- `OF DL.Core/Services/AuthService.cs` for user-facing authentication behavior and browser login flow.
- `OF DL.Core/Helpers/EnvironmentHelper.cs` for shared Docker/Windows runtime checks.
- `docs/` for public documentation; update docs whenever user-facing behavior or configuration changes.
## Documentation updates for common changes:

View File

@ -4,18 +4,16 @@ ARG VERSION
# Copy source code
COPY ["OF DL.sln", "/src/OF DL.sln"]
COPY ["OF DL.Cli", "/src/OF DL.Cli"]
COPY ["OF DL", "/src/OF DL"]
COPY ["OF DL.Core", "/src/OF DL.Core"]
COPY ["OF DL.Gui", "/src/OF DL.Gui"]
WORKDIR "/src"
# Build release
RUN dotnet publish "OF DL.Gui/OF DL.Gui.csproj" -p:WarningLevel=0 -p:Version=$VERSION -c Release -o outgui
RUN dotnet publish "OF DL.Cli/OF DL.Cli.csproj" -p:WarningLevel=0 -p:Version=$VERSION -c Release -o outcli
RUN dotnet publish "OF DL/OF DL.csproj" -p:WarningLevel=0 -p:Version=$VERSION -c Release -o out
# Generate default config.conf files
RUN /src/outcli/OF\ DL.Cli --non-interactive || true && \
RUN /src/out/OF\ DL --non-interactive || true && \
# Set download path in default config.conf to /data
sed -e 's/DownloadPath = ""/DownloadPath = "\/data"/' /src/config.conf > /src/updated_config.conf && \
mv /src/updated_config.conf /src/config.conf
@ -33,7 +31,6 @@ RUN apt-get update \
x11vnc \
novnc \
npm \
openbox \
&& rm -rf /var/lib/apt/lists/*
RUN npx playwright install-deps
@ -48,12 +45,11 @@ RUN echo "<!DOCTYPE html><html><head><meta http-equiv=\"Refresh\" content=\"0; u
RUN mkdir /data /config /config/logs /default-config
# Copy release
COPY --from=build /src/outcli /app/cli
COPY --from=build /src/outgui /app/gui
COPY --from=build /src/out /app
# Copy default configuration files
COPY --from=build /src/config.conf /default-config
COPY --from=build ["/src/OF DL.Cli/rules.json", "/default-config"]
COPY --from=build ["/src/OF DL/rules.json", "/default-config"]
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/entrypoint.sh /app/entrypoint.sh
@ -68,5 +64,5 @@ ENV DEBIAN_FRONTEND="noninteractive" \
EXPOSE 8080
WORKDIR /config
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
CMD []
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/app/entrypoint.sh"]

View File

@ -1,16 +0,0 @@
# Stealth Script Creation
## Requirements
- NodeJS (with npx CLI tool)
## Instructions
- Open a terminal in this directory (OF DL.Cli/chromium-scripts)
- Run `npx -y extract-stealth-evasions`
- Copy the `stealth.js` file into the other chromium-scripts directory as well (`OF DL.Gui/chromium-scripts/`)
## References
See the readme.md file and source code for the stealth
script [here](https://github.com/berstend/puppeteer-extra/tree/master/packages/extract-stealth-evasions).

View File

@ -1,9 +0,0 @@
// ReSharper disable InconsistentNaming
namespace OF_DL.Enumerations;
public enum Theme
{
light,
dark
}

View File

@ -2,14 +2,6 @@ namespace OF_DL.Helpers;
public static class Constants
{
public const string DiscordInviteUrl = "https://discord.com/invite/6bUW8EJ53j";
public const string DocumentationUrl = "https://docs.ofdl.tools/";
public const string AuthHelperExtensionUrl = "https://github.com/whimsical-c4lic0/OF-DL-Auth-Helper/";
public const string LegacyAuthDocumentationUrl = "https://docs.ofdl.tools/config/auth/#legacy-methods";
public const string ApiUrl = "https://onlyfans.com/api2/v2";
public const int ApiPageSize = 50;

View File

@ -1,14 +0,0 @@
namespace OF_DL.Helpers;
public static class EnvironmentHelper
{
private const string DockerEnvironmentVariableName = "OFDL_DOCKER";
public static bool IsRunningInDocker()
{
string? dockerValue = Environment.GetEnvironmentVariable(DockerEnvironmentVariableName);
return string.Equals(dockerValue, "true", StringComparison.OrdinalIgnoreCase);
}
public static bool IsRunningOnWindows() => OperatingSystem.IsWindows();
}

View File

@ -89,11 +89,6 @@ public class Config : IFileNameFormatConfig
[JsonConverter(typeof(StringEnumConverter))]
public LoggingLevel LoggingLevel { get; set; } = LoggingLevel.Error;
[JsonConverter(typeof(StringEnumConverter))]
public Theme Theme { get; set; } = Theme.dark;
[ToggleableConfig] public bool HideMissingCdmKeysWarning { get; set; }
[ToggleableConfig] public bool IgnoreOwnMessages { get; set; }
[ToggleableConfig] public bool DisableBrowserAuth { get; set; }
@ -107,11 +102,8 @@ public class Config : IFileNameFormatConfig
[ToggleableConfig] public bool DisableTextSanitization { get; set; }
public string? PaidPostFileNameFormat { get; set; } = "";
public string? PostFileNameFormat { get; set; } = "";
public string? PaidMessageFileNameFormat { get; set; } = "";
public string? MessageFileNameFormat { get; set; } = "";
public IFileNameFormatConfig GetCreatorFileNameFormatConfig(string username)

View File

@ -159,9 +159,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// Retrieves user information from the API.
/// </summary>
/// <param name="endpoint">The user endpoint.</param>
/// <param name="cancellationToken"></param>
/// <returns>The user entity when available.</returns>
public async Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default)
public async Task<UserEntities.User?> GetUserInfo(string endpoint)
{
Log.Debug($"Calling GetUserInfo: {endpoint}");
@ -172,7 +171,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
try
{
cancellationToken.ThrowIfCancellationRequested();
UserEntities.User user = new();
Dictionary<string, string> getParams = new()
{
@ -182,7 +180,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
HttpClient client = new();
HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint);
using HttpResponseMessage response = await client.SendAsync(request, cancellationToken);
using HttpResponseMessage response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
@ -238,10 +236,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
return jObject;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
ExceptionLoggerHelper.LogException(ex);
@ -418,13 +412,11 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="endpoint">The endpoint to query.</param>
/// <param name="username">Optional username context.</param>
/// <param name="folder">The creator folder path.</param>
/// <param name="cancellationToken"></param>
/// <returns>A mediaId-to-URL map.</returns>
public async Task<Dictionary<long, string>?> GetMedia(MediaType mediatype,
string endpoint,
string? username,
string folder,
CancellationToken cancellationToken = default)
string folder)
{
Log.Debug($"Calling GetMedia - {username}");
@ -435,7 +427,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
try
{
cancellationToken.ThrowIfCancellationRequested();
Dictionary<long, string> returnUrls = new();
const int limit = 5;
int offset = 0;
@ -639,10 +630,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
return returnUrls;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
ExceptionLoggerHelper.LogException(ex);
@ -660,11 +647,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="username">The creator username.</param>
/// <param name="paidPostIds">A list to collect paid media IDs.</param>
/// <param name="statusReporter">Status reporter.</param>
/// <param name="cancellationToken"></param>
/// <returns>A paid post collection.</returns>
public async Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
string username,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default)
List<long> paidPostIds, IStatusReporter statusReporter)
{
Log.Debug($"Calling GetPaidPosts - {username}");
@ -843,10 +829,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="folder">The creator folder path.</param>
/// <param name="paidPostIds">Paid post media IDs to skip.</param>
/// <param name="statusReporter">Status reporter.</param>
/// <param name="cancellationToken"></param>
/// <returns>A post collection.</returns>
public async Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
IStatusReporter statusReporter)
{
Log.Debug($"Calling GetPosts - {endpoint}");
@ -1033,9 +1018,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// </summary>
/// <param name="endpoint">The post endpoint.</param>
/// <param name="folder">The creator folder path.</param>
/// <param name="cancellationToken"></param>
/// <returns>A single post collection.</returns>
public async Task<SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default)
public async Task<SinglePostCollection> GetPost(string endpoint, string folder)
{
Log.Debug($"Calling GetPost - {endpoint}");
@ -1182,11 +1166,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="folder">The creator folder path.</param>
/// <param name="paidPostIds">Paid post media IDs to skip.</param>
/// <param name="statusReporter">Status reporter.</param>
/// <param name="cancellationToken"></param>
/// <returns>A streams collection.</returns>
public async Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder,
List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
IStatusReporter statusReporter)
{
Log.Debug($"Calling GetStreams - {endpoint}");
@ -1335,10 +1318,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="endpoint">The archived posts endpoint.</param>
/// <param name="folder">The creator folder path.</param>
/// <param name="statusReporter">Status reporter.</param>
/// <param name="cancellationToken"></param>
/// <returns>An archived collection.</returns>
public async Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
IStatusReporter statusReporter)
{
Log.Debug($"Calling GetArchived - {endpoint}");
@ -1490,10 +1472,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="endpoint">The messages endpoint.</param>
/// <param name="folder">The creator folder path.</param>
/// <param name="statusReporter">Status reporter.</param>
/// <param name="cancellationToken"></param>
/// <returns>A message collection.</returns>
public async Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
IStatusReporter statusReporter)
{
Log.Debug($"Calling GetMessages - {endpoint}");
@ -1680,9 +1661,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// </summary>
/// <param name="endpoint">The paid message endpoint.</param>
/// <param name="folder">The creator folder path.</param>
/// <param name="cancellationToken"></param>
/// <returns>A single paid message collection.</returns>
public async Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default)
public async Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder)
{
Log.Debug($"Calling GetPaidMessage - {endpoint}");
@ -1827,11 +1807,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="folder">The creator folder path.</param>
/// <param name="username">The creator username.</param>
/// <param name="statusReporter">Status reporter.</param>
/// <param name="cancellationToken"></param>
/// <returns>A paid message collection.</returns>
public async Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder,
string username,
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
IStatusReporter statusReporter)
{
Log.Debug($"Calling GetPaidMessages - {username}");
@ -2052,9 +2031,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// </summary>
/// <param name="endpoint">The purchased tab endpoint.</param>
/// <param name="users">Known users map.</param>
/// <param name="cancellationToken"></param>
/// <returns>A username-to-userId map.</returns>
public async Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default)
public async Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users)
{
Log.Debug($"Calling GetPurchasedTabUsers - {endpoint}");
@ -2069,7 +2047,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
{ "skip_users", "all" }
};
cancellationToken.ThrowIfCancellationRequested();
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
if (body == null)
{
@ -2084,7 +2061,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
getParams["offset"] = purchased.List.Count.ToString();
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}"));
PurchasedEntities.Purchased newPurchased;
Dictionary<string, string> loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams);
@ -2098,7 +2074,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value);
}
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest, cancellationToken))
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest))
{
loopresponse.EnsureSuccessStatusCode();
string loopbody = await loopresponse.Content.ReadAsStringAsync();
@ -2123,7 +2099,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
foreach (PurchasedEntities.ListItem purchase in
purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt))
{
cancellationToken.ThrowIfCancellationRequested();
long fromUserId = purchase.FromUser?.Id ?? 0;
long authorId = purchase.Author?.Id ?? 0;
@ -2207,10 +2182,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
return purchasedTabUsers;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
ExceptionLoggerHelper.LogException(ex);
@ -2225,10 +2196,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="endpoint">The purchased tab endpoint.</param>
/// <param name="folder">The base download folder.</param>
/// <param name="users">Known users map.</param>
/// <param name="cancellationToken"></param>
/// <returns>A list of purchased tab collections.</returns>
public async Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
Dictionary<string, long> users, CancellationToken cancellationToken = default)
Dictionary<string, long> users)
{
Log.Debug($"Calling GetPurchasedTab - {endpoint}");
@ -2244,7 +2214,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
{ "skip_users", "all" }
};
cancellationToken.ThrowIfCancellationRequested();
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
PurchasedDtos.PurchasedDto? purchasedDto =
DeserializeJson<PurchasedDtos.PurchasedDto>(body, s_mJsonSerializerSettings);
@ -2254,7 +2223,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
getParams["offset"] = purchased.List.Count.ToString();
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}"));
PurchasedEntities.Purchased newPurchased;
Dictionary<string, string> loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams);
@ -2268,7 +2236,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value);
}
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest, cancellationToken))
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest))
{
loopresponse.EnsureSuccessStatusCode();
string loopbody = await loopresponse.Content.ReadAsStringAsync();
@ -2293,7 +2261,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
foreach (PurchasedEntities.ListItem purchase in
purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt))
{
cancellationToken.ThrowIfCancellationRequested();
if (purchase.FromUser != null)
{
if (!userPurchases.ContainsKey(purchase.FromUser.Id))
@ -2317,7 +2284,6 @@ public class ApiService(IAuthService authService, IConfigService configService,
foreach (KeyValuePair<long, List<PurchasedEntities.ListItem>> user in userPurchases)
{
cancellationToken.ThrowIfCancellationRequested();
PurchasedEntities.PurchasedTabCollection purchasedTabCollection = new();
JObject? userObject = await GetUserInfoById($"/users/list?x[]={user.Key}");
purchasedTabCollection.UserId = user.Key;

View File

@ -2,7 +2,6 @@ using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Playwright;
using Newtonsoft.Json;
using OF_DL.Helpers;
using OF_DL.Models;
using Serilog;
using UserEntities = OF_DL.Models.Entities.Users;
@ -74,33 +73,19 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
/// Launches a browser session and extracts auth data after login.
/// </summary>
/// <returns>True when auth data is captured successfully.</returns>
public async Task<bool> LoadFromBrowserAsync(Action<string>? statusCallback = null)
public async Task<bool> LoadFromBrowserAsync()
{
statusCallback?.Invoke("Preparing browser dependencies ...");
try
{
bool runningInDocker = EnvironmentHelper.IsRunningInDocker();
bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
await SetupBrowser(runningInDocker);
}
catch (Exception ex)
{
statusCallback?.Invoke("Failed to prepare browser dependencies.");
Log.Error(ex, "Failed to download browser dependencies");
return false;
}
statusCallback?.Invoke("Please login using the opened Chromium window.");
try
{
CurrentAuth = await GetAuthFromBrowser();
return CurrentAuth != null;
}
catch (Exception ex)
{
statusCallback?.Invoke("Failed to get auth from browser.");
Log.Error(ex, "Failed to load auth from browser");
return false;
}
@ -122,7 +107,7 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
{
string json = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented);
await File.WriteAllTextAsync(filePath, json);
Log.Debug("Auth saved to file: {FilePath}", filePath);
Log.Debug($"Auth saved to file: {filePath}");
}
catch (Exception ex)
{
@ -130,7 +115,7 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
}
}
private Task SetupBrowser(bool runningInDocker) => Task.Run(() =>
private Task SetupBrowser(bool runningInDocker)
{
if (runningInDocker)
{
@ -149,16 +134,15 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
if (folders.Any())
{
Log.Information("chromium already downloaded. Skipping install step.");
return;
return Task.CompletedTask;
}
}
int exitCode = Program.Main(["install", "--with-deps", "chromium"]);
if (exitCode != 0)
{
throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}");
}
});
return exitCode != 0
? throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}")
: Task.CompletedTask;
}
private static async Task<string> GetBcToken(IPage page) =>
await page.EvaluateAsync<string>("window.localStorage.getItem('bcTokenSha') || ''");

View File

@ -236,12 +236,6 @@ public class ConfigService(ILoggingService loggingService) : IConfigService
LimitDownloadRate = hoconConfig.GetBoolean("Performance.LimitDownloadRate"),
DownloadLimitInMbPerSec = hoconConfig.GetInt("Performance.DownloadLimitInMbPerSec"),
// Appearance Settings
Theme = ParseTheme(hoconConfig.GetString("Appearance.Theme", "dark")),
HideMissingCdmKeysWarning =
bool.TryParse(hoconConfig.GetString("Appearance.HideMissingCdmKeysWarning", "false"),
out bool hideMissingCdmKeysWarning) && hideMissingCdmKeysWarning,
// Logging/Debug Settings
LoggingLevel = Enum.Parse<LoggingLevel>(hoconConfig.GetString("Logging.LoggingLevel"), true)
};
@ -413,12 +407,6 @@ public class ConfigService(ILoggingService loggingService) : IConfigService
hocon.AppendLine($" DownloadLimitInMbPerSec = {config.DownloadLimitInMbPerSec}");
hocon.AppendLine("}");
hocon.AppendLine("# Appearance Settings");
hocon.AppendLine("Appearance {");
hocon.AppendLine($" Theme = \"{config.Theme.ToString().ToLower()}\"");
hocon.AppendLine($" HideMissingCdmKeysWarning = {config.HideMissingCdmKeysWarning.ToString().ToLower()}");
hocon.AppendLine("}");
hocon.AppendLine("# Logging/Debug Settings");
hocon.AppendLine("Logging {");
hocon.AppendLine($" LoggingLevel = \"{config.LoggingLevel.ToString().ToLower()}\"");
@ -502,12 +490,15 @@ public class ConfigService(ILoggingService loggingService) : IConfigService
return configChanged;
}
private VideoResolution ParseVideoResolution(string value) =>
value.Equals("source", StringComparison.OrdinalIgnoreCase)
? VideoResolution.source
: Enum.Parse<VideoResolution>("_" + value, true);
private VideoResolution ParseVideoResolution(string value)
{
if (value.Equals("source", StringComparison.OrdinalIgnoreCase))
{
return VideoResolution.source;
}
private static Theme ParseTheme(string value) => Enum.TryParse(value, true, out Theme theme) ? theme : Theme.dark;
return Enum.Parse<VideoResolution>("_" + value, true);
}
private static double ParseDrmVideoDurationMatchThreshold(string value) =>
!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)

View File

@ -96,13 +96,6 @@ public class DownloadOrchestrationService(
return result;
}
/// <summary>
/// Retrieves the user's lists only.
/// </summary>
/// <returns>A dictionary of list names to list IDs.</returns>
public async Task<Dictionary<string, long>> GetUserListsAsync() =>
await apiService.GetLists("/lists") ?? new Dictionary<string, long>();
/// <summary>
/// Resolves the users that belong to a specific list.
/// </summary>
@ -171,14 +164,11 @@ public class DownloadOrchestrationService(
eventHandler.OnUserStarting(username);
Log.Debug($"Scraping Data for {username}");
eventHandler.CancellationToken.ThrowIfCancellationRequested();
await PrepareUserFolderAsync(username, userId, path);
if (config.DownloadAvatarHeaderPhoto)
{
eventHandler.CancellationToken.ThrowIfCancellationRequested();
UserEntities.User? userInfo =
await apiService.GetUserInfo($"/users/{username}", eventHandler.CancellationToken);
UserEntities.User? userInfo = await apiService.GetUserInfo($"/users/{username}");
if (userInfo != null)
{
await downloadService.DownloadAvatarHeader(userInfo.Avatar, userInfo.Header, path, username);
@ -243,10 +233,9 @@ public class DownloadOrchestrationService(
if (config.DownloadStories)
{
eventHandler.CancellationToken.ThrowIfCancellationRequested();
eventHandler.OnMessage("Getting Stories");
Dictionary<long, string>? tempStories = await apiService.GetMedia(MediaType.Stories,
$"/users/{userId}/stories", null, path, eventHandler.CancellationToken);
$"/users/{userId}/stories", null, path);
if (tempStories is { Count: > 0 })
{
@ -257,7 +246,7 @@ public class DownloadOrchestrationService(
: tempStories.Count;
DownloadResult result = await eventHandler.WithProgressAsync(
$"Downloading {tempStories.Count} stories", totalSize, config.ShowScrapeSize,
$"Downloading {tempStories.Count} Stories", totalSize, config.ShowScrapeSize,
async reporter => await downloadService.DownloadStories(username, userId, path,
PaidPostIds.ToHashSet(), reporter));
@ -272,10 +261,9 @@ public class DownloadOrchestrationService(
if (config.DownloadHighlights)
{
eventHandler.CancellationToken.ThrowIfCancellationRequested();
eventHandler.OnMessage("Getting Highlights");
Dictionary<long, string>? tempHighlights = await apiService.GetMedia(MediaType.Highlights,
$"/users/{userId}/stories/highlights", null, path, eventHandler.CancellationToken);
$"/users/{userId}/stories/highlights", null, path);
if (tempHighlights is { Count: > 0 })
{
@ -286,7 +274,7 @@ public class DownloadOrchestrationService(
: tempHighlights.Count;
DownloadResult result = await eventHandler.WithProgressAsync(
$"Downloading {tempHighlights.Count} highlights", totalSize, config.ShowScrapeSize,
$"Downloading {tempHighlights.Count} Highlights", totalSize, config.ShowScrapeSize,
async reporter => await downloadService.DownloadHighlights(username, userId, path,
PaidPostIds.ToHashSet(), reporter));
@ -360,12 +348,9 @@ public class DownloadOrchestrationService(
long totalSize = config.ShowScrapeSize
? await downloadService.CalculateTotalFileSize(post.SinglePosts.Values.ToList())
: post.SinglePosts.Count;
int postCount = post.SinglePostObjects.Count;
string postLabel = postCount == 1 ? "Post" : "Posts";
DownloadResult result = await eventHandler.WithProgressAsync(
$"Downloading {post.SinglePosts.Count} media from {postCount} {postLabel.ToLowerInvariant()}", totalSize,
config.ShowScrapeSize,
"Downloading Post", totalSize, config.ShowScrapeSize,
async reporter => await downloadService.DownloadSinglePost(username, path, users,
clientIdBlobMissing, devicePrivateKeyMissing, post, reporter));
@ -395,19 +380,13 @@ public class DownloadOrchestrationService(
{
Config config = configService.CurrentConfig;
eventHandler.OnMessage("Fetching purchased tab users...");
eventHandler.CancellationToken.ThrowIfCancellationRequested();
Dictionary<string, long> purchasedTabUsers =
await apiService.GetPurchasedTabUsers("/posts/paid/all", users, eventHandler.CancellationToken);
await apiService.GetPurchasedTabUsers("/posts/paid/all", users);
eventHandler.OnMessage("Checking folders for users in Purchased Tab");
eventHandler.CancellationToken.ThrowIfCancellationRequested();
foreach (KeyValuePair<string, long> user in purchasedTabUsers)
{
eventHandler.CancellationToken.ThrowIfCancellationRequested();
string path = ResolveDownloadPath(user.Key);
Log.Debug($"Download path: {path}");
@ -419,7 +398,7 @@ public class DownloadOrchestrationService(
Log.Debug($"Created folder for {user.Key}");
}
await apiService.GetUserInfo($"/users/{user.Key}", eventHandler.CancellationToken);
await apiService.GetUserInfo($"/users/{user.Key}");
await dbService.CreateDb(path);
}
@ -429,16 +408,11 @@ public class DownloadOrchestrationService(
Log.Debug($"Download path: {basePath}");
eventHandler.OnMessage("Fetching purchased tab content...");
eventHandler.CancellationToken.ThrowIfCancellationRequested();
List<PurchasedEntities.PurchasedTabCollection> purchasedTabCollections =
await apiService.GetPurchasedTab("/posts/paid/all", basePath, users, eventHandler.CancellationToken);
await apiService.GetPurchasedTab("/posts/paid/all", basePath, users);
foreach (PurchasedEntities.PurchasedTabCollection purchasedTabCollection in purchasedTabCollections)
{
eventHandler.CancellationToken.ThrowIfCancellationRequested();
eventHandler.OnUserStarting(purchasedTabCollection.Username);
string path = ResolveDownloadPath(purchasedTabCollection.Username);
Log.Debug($"Download path: {path}");
@ -459,7 +433,7 @@ public class DownloadOrchestrationService(
: purchasedTabCollection.PaidPosts.PaidPosts.Count;
DownloadResult postResult = await eventHandler.WithProgressAsync(
$"Downloading {purchasedTabCollection.PaidPosts.PaidPosts.Count} media from {purchasedTabCollection.PaidPosts.PaidPostObjects.Count} paid posts",
$"Downloading {purchasedTabCollection.PaidPosts.PaidPosts.Count} Media from {purchasedTabCollection.PaidPosts.PaidPostObjects.Count} Paid Posts",
totalSize, config.ShowScrapeSize,
async reporter => await downloadService.DownloadPaidPostsPurchasedTab(
purchasedTabCollection.Username, path, users,
@ -487,7 +461,7 @@ public class DownloadOrchestrationService(
: purchasedTabCollection.PaidMessages.PaidMessages.Count;
DownloadResult msgResult = await eventHandler.WithProgressAsync(
$"Downloading {purchasedTabCollection.PaidMessages.PaidMessages.Count} media from {purchasedTabCollection.PaidMessages.PaidMessageObjects.Count} paid messages",
$"Downloading {purchasedTabCollection.PaidMessages.PaidMessages.Count} Media from {purchasedTabCollection.PaidMessages.PaidMessageObjects.Count} Paid Messages",
totalSize, config.ShowScrapeSize,
async reporter => await downloadService.DownloadPaidMessagesPurchasedTab(
purchasedTabCollection.Username, path, users,
@ -558,7 +532,7 @@ public class DownloadOrchestrationService(
: totalCount;
DownloadResult result = await eventHandler.WithProgressAsync(
$"Downloading {totalCount} media from {messageCount} {messageLabel} ({paidCount} paid + {previewCount} preview)",
$"Downloading {totalCount} Media from {messageCount} {messageLabel} ({paidCount} Paid + {previewCount} Preview)",
totalSize, config.ShowScrapeSize,
async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users,
clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter));
@ -578,7 +552,7 @@ public class DownloadOrchestrationService(
: previewCount;
DownloadResult previewResult = await eventHandler.WithProgressAsync(
$"Downloading {previewCount} preview media from {messageCount} {messageLabel.ToLowerInvariant()}",
$"Downloading {previewCount} Preview Media from {messageCount} {messageLabel}",
previewSize, config.ShowScrapeSize,
async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users,
clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter));
@ -598,7 +572,7 @@ public class DownloadOrchestrationService(
: paidCount;
DownloadResult result = await eventHandler.WithProgressAsync(
$"Downloading {paidCount} paid media from {messageCount} {messageLabel.ToLowerInvariant()}",
$"Downloading {paidCount} Paid Media from {messageCount} {messageLabel}",
totalSize, config.ShowScrapeSize,
async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users,
clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter));
@ -662,8 +636,7 @@ public class DownloadOrchestrationService(
: mediaCount;
DownloadResult result = await eventHandler.WithProgressAsync(
$"Downloading {mediaCount} media from {objectCount} {contentType.ToLowerInvariant()}", totalSize,
config.ShowScrapeSize,
$"Downloading {mediaCount} Media from {objectCount} {contentType}", totalSize, config.ShowScrapeSize,
async reporter => await downloadData(data, reporter));
eventHandler.OnDownloadComplete(contentType, result);

View File

@ -217,7 +217,7 @@ public class DownloadService(
Engine ffmpeg = new(configService.CurrentConfig.FFmpegPath);
ffmpeg.Error += OnError;
ffmpeg.Complete += (_, _) => { _completionSource.TrySetResult(true); };
await ffmpeg.ExecuteAsync(parameters, progressReporter.CancellationToken);
await ffmpeg.ExecuteAsync(parameters, CancellationToken.None);
bool ffmpegSuccess = await _completionSource.Task;
if (!ffmpegSuccess || !File.Exists(tempFilename))
@ -263,10 +263,6 @@ public class DownloadService(
Constants.DrmDownloadMaxRetries, mediaId);
return false;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
ExceptionLoggerHelper.LogException(ex);
@ -412,14 +408,14 @@ public class DownloadService(
{
string firstFullPath = Path.GetFullPath(firstPath);
string secondFullPath = Path.GetFullPath(secondPath);
StringComparison comparison = EnvironmentHelper.IsRunningOnWindows()
StringComparison comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return string.Equals(firstFullPath, secondFullPath, comparison);
}
catch
{
StringComparison comparison = EnvironmentHelper.IsRunningOnWindows()
StringComparison comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return string.Equals(firstPath, secondPath, comparison);
@ -939,10 +935,9 @@ public class DownloadService(
using HttpClient client = new();
HttpRequestMessage request = new() { Method = HttpMethod.Get, RequestUri = new Uri(url) };
using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead,
progressReporter.CancellationToken);
using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
Stream body = await response.Content.ReadAsStreamAsync(progressReporter.CancellationToken);
Stream body = await response.Content.ReadAsStreamAsync();
// Wrap the body stream with the ThrottledStream to limit read rate.
await using (ThrottledStream throttledStream = new(body,
@ -954,14 +949,14 @@ public class DownloadService(
true);
byte[] buffer = new byte[16384];
int read;
while ((read = await throttledStream.ReadAsync(buffer, progressReporter.CancellationToken)) > 0)
while ((read = await throttledStream.ReadAsync(buffer, CancellationToken.None)) > 0)
{
if (configService.CurrentConfig.ShowScrapeSize)
{
progressReporter.ReportProgress(read);
}
await fileStream.WriteAsync(buffer.AsMemory(0, read), progressReporter.CancellationToken);
await fileStream.WriteAsync(buffer.AsMemory(0, read), CancellationToken.None);
}
}

View File

@ -35,69 +35,69 @@ public interface IApiService
/// <summary>
/// Retrieves media URLs for stories or highlights.
/// </summary>
Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, string folder, CancellationToken cancellationToken = default);
Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, string folder);
/// <summary>
/// Retrieves paid posts and their media.
/// </summary>
Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username,
List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
IStatusReporter statusReporter);
/// <summary>
/// Retrieves posts and their media.
/// </summary>
Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
IStatusReporter statusReporter);
/// <summary>
/// Retrieves a single post and its media.
/// </summary>
Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default);
Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder);
/// <summary>
/// Retrieves streams and their media.
/// </summary>
Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
IStatusReporter statusReporter);
/// <summary>
/// Retrieves archived posts and their media.
/// </summary>
Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
IStatusReporter statusReporter);
/// <summary>
/// Retrieves messages and their media.
/// </summary>
Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder, IStatusReporter statusReporter, CancellationToken cancellationToken = default);
Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder, IStatusReporter statusReporter);
/// <summary>
/// Retrieves paid messages and their media.
/// </summary>
Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder, string username,
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
IStatusReporter statusReporter);
/// <summary>
/// Retrieves a single paid message and its media.
/// </summary>
Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default);
Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder);
/// <summary>
/// Retrieves users that appear in the Purchased tab.
/// </summary>
Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default);
Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users);
/// <summary>
/// Retrieves Purchased tab content grouped by user.
/// </summary>
Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
Dictionary<string, long> users, CancellationToken cancellationToken = default);
Dictionary<string, long> users);
/// <summary>
/// Retrieves user information.
/// </summary>
Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default);
Task<UserEntities.User?> GetUserInfo(string endpoint);
/// <summary>
/// Retrieves user information by ID.

View File

@ -18,10 +18,7 @@ public interface IAuthService
/// <summary>
/// Launches a browser session and extracts auth data after login.
/// </summary>
/// <param name="statusCallback">
/// Optional callback for reporting status messages to be displayed in the UI.
/// </param>
Task<bool> LoadFromBrowserAsync(Action<string>? statusCallback = null);
Task<bool> LoadFromBrowserAsync();
/// <summary>
/// Persists the current auth data to disk.

View File

@ -8,11 +8,6 @@ namespace OF_DL.Services;
/// </summary>
public interface IDownloadEventHandler
{
/// <summary>
/// Gets the cancellation token for the operation.
/// </summary>
CancellationToken CancellationToken { get; }
/// <summary>
/// Wraps work in a status indicator (spinner) during API fetching.
/// The implementation controls how the status is displayed.

View File

@ -9,11 +9,6 @@ public interface IDownloadOrchestrationService
/// </summary>
Task<UserListResult> GetAvailableUsersAsync();
/// <summary>
/// Fetch only user lists.
/// </summary>
Task<Dictionary<string, long>> GetUserListsAsync();
/// <summary>
/// Get users for a specific list by name.
/// </summary>

View File

@ -11,9 +11,4 @@ public interface IProgressReporter
/// </summary>
/// <param name="increment">The amount to increment progress by</param>
void ReportProgress(long increment);
/// <summary>
/// Gets the cancellation token for canceling the operation.
/// </summary>
CancellationToken CancellationToken { get; }
}

View File

@ -7,11 +7,8 @@ namespace OF_DL.Services;
public class LoggingService : ILoggingService
{
private readonly ILogEventSink? _optionalErrorSink;
public LoggingService(ILogEventSink? optionalErrorSink = null)
public LoggingService()
{
_optionalErrorSink = optionalErrorSink;
LevelSwitch = new LoggingLevelSwitch();
InitializeLogger();
}
@ -41,17 +38,10 @@ public class LoggingService : ILoggingService
// Set the initial level to Error (until we've read from config)
LevelSwitch.MinimumLevel = LogEventLevel.Error;
LoggerConfiguration loggerConfiguration = new LoggerConfiguration()
Log.Logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(LevelSwitch)
.WriteTo.File("logs/OFDL.txt", rollingInterval: RollingInterval.Day);
if (_optionalErrorSink != null)
{
loggerConfiguration = loggerConfiguration.WriteTo.Sink(_optionalErrorSink,
LogEventLevel.Error);
}
Log.Logger = loggerConfiguration.CreateLogger();
.WriteTo.File("logs/OFDL.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
Log.Debug("Logging service initialized");
}

View File

@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using Newtonsoft.Json;
using OF_DL.Helpers;
using OF_DL.Models;
@ -23,9 +24,9 @@ public class StartupService(IConfigService configService, IAuthService authServi
// OS validation
OperatingSystem os = Environment.OSVersion;
result.OsVersionString = os.VersionString;
Log.Debug("Operating system information: {OsVersionString}", os.VersionString);
Log.Debug($"Operating system information: {os.VersionString}");
if (EnvironmentHelper.IsRunningOnWindows() && os.Version.Major < 10)
if (os.Platform == PlatformID.Win32NT && os.Version.Major < 10)
{
result.IsWindowsVersionValid = false;
Log.Error("Windows version prior to 10.x: {0}", os.VersionString);
@ -39,7 +40,7 @@ public class StartupService(IConfigService configService, IAuthService authServi
if (result is { FfmpegFound: true, FfmpegPath: not null })
{
// Escape backslashes for Windows
if (EnvironmentHelper.IsRunningOnWindows() &&
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
result.FfmpegPath.Contains(@":\") &&
!result.FfmpegPath.Contains(@":\\"))
{
@ -54,7 +55,7 @@ public class StartupService(IConfigService configService, IAuthService authServi
if (result is { FfprobeFound: true, FfprobePath: not null })
{
// Escape backslashes for Windows
if (EnvironmentHelper.IsRunningOnWindows() &&
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
result.FfprobePath.Contains(@":\") &&
!result.FfprobePath.Contains(@":\\"))
{
@ -210,7 +211,7 @@ public class StartupService(IConfigService configService, IAuthService authServi
string? ffmpegDirectory = Path.GetDirectoryName(result.FfmpegPath);
if (!string.IsNullOrEmpty(ffmpegDirectory))
{
string ffprobeFileName = EnvironmentHelper.IsRunningOnWindows()
string ffprobeFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "ffprobe.exe"
: "ffprobe";
string inferredFfprobePath = Path.Combine(ffmpegDirectory, ffprobeFileName);

View File

@ -1,118 +0,0 @@
<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.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<!-- Light Theme -->
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#F8FAFC" />
<SolidColorBrush x:Key="SurfaceBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="SurfaceBorderBrush" Color="#E2E8F0" />
<!-- Primary Button -->
<SolidColorBrush x:Key="PrimaryButtonBackgroundBrush" Color="#3B82F6" />
<SolidColorBrush x:Key="PrimaryButtonBackgroundHoverBrush" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryButtonBackgroundPressedBrush" Color="#1D4ED8" />
<SolidColorBrush x:Key="PrimaryButtonForegroundBrush" Color="#FFFFFF" />
<!-- Secondary Button -->
<SolidColorBrush x:Key="SecondaryButtonBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="SecondaryButtonBackgroundHoverBrush" Color="#F1F5F9" />
<SolidColorBrush x:Key="SecondaryButtonBackgroundPressedBrush" Color="#E2E8F0" />
<SolidColorBrush x:Key="SecondaryButtonForegroundBrush" Color="#1E293B" />
<SolidColorBrush x:Key="SecondaryButtonBorderBrush" Color="#CBD5E1" />
<!-- Top Bar -->
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#EFF6FF" />
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#DBEAFE" />
<SolidColorBrush x:Key="TopBarTextBrush" Color="#1E40AF" />
<!-- Text Colors -->
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#0F172A" />
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#475569" />
<!-- Help Badge -->
<SolidColorBrush x:Key="HelpBadgeBackgroundBrush" Color="#EFF6FF" />
<SolidColorBrush x:Key="HelpBadgeBorderBrush" Color="#BFDBFE" />
<!-- Error -->
<SolidColorBrush x:Key="ErrorTextBrush" Color="#EF4444" />
<!-- Preview/Item Background -->
<SolidColorBrush x:Key="PreviewBackgroundBrush" Color="#F8FAFC" />
<SolidColorBrush x:Key="PreviewBorderBrush" Color="#E2E8F0" />
<!-- Danger Button -->
<SolidColorBrush x:Key="DangerSoftBackgroundBrush" Color="#FEE2E2" />
<SolidColorBrush x:Key="DangerSoftBorderBrush" Color="#FECACA" />
<SolidColorBrush x:Key="DangerButtonBackgroundBrush" Color="#EF4444" />
<SolidColorBrush x:Key="DangerButtonBackgroundHoverBrush" Color="#DC2626" />
<SolidColorBrush x:Key="DangerButtonBackgroundPressedBrush" Color="#B91C1C" />
<!-- Modal -->
<SolidColorBrush x:Key="OverlayBackgroundBrush" Color="#99000000" />
<SolidColorBrush x:Key="ModalBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="ModalBorderBrush" Color="#E2E8F0" />
</ResourceDictionary>
<!-- Dark Theme -->
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#0F172A" />
<SolidColorBrush x:Key="SurfaceBackgroundBrush" Color="#1E293B" />
<SolidColorBrush x:Key="SurfaceBorderBrush" Color="#334155" />
<!-- Primary Button -->
<SolidColorBrush x:Key="PrimaryButtonBackgroundBrush" Color="#3B82F6" />
<SolidColorBrush x:Key="PrimaryButtonBackgroundHoverBrush" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryButtonBackgroundPressedBrush" Color="#1D4ED8" />
<SolidColorBrush x:Key="PrimaryButtonForegroundBrush" Color="#FFFFFF" />
<!-- Secondary Button - Fixed for dark theme -->
<SolidColorBrush x:Key="SecondaryButtonBackgroundBrush" Color="#334155" />
<SolidColorBrush x:Key="SecondaryButtonBackgroundHoverBrush" Color="#475569" />
<SolidColorBrush x:Key="SecondaryButtonBackgroundPressedBrush" Color="#1E293B" />
<SolidColorBrush x:Key="SecondaryButtonForegroundBrush" Color="#F1F5F9" />
<SolidColorBrush x:Key="SecondaryButtonBorderBrush" Color="#475569" />
<!-- Top Bar -->
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#1E293B" />
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#334155" />
<SolidColorBrush x:Key="TopBarTextBrush" Color="#93C5FD" />
<!-- Text Colors -->
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#F1F5F9" />
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#94A3B8" />
<!-- Help Badge -->
<SolidColorBrush x:Key="HelpBadgeBackgroundBrush" Color="#1E3A8A" />
<SolidColorBrush x:Key="HelpBadgeBorderBrush" Color="#3B82F6" />
<!-- Error -->
<SolidColorBrush x:Key="ErrorTextBrush" Color="#F87171" />
<!-- Preview/Item Background -->
<SolidColorBrush x:Key="PreviewBackgroundBrush" Color="#0F172A" />
<SolidColorBrush x:Key="PreviewBorderBrush" Color="#334155" />
<!-- Danger Button -->
<SolidColorBrush x:Key="DangerSoftBackgroundBrush" Color="#7F1D1D" />
<SolidColorBrush x:Key="DangerSoftBorderBrush" Color="#991B1B" />
<SolidColorBrush x:Key="DangerButtonBackgroundBrush" Color="#EF4444" />
<SolidColorBrush x:Key="DangerButtonBackgroundHoverBrush" Color="#DC2626" />
<SolidColorBrush x:Key="DangerButtonBackgroundPressedBrush" Color="#B91C1C" />
<!-- Modal -->
<SolidColorBrush x:Key="OverlayBackgroundBrush" Color="#CC000000" />
<SolidColorBrush x:Key="ModalBackgroundBrush" Color="#1E293B" />
<SolidColorBrush x:Key="ModalBorderBrush" Color="#334155" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<fluent:FluentTheme />
</Application.Styles>
</Application>

View File

@ -1,28 +0,0 @@
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();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,211 +0,0 @@
using System.Collections.Specialized;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Primitives;
using Avalonia.Media;
using Avalonia.VisualTree;
using OF_DL.Gui.ViewModels;
namespace OF_DL.Gui.Controls;
public class FileNameFormatOverlayTextBlock : TextBlock
{
public static readonly StyledProperty<IEnumerable<FileNameFormatSegmentViewModel>?> SegmentsProperty =
AvaloniaProperty.Register<FileNameFormatOverlayTextBlock, IEnumerable<FileNameFormatSegmentViewModel>?>(
nameof(Segments));
public static readonly StyledProperty<TextBox?> SourceTextBoxProperty =
AvaloniaProperty.Register<FileNameFormatOverlayTextBlock, TextBox?>(nameof(SourceTextBox));
private INotifyCollectionChanged? _segmentsCollection;
private TextBox? _attachedTextBox;
private ScrollViewer? _attachedScrollViewer;
static FileNameFormatOverlayTextBlock()
{
SegmentsProperty.Changed.AddClassHandler<FileNameFormatOverlayTextBlock>(OnSegmentsChanged);
SourceTextBoxProperty.Changed.AddClassHandler<FileNameFormatOverlayTextBlock>(OnSourceTextBoxChanged);
}
public IEnumerable<FileNameFormatSegmentViewModel>? Segments
{
get => GetValue(SegmentsProperty);
set => SetValue(SegmentsProperty, value);
}
public TextBox? SourceTextBox
{
get => GetValue(SourceTextBoxProperty);
set => SetValue(SourceTextBoxProperty, value);
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
DetachSegmentsCollection();
AttachSegmentsCollection(Segments);
AttachSourceTextBox(SourceTextBox);
RebuildInlines();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
DetachSourceTextBox();
DetachSegmentsCollection();
base.OnDetachedFromVisualTree(e);
}
private static void OnSegmentsChanged(
FileNameFormatOverlayTextBlock sender,
AvaloniaPropertyChangedEventArgs e)
{
sender.DetachSegmentsCollection();
sender.AttachSegmentsCollection(e.NewValue as IEnumerable<FileNameFormatSegmentViewModel>);
sender.RebuildInlines();
}
private static void OnSourceTextBoxChanged(
FileNameFormatOverlayTextBlock sender,
AvaloniaPropertyChangedEventArgs e) =>
sender.AttachSourceTextBox(e.NewValue as TextBox);
private void AttachSegmentsCollection(IEnumerable<FileNameFormatSegmentViewModel>? segments)
{
_segmentsCollection = segments as INotifyCollectionChanged;
if (_segmentsCollection is not null)
{
_segmentsCollection.CollectionChanged += OnSegmentsCollectionChanged;
}
}
private void DetachSegmentsCollection()
{
if (_segmentsCollection is null)
{
return;
}
_segmentsCollection.CollectionChanged -= OnSegmentsCollectionChanged;
_segmentsCollection = null;
}
private void OnSegmentsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildInlines();
private void AttachSourceTextBox(TextBox? textBox)
{
if (ReferenceEquals(_attachedTextBox, textBox))
{
UpdateOverlayOffset();
return;
}
DetachSourceTextBox();
_attachedTextBox = textBox;
if (_attachedTextBox is null)
{
UpdateOverlayOffset();
return;
}
_attachedTextBox.TemplateApplied += OnSourceTextBoxTemplateApplied;
AttachScrollViewer(FindSourceScrollViewer(_attachedTextBox));
UpdateOverlayOffset();
}
private void DetachSourceTextBox()
{
if (_attachedTextBox is not null)
{
_attachedTextBox.TemplateApplied -= OnSourceTextBoxTemplateApplied;
}
if (_attachedScrollViewer is not null)
{
_attachedScrollViewer.ScrollChanged -= OnSourceScrollChanged;
_attachedScrollViewer = null;
}
_attachedTextBox = null;
UpdateOverlayOffset();
}
private void OnSourceTextBoxTemplateApplied(object? sender, TemplateAppliedEventArgs e)
{
ScrollViewer? scrollViewer = e.NameScope.Find<ScrollViewer>("PART_ScrollViewer");
AttachScrollViewer(scrollViewer ?? FindSourceScrollViewer(_attachedTextBox));
UpdateOverlayOffset();
}
private void OnSourceScrollChanged(object? sender, ScrollChangedEventArgs e) => UpdateOverlayOffset();
private void AttachScrollViewer(ScrollViewer? scrollViewer)
{
if (ReferenceEquals(_attachedScrollViewer, scrollViewer))
{
return;
}
if (_attachedScrollViewer is not null)
{
_attachedScrollViewer.ScrollChanged -= OnSourceScrollChanged;
}
_attachedScrollViewer = scrollViewer;
if (_attachedScrollViewer is not null)
{
_attachedScrollViewer.ScrollChanged += OnSourceScrollChanged;
}
}
private void UpdateOverlayOffset()
{
if (_attachedScrollViewer is null)
{
RenderTransform = null;
return;
}
RenderTransform = new TranslateTransform(-_attachedScrollViewer.Offset.X, -_attachedScrollViewer.Offset.Y);
}
private static ScrollViewer? FindSourceScrollViewer(TextBox? textBox)
{
if (textBox is null)
{
return null;
}
return textBox.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
}
private void RebuildInlines()
{
if (Inlines is null)
{
return;
}
Inlines.Clear();
if (Segments is null)
{
return;
}
foreach (FileNameFormatSegmentViewModel segment in Segments)
{
Run run = new() { Text = segment.Text, Foreground = ParseForegroundBrush(segment.Foreground) };
Inlines.Add(run);
}
}
private IBrush ParseForegroundBrush(string? value)
{
if (!string.IsNullOrWhiteSpace(value) && Color.TryParse(value, out Color color))
{
return new SolidColorBrush(color);
}
return Foreground ?? Brushes.Transparent;
}
}

View File

@ -1,92 +0,0 @@
using System.Diagnostics;
using Avalonia.Controls;
using OF_DL.Helpers;
using Serilog;
namespace OF_DL.Gui.Helpers;
public static class WebLinkHelper
{
private const string CopiedToClipboardMessage = "Copied to clipboard";
public static async Task OpenOrCopyAsync(
Window owner,
string url,
Control? toolTipTarget = null,
Func<string, Task>? dockerFeedbackAsync = null)
{
try
{
if (EnvironmentHelper.IsRunningInDocker())
{
TopLevel? topLevel = TopLevel.GetTopLevel(owner);
if (topLevel?.Clipboard != null)
{
try
{
await topLevel.Clipboard.SetTextAsync(url);
}
catch
{
return;
}
}
if (dockerFeedbackAsync != null)
{
await dockerFeedbackAsync(CopiedToClipboardMessage);
return;
}
if (toolTipTarget != null)
{
await ShowTemporaryTooltipAsync(toolTipTarget, CopiedToClipboardMessage);
}
return;
}
}
catch (Exception e)
{
Log.Error("Failed to copy URL to clipboard. {ErrorMessage}", e.Message);
}
try
{
OpenExternalUrl(url);
}
catch (Exception e)
{
Log.Error(e, "Failed to open external URL. {ErrorMessage}", e.Message);
}
}
private static async Task ShowTemporaryTooltipAsync(
Control target,
string message,
int durationMilliseconds = 1500)
{
object? originalTip = ToolTip.GetTip(target);
ToolTip.SetTip(target, message);
ToolTip.SetIsOpen(target, true);
await Task.Delay(durationMilliseconds);
ToolTip.SetIsOpen(target, false);
ToolTip.SetTip(target, originalTip);
}
private static void OpenExternalUrl(string url)
{
try
{
ProcessStartInfo processStartInfo = new(url) { UseShellExecute = true };
Process.Start(processStartInfo);
}
catch
{
// Ignore browser launch failures to preserve prior behavior.
}
}
}

View File

@ -1,46 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>OF_DL.Gui</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\icon.ico"/>
</ItemGroup>
<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"/>
<PackageReference Include="PuppeteerSharp" Version="20.2.6"/>
<PackageReference Include="Serilog" Version="4.3.1"/>
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.10"/>
</ItemGroup>
<ItemGroup>
<None Update="chromium-scripts\stealth.min.js">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="rules.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -1,102 +0,0 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using OF_DL.Helpers;
using OF_DL.Services;
using Serilog;
namespace OF_DL.Gui;
public static class Program
{
private static int s_hasProcessedUnhandledException;
public static bool HidePrivateInfo { get; private set; }
public static void Main(string[] args)
{
try
{
// Parse command line arguments
HidePrivateInfo = args.Contains("--hide-private-info", StringComparer.OrdinalIgnoreCase);
// Initialize the logging service to ensure that logs are written before Avalonia starts (with a new logging service)
LoggingService _ = new();
RegisterGlobalExceptionHandlers();
// Check if running in Docker and print a message
if (EnvironmentHelper.IsRunningInDocker())
{
Console.WriteLine(
"In your web browser, navigate to the port forwarded from your docker container. For instance, if your docker run command included \"-p 8080:8080\", open your web browser to \"http://localhost:8080\".");
}
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
catch (Exception ex)
{
HandleUnhandledException(ex, "Program.Main", true);
}
finally
{
Log.CloseAndFlush();
}
}
private static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
private static void RegisterGlobalExceptionHandlers()
{
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
HandleUnhandledException(eventArgs.ExceptionObject as Exception,
"AppDomain.CurrentDomain.UnhandledException",
eventArgs.IsTerminating);
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
{
HandleUnhandledException(eventArgs.Exception, "TaskScheduler.UnobservedTaskException",
false);
eventArgs.SetObserved();
};
}
private static void HandleUnhandledException(Exception? exception, string source, bool isTerminating)
{
if (Interlocked.Exchange(ref s_hasProcessedUnhandledException, 1) != 0)
{
return;
}
try
{
if (exception != null)
{
Log.Fatal(exception, "Unhandled exception from {Source}. Terminating={IsTerminating}",
source, isTerminating);
Console.WriteLine($"Unhandled exception from {source}: {exception}");
}
else
{
Log.Fatal("Unhandled non-exception object from {Source}. Terminating={IsTerminating}",
source, isTerminating);
Console.WriteLine($"Unhandled non-exception object from {source}.");
}
}
finally
{
Log.CloseAndFlush();
}
if (!isTerminating &&
Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
{
desktopLifetime.Shutdown(1);
}
}
}

View File

@ -1,160 +0,0 @@
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,
CancellationToken cancellationToken) : IDownloadEventHandler
{
private string _lastProgressDescription = string.Empty;
private string _activeUsername = string.Empty;
private DateTime _activeUserStartedAtUtc;
private readonly Dictionary<string, int> _contentObjectCounts = new(StringComparer.Ordinal);
private bool _hasPerUserCompletionLogged;
public CancellationToken CancellationToken { get; } = cancellationToken;
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();
_lastProgressDescription = description;
progressStart(description, maxValue, showSize);
try
{
AvaloniaProgressReporter reporter = new(progressIncrement, isCancellationRequested, CancellationToken);
return await work(reporter);
}
finally
{
progressStop();
}
}
public void OnContentFound(string contentType, int mediaCount, int objectCount)
{
ThrowIfCancellationRequested();
_contentObjectCounts[contentType] = objectCount;
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();
if (!string.IsNullOrWhiteSpace(_activeUsername) && result.NewDownloads > 0)
{
if (_contentObjectCounts.TryGetValue(contentType, out int objectCount) && objectCount > 0)
{
activitySink(
$"Downloaded {result.NewDownloads} media from {objectCount} {contentType.ToLowerInvariant()}.");
}
else
{
activitySink($"Downloaded {result.NewDownloads} media from {contentType.ToLowerInvariant()}.");
}
}
progressStatusUpdate(
$"{contentType} complete. Existing: {result.ExistingDownloads}, New: {result.NewDownloads}, Total: {result.TotalCount}.");
}
public void OnUserStarting(string username)
{
ThrowIfCancellationRequested();
_activeUsername = username;
_activeUserStartedAtUtc = DateTime.UtcNow;
_hasPerUserCompletionLogged = false;
activitySink($"Starting scrape for {username}.");
progressStatusUpdate($"Scraping data for {username}...");
}
public void OnUserComplete(string username, CreatorDownloadResult result)
{
ThrowIfCancellationRequested();
TimeSpan elapsed = DateTime.UtcNow - _activeUserStartedAtUtc;
activitySink($"Completed {username} in {elapsed.TotalMinutes:0.0} minutes.");
_activeUsername = string.Empty;
_hasPerUserCompletionLogged = true;
_contentObjectCounts.Clear();
}
public void OnPurchasedTabUserComplete(string username, int paidPostCount, int paidMessagesCount)
{
ThrowIfCancellationRequested();
TimeSpan elapsed = DateTime.UtcNow - _activeUserStartedAtUtc;
activitySink($"Completed {username} in {elapsed.TotalMinutes:0.0} minutes.");
_activeUsername = string.Empty;
_hasPerUserCompletionLogged = true;
_contentObjectCounts.Clear();
}
public void OnScrapeComplete(TimeSpan elapsed)
{
ThrowIfCancellationRequested();
if (_hasPerUserCompletionLogged)
{
return;
}
string summary = BuildCompletionSummary(elapsed);
activitySink(summary);
}
public void OnMessage(string message)
{
ThrowIfCancellationRequested();
progressStatusUpdate(message);
}
private void ThrowIfCancellationRequested()
{
if (isCancellationRequested())
{
throw new OperationCanceledException("Operation canceled by user.");
}
}
private string BuildCompletionSummary(TimeSpan elapsed)
{
if (string.IsNullOrWhiteSpace(_lastProgressDescription))
{
return $"Download completed in {elapsed.TotalMinutes:0.0} minutes.";
}
string normalized = _lastProgressDescription.Trim().TrimEnd('.');
if (normalized.StartsWith("Downloading ", StringComparison.OrdinalIgnoreCase))
{
string remainder = normalized["Downloading ".Length..].ToLowerInvariant();
return $"Downloaded {remainder} in {elapsed.TotalMinutes:0.0} minutes.";
}
return $"{normalized} in {elapsed.TotalMinutes:0.0} minutes.";
}
}

View File

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

View File

@ -1,18 +0,0 @@
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

@ -1,85 +0,0 @@
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);
ValidatePath(config.FFprobePath, nameof(Config.FFprobePath), 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

@ -1,45 +0,0 @@
using Serilog.Core;
using Serilog.Events;
namespace OF_DL.Gui.Services;
public sealed class DownloadErrorLogTracker
{
private int _sessionActive;
private int _errorLoggedInSession;
public bool IsSessionActive => Volatile.Read(ref _sessionActive) == 1;
public void StartSession()
{
Interlocked.Exchange(ref _errorLoggedInSession, 0);
Interlocked.Exchange(ref _sessionActive, 1);
}
public bool StopSession()
{
Interlocked.Exchange(ref _sessionActive, 0);
return Volatile.Read(ref _errorLoggedInSession) == 1;
}
public void RecordError()
{
if (!IsSessionActive)
{
return;
}
Interlocked.Exchange(ref _errorLoggedInSession, 1);
}
}
internal sealed class DownloadErrorTrackingSink(DownloadErrorLogTracker tracker) : ILogEventSink
{
public void Emit(LogEvent logEvent)
{
if (logEvent.Level >= LogEventLevel.Error)
{
tracker.RecordError();
}
}
}

View File

@ -1,32 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using OF_DL.Gui.ViewModels;
using OF_DL.Gui.Views;
using OF_DL.Services;
using Serilog.Core;
namespace OF_DL.Gui.Services;
internal static class ServiceCollectionFactory
{
public static IServiceCollection Create()
{
IServiceCollection services = new ServiceCollection();
services.AddSingleton<DownloadErrorLogTracker>();
services.AddSingleton<ILogEventSink, DownloadErrorTrackingSink>();
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

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

View File

@ -1,150 +0,0 @@
using System.Collections.ObjectModel;
using OF_DL.Models.Config;
namespace OF_DL.Gui.ViewModels;
public sealed class ConfigCategoryViewModel : ViewModelBase
{
private const string SpecificDateFilterOptionHelpText =
"Downloads posts (does not apply to messages) before or after the chosen date.";
private const string FolderStructureOptionHelpText =
"Choose which content types get separate folders, including unlocked PPV posts and messages.";
public ConfigCategoryViewModel(string categoryName, IEnumerable<ConfigFieldViewModel> fields)
{
CategoryName = categoryName;
List<ConfigFieldViewModel> fieldList = fields.ToList();
DownloadOnlySpecificDatesField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.DownloadOnlySpecificDates), StringComparison.Ordinal));
DownloadDateSelectionField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.DownloadDateSelection), StringComparison.Ordinal));
CustomDateField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.CustomDate), StringComparison.Ordinal));
DownloadVideoResolutionField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.DownloadVideoResolution), StringComparison.Ordinal));
LimitDownloadRateField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.LimitDownloadRate), StringComparison.Ordinal));
DownloadLimitInMbPerSecField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.DownloadLimitInMbPerSec), StringComparison.Ordinal));
FolderPerPaidPostField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.FolderPerPaidPost), StringComparison.Ordinal));
FolderPerPostField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.FolderPerPost), StringComparison.Ordinal));
FolderPerPaidMessageField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.FolderPerPaidMessage), StringComparison.Ordinal));
FolderPerMessageField = fieldList.FirstOrDefault(field =>
string.Equals(field.PropertyName, nameof(Config.FolderPerMessage), StringComparison.Ordinal));
IEnumerable<ConfigFieldViewModel> visibleFields = IsExternal
? fieldList.Where(field => field.PropertyName is not nameof(Config.FFmpegPath)
and not nameof(Config.FFprobePath))
: IsDownloadBehavior
? fieldList.Where(field => field.PropertyName is not nameof(Config.DownloadOnlySpecificDates)
and not nameof(Config.DownloadDateSelection)
and not nameof(Config.CustomDate)
and not nameof(Config.DownloadVideoResolution))
: IsPerformance
? fieldList.Where(field => field.PropertyName is not nameof(Config.LimitDownloadRate)
and not nameof(Config.DownloadLimitInMbPerSec))
: IsFolderStructure
? fieldList.Where(field => field.PropertyName is not nameof(Config.FolderPerPaidPost)
and not nameof(Config.FolderPerPost)
and not nameof(Config.FolderPerPaidMessage)
and not nameof(Config.FolderPerMessage))
: fieldList;
foreach (ConfigFieldViewModel field in visibleFields)
{
Fields.Add(field);
}
}
public string CategoryName { get; }
public bool IsExternal =>
string.Equals(CategoryName, "External", StringComparison.Ordinal);
public bool IsDownloadBehavior =>
string.Equals(CategoryName, "Download Behavior", StringComparison.Ordinal);
public bool IsPerformance =>
string.Equals(CategoryName, "Performance", StringComparison.Ordinal);
public bool IsFolderStructure =>
string.Equals(CategoryName, "Folder Structure", StringComparison.Ordinal);
public bool IsFileNaming =>
string.Equals(CategoryName, "File Naming", StringComparison.Ordinal);
public ConfigFieldViewModel? DownloadOnlySpecificDatesField { get; }
public ConfigFieldViewModel? DownloadDateSelectionField { get; }
public ConfigFieldViewModel? CustomDateField { get; }
public ConfigFieldViewModel? DownloadVideoResolutionField { get; }
public ConfigFieldViewModel? LimitDownloadRateField { get; }
public ConfigFieldViewModel? DownloadLimitInMbPerSecField { get; }
public ConfigFieldViewModel? FolderPerPaidPostField { get; }
public ConfigFieldViewModel? FolderPerPostField { get; }
public ConfigFieldViewModel? FolderPerPaidMessageField { get; }
public ConfigFieldViewModel? FolderPerMessageField { get; }
public bool HasSpecificDateFilterFields =>
DownloadOnlySpecificDatesField != null &&
DownloadDateSelectionField != null &&
CustomDateField != null;
public bool HasDownloadVideoResolutionField => DownloadVideoResolutionField != null;
public bool HasRateLimitFields =>
LimitDownloadRateField != null &&
DownloadLimitInMbPerSecField != null;
public bool HasFolderStructureFields =>
FolderPerPaidPostField != null &&
FolderPerPostField != null &&
FolderPerPaidMessageField != null &&
FolderPerMessageField != null;
public string SpecificDateFilterHelpText => SpecificDateFilterOptionHelpText;
public string RateLimitHelpText
{
get
{
List<string> parts = [];
if (!string.IsNullOrWhiteSpace(LimitDownloadRateField?.HelpText))
{
parts.Add(LimitDownloadRateField.HelpText);
}
if (!string.IsNullOrWhiteSpace(DownloadLimitInMbPerSecField?.HelpText))
{
parts.Add(DownloadLimitInMbPerSecField.HelpText);
}
return string.Join(" ", parts.Distinct(StringComparer.Ordinal));
}
}
public string FolderStructureHelpText => FolderStructureOptionHelpText;
public bool HasSpecificDateFilterHelpText => !string.IsNullOrWhiteSpace(SpecificDateFilterHelpText);
public bool HasRateLimitHelpText => !string.IsNullOrWhiteSpace(RateLimitHelpText);
public bool HasFolderStructureHelpText => !string.IsNullOrWhiteSpace(FolderStructureHelpText);
public ObservableCollection<ConfigFieldViewModel> Fields { get; } = [];
}

View File

@ -1,546 +0,0 @@
using System.Collections.ObjectModel;
using System.Reflection;
using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Newtonsoft.Json;
using OF_DL.Models.Config;
namespace OF_DL.Gui.ViewModels;
public partial class ConfigFieldViewModel : ViewModelBase
{
private const string NoListSelectedValue = "";
private const string NoListSelectedDisplayName = "(No list selected)";
private static readonly Regex s_fileNameVariableRegex = new(@"\{([^{}]+)\}", RegexOptions.Compiled);
private static readonly Dictionary<string, string[]> s_fileNameVariablesByConfigOption =
new(StringComparer.Ordinal)
{
[nameof(Config.PaidPostFileNameFormat)] =
[
"id", "postedAt", "mediaId", "mediaCreatedAt", "filename", "username", "text"
],
[nameof(Config.PostFileNameFormat)] =
[
"id", "postedAt", "mediaId", "mediaCreatedAt", "filename", "username", "text", "rawText"
],
[nameof(Config.PaidMessageFileNameFormat)] =
[
"id", "createdAt", "mediaId", "mediaCreatedAt", "filename", "username", "text"
],
[nameof(Config.MessageFileNameFormat)] =
[
"id", "createdAt", "mediaId", "mediaCreatedAt", "filename", "username", "text"
]
};
private static readonly Dictionary<string, string> s_enumDisplayNames =
new(StringComparer.Ordinal)
{
["_240"] = "240p",
["_720"] = "720p",
["source"] = "Source Resolution",
["light"] = "Light",
["dark"] = "Dark"
};
private static readonly Dictionary<string, string> s_displayNameOverridesByProperty =
new(StringComparer.Ordinal)
{
[nameof(Config.PostFileNameFormat)] = "Free Post File Name Format",
[nameof(Config.MessageFileNameFormat)] = "Free Message File Name Format",
[nameof(Config.DownloadVideoResolution)] = "Video Resolution",
[nameof(Config.IgnoredUsersListName)] = "Ignored Users List",
[nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected)] =
"Rename Existing Files with Custom Formats",
[nameof(Config.DownloadPath)] = "Download Folder",
[nameof(Config.HideMissingCdmKeysWarning)] = "Hide Missing CDM Keys Warning"
};
public ConfigFieldViewModel(
PropertyInfo propertyInfo,
object? initialValue,
IEnumerable<string>? ignoredUsersListNames = null,
string? helpText = null)
{
PropertyInfo = propertyInfo;
PropertyName = propertyInfo.Name;
DisplayName = GetDisplayName(propertyInfo.Name);
PropertyType = propertyInfo.PropertyType;
HelpText = helpText?.Trim() ?? string.Empty;
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))
{
string displayName = s_enumDisplayNames.TryGetValue(enumName, out string? mappedName)
? mappedName
: enumName;
EnumOptions.Add(displayName);
}
}
if (IsFileNameFormatField)
{
foreach (string variableName in GetAllowedFileNameVariables())
{
AvailableFileNameVariables.Add(variableName);
}
SelectedFileNameVariable = AvailableFileNameVariables.FirstOrDefault();
const string fileNameHelpText = "Include {mediaId} or {filename} to avoid filename collisions.";
HelpText = string.IsNullOrWhiteSpace(HelpText)
? fileNameHelpText
: $"{HelpText} {fileNameHelpText}";
}
LoadInitialValue(initialValue);
if (IsIgnoredUsersListField)
{
SetIgnoredUsersListOptions(ignoredUsersListNames ?? []);
}
if (IsFileNameFormatField)
{
UpdateFileNameFormatPreview();
}
}
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 IsNumericAndNotTimeout => IsNumeric && !IsTimeoutField;
public bool IsTextInput { get; }
public bool IsMultiline { get; }
public bool IsFileNameFormatField => s_fileNameVariablesByConfigOption.ContainsKey(PropertyName);
public bool IsCreatorConfigsField =>
string.Equals(PropertyName, nameof(Config.CreatorConfigs), StringComparison.Ordinal);
public bool IsIgnoredUsersListField =>
string.Equals(PropertyName, nameof(Config.IgnoredUsersListName), StringComparison.Ordinal);
public bool IsTimeoutField =>
string.Equals(PropertyName, nameof(Config.Timeout), StringComparison.Ordinal);
public bool IsHideMissingCdmKeysWarningField =>
string.Equals(PropertyName, nameof(Config.HideMissingCdmKeysWarning), StringComparison.Ordinal);
public bool IsRegularTextInput =>
IsTextInput && !IsIgnoredUsersListField && !IsCreatorConfigsField && !IsFileNameFormatField;
public bool HasHelpText => !string.IsNullOrWhiteSpace(HelpText);
public double TextBoxMinHeight => IsMultiline ? 150 : 36;
public ObservableCollection<string> EnumOptions { get; } = [];
public ObservableCollection<string> AvailableFileNameVariables { get; } = [];
public ObservableCollection<ConfigSelectOptionViewModel> IgnoredUsersListOptions { get; } = [];
public ObservableCollection<FileNameFormatSegmentViewModel> FileNameFormatSegments { get; } = [];
[ObservableProperty] private bool _boolValue;
[ObservableProperty] private string? _enumValue;
[ObservableProperty] private DateTimeOffset? _dateValue;
[ObservableProperty] private decimal? _numericValue;
private string _actualTextValue = string.Empty;
[ObservableProperty] private string _textValue = string.Empty;
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(InsertSelectedFileNameVariableCommand))]
private string? _selectedFileNameVariable;
private bool IsPathField =>
string.Equals(PropertyName, nameof(Config.FFmpegPath), StringComparison.Ordinal) ||
string.Equals(PropertyName, nameof(Config.DownloadPath), StringComparison.Ordinal);
[ObservableProperty] private ConfigSelectOptionViewModel? _selectedIgnoredUsersListOption;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasHelpText))]
private string _helpText = string.Empty;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasUnknownFileNameVariables))]
private string _unknownFileNameVariablesMessage = string.Empty;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string _errorMessage = string.Empty;
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
public bool HasUnknownFileNameVariables => !string.IsNullOrWhiteSpace(UnknownFileNameVariablesMessage);
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;
}
string actualEnumValue = s_enumDisplayNames
.FirstOrDefault(kvp => string.Equals(kvp.Value, EnumValue, StringComparison.Ordinal))
.Key ?? EnumValue;
if (!Enum.TryParse(PropertyType, actualEnumValue, true, out object? enumResult))
{
error = $"{DisplayName} must be one of: {string.Join(", ", EnumOptions)}.";
return false;
}
value = enumResult;
return true;
}
if (PropertyType == typeof(string))
{
// Use actual value for path fields when privacy mode is enabled
string textToUse = Program.HidePrivateInfo && IsPathField ? _actualTextValue : TextValue;
value = textToUse.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;
public void SetIgnoredUsersListOptions(IEnumerable<string> listNames)
{
if (!IsIgnoredUsersListField)
{
return;
}
string selectedValue = SelectedIgnoredUsersListOption?.Value ?? TextValue.Trim();
ConfigSelectOptionViewModel noSelectionOption =
new(NoListSelectedValue, NoListSelectedDisplayName);
IgnoredUsersListOptions.Clear();
IgnoredUsersListOptions.Add(noSelectionOption);
IEnumerable<string> distinctNames = listNames
.Where(listName => !string.IsNullOrWhiteSpace(listName))
.Distinct(StringComparer.Ordinal)
.OrderBy(listName => listName, StringComparer.OrdinalIgnoreCase);
foreach (string listName in distinctNames)
{
IgnoredUsersListOptions.Add(new ConfigSelectOptionViewModel(listName, listName));
}
ConfigSelectOptionViewModel selectedOption =
IgnoredUsersListOptions.FirstOrDefault(option =>
string.Equals(option.Value, selectedValue, StringComparison.Ordinal)) ?? noSelectionOption;
SelectedIgnoredUsersListOption = selectedOption;
TextValue = selectedOption.Value;
}
[RelayCommand(CanExecute = nameof(CanInsertSelectedFileNameVariable))]
private void InsertSelectedFileNameVariable()
{
if (string.IsNullOrWhiteSpace(SelectedFileNameVariable))
{
return;
}
string placeholder = $"{{{SelectedFileNameVariable}}}";
TextValue += placeholder;
}
partial void OnTextValueChanged(string value)
{
// Store actual value if not the privacy placeholder
if (value != "[Hidden for Privacy]")
{
_actualTextValue = value;
}
if (!IsFileNameFormatField)
{
return;
}
UpdateFileNameFormatPreview();
}
private bool CanInsertSelectedFileNameVariable() =>
IsFileNameFormatField && !string.IsNullOrWhiteSpace(SelectedFileNameVariable);
partial void OnSelectedIgnoredUsersListOptionChanged(ConfigSelectOptionViewModel? value)
{
if (!IsIgnoredUsersListField)
{
return;
}
TextValue = value?.Value ?? NoListSelectedValue;
}
private void LoadInitialValue(object? initialValue)
{
if (IsBoolean)
{
BoolValue = initialValue is bool boolValue && boolValue;
return;
}
if (IsEnum)
{
string? enumName = initialValue?.ToString();
if (!string.IsNullOrEmpty(enumName))
{
string displayName = s_enumDisplayNames.TryGetValue(enumName, out string? mappedName)
? mappedName
: enumName;
EnumValue = displayName;
}
else
{
EnumValue = 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;
}
string initialText = initialValue?.ToString() ?? string.Empty;
_actualTextValue = initialText;
// Show privacy placeholder for path fields if flag is set
if (Program.HidePrivateInfo && IsPathField && !string.IsNullOrWhiteSpace(initialText))
{
TextValue = "[Hidden for Privacy]";
}
else
{
TextValue = initialText;
}
}
private IEnumerable<string> GetAllowedFileNameVariables() =>
s_fileNameVariablesByConfigOption.TryGetValue(PropertyName, out string[]? variables)
? variables
: [];
private void UpdateFileNameFormatPreview()
{
FileNameFormatSegments.Clear();
UnknownFileNameVariablesMessage = string.Empty;
if (string.IsNullOrEmpty(TextValue))
{
return;
}
HashSet<string> allowedVariables = new(GetAllowedFileNameVariables(), StringComparer.OrdinalIgnoreCase);
HashSet<string> unknownVariables = new(StringComparer.OrdinalIgnoreCase);
(string PlainTextColor, string AllowedVariableColor, string InvalidVariableColor) = GetFileNamePreviewColors();
MatchCollection matches = s_fileNameVariableRegex.Matches(TextValue);
int currentIndex = 0;
foreach (Match match in matches)
{
if (match.Index > currentIndex)
{
string plainText = TextValue[currentIndex..match.Index];
FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(plainText, PlainTextColor));
}
string variableName = match.Groups[1].Value;
bool isAllowedVariable = allowedVariables.Contains(variableName);
FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(match.Value,
isAllowedVariable ? AllowedVariableColor : InvalidVariableColor));
if (!isAllowedVariable)
{
unknownVariables.Add(variableName);
}
currentIndex = match.Index + match.Length;
}
if (currentIndex < TextValue.Length)
{
string trailingText = TextValue[currentIndex..];
FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(trailingText, PlainTextColor));
}
if (unknownVariables.Count > 0)
{
string tokens = string.Join(", ", unknownVariables.Select(variable => $"{{{variable}}}"));
UnknownFileNameVariablesMessage = $"Unknown variable(s): {tokens}";
}
}
private static (string PlainTextColor, string AllowedVariableColor, string InvalidVariableColor)
GetFileNamePreviewColors()
{
bool isDarkTheme = Application.Current?.RequestedThemeVariant == ThemeVariant.Dark;
return isDarkTheme
? ("#DCE6F7", "#66A6FF", "#FF8C8C")
: ("#1F2A44", "#2E6EEA", "#D84E4E");
}
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()));
}
private static string GetDisplayName(string propertyName) =>
s_displayNameOverridesByProperty.TryGetValue(propertyName, out string? displayName)
? displayName
: ToDisplayName(propertyName);
}

View File

@ -1,14 +0,0 @@
namespace OF_DL.Gui.ViewModels;
public sealed class ConfigSelectOptionViewModel
{
public ConfigSelectOptionViewModel(string value, string displayName)
{
Value = value;
DisplayName = displayName;
}
public string Value { get; }
public string DisplayName { get; }
}

View File

@ -1,107 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using OF_DL.Models.Config;
using Serilog;
namespace OF_DL.Gui.ViewModels;
public partial class CreatorConfigEditorViewModel : ViewModelBase
{
private CreatorConfigRowViewModel? _editingRow;
public ObservableCollection<CreatorConfigRowViewModel> Rows { get; } = [];
public ObservableCollection<string> AvailableUsers { get; }
[ObservableProperty] private CreatorConfigModalViewModel _modalViewModel;
public CreatorConfigEditorViewModel(IEnumerable<string> availableUsers)
{
AvailableUsers = new ObservableCollection<string>(availableUsers);
ModalViewModel = new CreatorConfigModalViewModel(AvailableUsers, OnModalClose, IsUsernameDuplicate);
}
public void LoadFromConfig(Dictionary<string, CreatorConfig> configs)
{
Rows.Clear();
foreach (KeyValuePair<string, CreatorConfig> kvp in configs.OrderBy(c => c.Key,
StringComparer.OrdinalIgnoreCase))
{
Rows.Add(new CreatorConfigRowViewModel(kvp.Key, kvp.Value, OnDeleteRow, OnEditRow));
}
}
public Dictionary<string, CreatorConfig> ToDictionary()
{
Dictionary<string, CreatorConfig> result = new(StringComparer.OrdinalIgnoreCase);
foreach (CreatorConfigRowViewModel row in Rows)
{
result[row.Username] = row.Config;
}
return result;
}
public void UpdateAvailableUsers(IEnumerable<string> users)
{
AvailableUsers.Clear();
foreach (string user in users.OrderBy(u => u, StringComparer.OrdinalIgnoreCase))
{
AvailableUsers.Add(user);
}
}
[RelayCommand]
private void AddCreator()
{
Log.Information("AddCreator command executed");
_editingRow = null;
ModalViewModel.OpenForAdd();
}
private void OnDeleteRow(CreatorConfigRowViewModel row) => Rows.Remove(row);
private void OnEditRow(CreatorConfigRowViewModel row)
{
_editingRow = row;
ModalViewModel.OpenForEdit(row.Username, row.Config);
}
private bool IsUsernameDuplicate()
{
string username = ModalViewModel.Username.Trim();
if (_editingRow != null && string.Equals(_editingRow.Username, username, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return Rows.Any(r => string.Equals(r.Username, username, StringComparison.OrdinalIgnoreCase));
}
private void OnModalClose(bool confirmed)
{
if (!confirmed)
{
ModalViewModel.IsOpen = false;
return;
}
(string Username, CreatorConfig Config)? result = ModalViewModel.GetResult();
if (result == null)
{
return;
}
if (_editingRow != null)
{
_editingRow.Username = result.Value.Username;
_editingRow.Config = result.Value.Config;
}
else
{
Rows.Add(new CreatorConfigRowViewModel(result.Value.Username, result.Value.Config, OnDeleteRow, OnEditRow));
}
ModalViewModel.IsOpen = false;
}
}

View File

@ -1,433 +0,0 @@
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using OF_DL.Models.Config;
using Serilog;
namespace OF_DL.Gui.ViewModels;
public partial class CreatorConfigModalViewModel : ViewModelBase
{
private static readonly Regex s_fileNameVariableRegex = new(@"\{([^{}]+)\}", RegexOptions.Compiled);
private static readonly string[] s_postFileNameVariables = ["id", "postedAt", "mediaId", "mediaCreatedAt", "filename", "username", "text"];
private static readonly string[] s_messageFileNameVariables = ["id", "createdAt", "mediaId", "mediaCreatedAt", "filename", "username", "text"];
private readonly Action<bool> _onClose;
private readonly Func<bool> _isUsernameDuplicate;
private bool _isNormalizingUsername;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isEditMode;
[ObservableProperty] private string _originalUsername = string.Empty;
[ObservableProperty] private string _username = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasUsernameError))]
private string _usernameError = string.Empty;
[ObservableProperty] private string _paidPostFileNameFormat = string.Empty;
[ObservableProperty] private string _postFileNameFormat = string.Empty;
[ObservableProperty] private string _paidMessageFileNameFormat = string.Empty;
[ObservableProperty] private string _messageFileNameFormat = string.Empty;
[ObservableProperty] private string _selectedPaidPostVariable = string.Empty;
[ObservableProperty] private string _selectedPostVariable = string.Empty;
[ObservableProperty] private string _selectedPaidMessageVariable = string.Empty;
[ObservableProperty] private string _selectedMessageVariable = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasUnknownPaidPostVariables))]
private string _unknownPaidPostVariablesMessage = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasUnknownPostVariables))]
private string _unknownPostVariablesMessage = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasUnknownPaidMessageVariables))]
private string _unknownPaidMessageVariablesMessage = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasUnknownMessageVariables))]
private string _unknownMessageVariablesMessage = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPaidPostFileNameFormatError))]
private string _paidPostFileNameFormatError = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPostFileNameFormatError))]
private string _postFileNameFormatError = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPaidMessageFileNameFormatError))]
private string _paidMessageFileNameFormatError = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMessageFileNameFormatError))]
private string _messageFileNameFormatError = string.Empty;
public ObservableCollection<string> AvailableUsers { get; }
public ObservableCollection<string> PaidPostVariables { get; } = [];
public ObservableCollection<string> PostVariables { get; } = [];
public ObservableCollection<string> PaidMessageVariables { get; } = [];
public ObservableCollection<string> MessageVariables { get; } = [];
public ObservableCollection<FileNameFormatSegmentViewModel> PaidPostSegments { get; } = [];
public ObservableCollection<FileNameFormatSegmentViewModel> PostSegments { get; } = [];
public ObservableCollection<FileNameFormatSegmentViewModel> PaidMessageSegments { get; } = [];
public ObservableCollection<FileNameFormatSegmentViewModel> MessageSegments { get; } = [];
public bool HasUsernameError => !string.IsNullOrWhiteSpace(UsernameError);
public bool HasUnknownPaidPostVariables => !string.IsNullOrWhiteSpace(UnknownPaidPostVariablesMessage);
public bool HasUnknownPostVariables => !string.IsNullOrWhiteSpace(UnknownPostVariablesMessage);
public bool HasUnknownPaidMessageVariables => !string.IsNullOrWhiteSpace(UnknownPaidMessageVariablesMessage);
public bool HasUnknownMessageVariables => !string.IsNullOrWhiteSpace(UnknownMessageVariablesMessage);
public bool HasPaidPostFileNameFormatError => !string.IsNullOrWhiteSpace(PaidPostFileNameFormatError);
public bool HasPostFileNameFormatError => !string.IsNullOrWhiteSpace(PostFileNameFormatError);
public bool HasPaidMessageFileNameFormatError => !string.IsNullOrWhiteSpace(PaidMessageFileNameFormatError);
public bool HasMessageFileNameFormatError => !string.IsNullOrWhiteSpace(MessageFileNameFormatError);
public string DialogTitle => IsEditMode ? "Edit Creator Config" : "Add Creator Config";
public CreatorConfigModalViewModel(IEnumerable<string> availableUsers, Action<bool> onClose, Func<bool> isUsernameDuplicate)
{
AvailableUsers = new ObservableCollection<string>(availableUsers);
_onClose = onClose;
_isUsernameDuplicate = isUsernameDuplicate;
foreach (string variable in s_postFileNameVariables)
{
PaidPostVariables.Add(variable);
PostVariables.Add(variable);
}
foreach (string variable in s_messageFileNameVariables)
{
PaidMessageVariables.Add(variable);
MessageVariables.Add(variable);
}
SelectedPaidPostVariable = PaidPostVariables.FirstOrDefault() ?? string.Empty;
SelectedPostVariable = PostVariables.FirstOrDefault() ?? string.Empty;
SelectedPaidMessageVariable = PaidMessageVariables.FirstOrDefault() ?? string.Empty;
SelectedMessageVariable = MessageVariables.FirstOrDefault() ?? string.Empty;
}
public void OpenForAdd()
{
Log.Information("=== OpenForAdd called ===");
IsEditMode = false;
OriginalUsername = string.Empty;
Username = string.Empty;
PaidPostFileNameFormat = string.Empty;
PostFileNameFormat = string.Empty;
PaidMessageFileNameFormat = string.Empty;
MessageFileNameFormat = string.Empty;
ClearValidationErrors();
ClearAllPreviews();
Log.Information("About to set IsOpen = true");
IsOpen = true;
Log.Information("=== OpenForAdd: IsOpen is now {IsOpen} ===", IsOpen);
}
public void OpenForEdit(string username, CreatorConfig config)
{
IsEditMode = true;
OriginalUsername = username;
Username = username;
PaidPostFileNameFormat = config.PaidPostFileNameFormat ?? string.Empty;
PostFileNameFormat = config.PostFileNameFormat ?? string.Empty;
PaidMessageFileNameFormat = config.PaidMessageFileNameFormat ?? string.Empty;
MessageFileNameFormat = config.MessageFileNameFormat ?? string.Empty;
ClearValidationErrors();
UpdateAllPreviews();
IsOpen = true;
}
public (string Username, CreatorConfig Config)? GetResult()
{
if (!Validate())
{
return null;
}
CreatorConfig config = new()
{
PaidPostFileNameFormat = string.IsNullOrWhiteSpace(PaidPostFileNameFormat) ? null : PaidPostFileNameFormat,
PostFileNameFormat = string.IsNullOrWhiteSpace(PostFileNameFormat) ? null : PostFileNameFormat,
PaidMessageFileNameFormat = string.IsNullOrWhiteSpace(PaidMessageFileNameFormat) ? null : PaidMessageFileNameFormat,
MessageFileNameFormat = string.IsNullOrWhiteSpace(MessageFileNameFormat) ? null : MessageFileNameFormat
};
return (Username.Trim(), config);
}
[RelayCommand]
private void InsertPaidPostVariable()
{
if (!string.IsNullOrWhiteSpace(SelectedPaidPostVariable))
{
PaidPostFileNameFormat += $"{{{SelectedPaidPostVariable}}}";
}
}
[RelayCommand]
private void InsertPostVariable()
{
if (!string.IsNullOrWhiteSpace(SelectedPostVariable))
{
PostFileNameFormat += $"{{{SelectedPostVariable}}}";
}
}
[RelayCommand]
private void InsertPaidMessageVariable()
{
if (!string.IsNullOrWhiteSpace(SelectedPaidMessageVariable))
{
PaidMessageFileNameFormat += $"{{{SelectedPaidMessageVariable}}}";
}
}
[RelayCommand]
private void InsertMessageVariable()
{
if (!string.IsNullOrWhiteSpace(SelectedMessageVariable))
{
MessageFileNameFormat += $"{{{SelectedMessageVariable}}}";
}
}
[RelayCommand]
private void Confirm()
{
if (Validate())
{
_onClose(true);
}
}
[RelayCommand]
private void Cancel()
{
_onClose(false);
}
partial void OnIsOpenChanged(bool value)
{
Log.Information("*** IsOpen property changed to: {Value} ***", value);
}
partial void OnUsernameChanged(string value)
{
if (_isNormalizingUsername)
{
return;
}
string trimmed = value.Trim();
if (!string.Equals(value, trimmed, StringComparison.Ordinal))
{
_isNormalizingUsername = true;
Username = trimmed;
_isNormalizingUsername = false;
}
UsernameError = string.Empty;
}
partial void OnPaidPostFileNameFormatChanged(string value) =>
HandleFileNameFormatChanged(
() => PaidPostFileNameFormatError = string.Empty,
UpdatePaidPostPreview);
partial void OnPostFileNameFormatChanged(string value) =>
HandleFileNameFormatChanged(
() => PostFileNameFormatError = string.Empty,
UpdatePostPreview);
partial void OnPaidMessageFileNameFormatChanged(string value) =>
HandleFileNameFormatChanged(
() => PaidMessageFileNameFormatError = string.Empty,
UpdatePaidMessagePreview);
partial void OnMessageFileNameFormatChanged(string value) =>
HandleFileNameFormatChanged(
() => MessageFileNameFormatError = string.Empty,
UpdateMessagePreview);
private bool Validate()
{
ClearValidationErrors();
TrimInputValues();
bool isValid = true;
if (string.IsNullOrWhiteSpace(Username))
{
UsernameError = "Username is required.";
isValid = false;
}
string trimmedUsername = Username.Trim();
if (isValid && (!IsEditMode || trimmedUsername != OriginalUsername))
{
if (_isUsernameDuplicate())
{
UsernameError = "A config for this username already exists.";
isValid = false;
}
}
ValidateFileNameFormatUniqueness(PaidPostFileNameFormat, message => PaidPostFileNameFormatError = message,
ref isValid);
ValidateFileNameFormatUniqueness(PostFileNameFormat, message => PostFileNameFormatError = message,
ref isValid);
ValidateFileNameFormatUniqueness(PaidMessageFileNameFormat, message => PaidMessageFileNameFormatError = message,
ref isValid);
ValidateFileNameFormatUniqueness(MessageFileNameFormat, message => MessageFileNameFormatError = message,
ref isValid);
return isValid;
}
private void ClearValidationErrors()
{
UsernameError = string.Empty;
PaidPostFileNameFormatError = string.Empty;
PostFileNameFormatError = string.Empty;
PaidMessageFileNameFormatError = string.Empty;
MessageFileNameFormatError = string.Empty;
}
private void TrimInputValues()
{
Username = Username.Trim();
PaidPostFileNameFormat = PaidPostFileNameFormat.Trim();
PostFileNameFormat = PostFileNameFormat.Trim();
PaidMessageFileNameFormat = PaidMessageFileNameFormat.Trim();
MessageFileNameFormat = MessageFileNameFormat.Trim();
}
private static void ValidateFileNameFormatUniqueness(
string format,
Action<string> setError,
ref bool isValid)
{
if (string.IsNullOrWhiteSpace(format))
{
setError(string.Empty);
return;
}
bool hasUniqueToken = format.Contains("{mediaId}", StringComparison.OrdinalIgnoreCase) ||
format.Contains("{filename}", StringComparison.OrdinalIgnoreCase);
if (hasUniqueToken)
{
setError(string.Empty);
return;
}
setError("Format must include {mediaId} or {filename} to avoid file collisions.");
isValid = false;
}
private static void HandleFileNameFormatChanged(
Action clearError,
Action updatePreview)
{
clearError();
updatePreview();
}
private void ClearAllPreviews()
{
PaidPostSegments.Clear();
PostSegments.Clear();
PaidMessageSegments.Clear();
MessageSegments.Clear();
UnknownPaidPostVariablesMessage = string.Empty;
UnknownPostVariablesMessage = string.Empty;
UnknownPaidMessageVariablesMessage = string.Empty;
UnknownMessageVariablesMessage = string.Empty;
}
private void UpdateAllPreviews()
{
UpdatePaidPostPreview();
UpdatePostPreview();
UpdatePaidMessagePreview();
UpdateMessagePreview();
}
private void UpdatePaidPostPreview() =>
UpdateFileNamePreview(PaidPostFileNameFormat, PaidPostSegments, s_postFileNameVariables, msg => UnknownPaidPostVariablesMessage = msg);
private void UpdatePostPreview() =>
UpdateFileNamePreview(PostFileNameFormat, PostSegments, s_postFileNameVariables, msg => UnknownPostVariablesMessage = msg);
private void UpdatePaidMessagePreview() =>
UpdateFileNamePreview(PaidMessageFileNameFormat, PaidMessageSegments, s_messageFileNameVariables, msg => UnknownPaidMessageVariablesMessage = msg);
private void UpdateMessagePreview() =>
UpdateFileNamePreview(MessageFileNameFormat, MessageSegments, s_messageFileNameVariables, msg => UnknownMessageVariablesMessage = msg);
private void UpdateFileNamePreview(string format, ObservableCollection<FileNameFormatSegmentViewModel> segments, string[] allowedVariables, Action<string> setUnknownMessage)
{
segments.Clear();
setUnknownMessage(string.Empty);
if (string.IsNullOrEmpty(format))
{
return;
}
HashSet<string> allowedSet = new(allowedVariables, StringComparer.OrdinalIgnoreCase);
HashSet<string> unknownVariables = new(StringComparer.OrdinalIgnoreCase);
(string PlainTextColor, string AllowedVariableColor, string InvalidVariableColor) = GetFileNamePreviewColors();
MatchCollection matches = s_fileNameVariableRegex.Matches(format);
int currentIndex = 0;
foreach (Match match in matches)
{
if (match.Index > currentIndex)
{
string plainText = format[currentIndex..match.Index];
segments.Add(new FileNameFormatSegmentViewModel(plainText, PlainTextColor));
}
string variableName = match.Groups[1].Value;
bool isAllowed = allowedSet.Contains(variableName);
segments.Add(new FileNameFormatSegmentViewModel(match.Value,
isAllowed ? AllowedVariableColor : InvalidVariableColor));
if (!isAllowed)
{
unknownVariables.Add(variableName);
}
currentIndex = match.Index + match.Length;
}
if (currentIndex < format.Length)
{
string trailingText = format[currentIndex..];
segments.Add(new FileNameFormatSegmentViewModel(trailingText, PlainTextColor));
}
if (unknownVariables.Count > 0)
{
string tokens = string.Join(", ", unknownVariables.Select(v => $"{{{v}}}"));
setUnknownMessage($"Unknown variable(s): {tokens}");
}
}
private static (string PlainTextColor, string AllowedVariableColor, string InvalidVariableColor)
GetFileNamePreviewColors()
{
bool isDarkTheme = Application.Current?.RequestedThemeVariant == ThemeVariant.Dark;
return isDarkTheme
? ("#DCE6F7", "#66A6FF", "#FF8C8C")
: ("#1F2A44", "#2E6EEA", "#D84E4E");
}
}

View File

@ -1,28 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using OF_DL.Models.Config;
namespace OF_DL.Gui.ViewModels;
public partial class CreatorConfigRowViewModel : ViewModelBase
{
private readonly Action<CreatorConfigRowViewModel> _onDelete;
private readonly Action<CreatorConfigRowViewModel> _onEdit;
[ObservableProperty] private string _username;
[ObservableProperty] private CreatorConfig _config;
public CreatorConfigRowViewModel(string username, CreatorConfig config, Action<CreatorConfigRowViewModel> onDelete, Action<CreatorConfigRowViewModel> onEdit)
{
_username = username;
_config = config;
_onDelete = onDelete;
_onEdit = onEdit;
}
[RelayCommand]
private void Delete() => _onDelete(this);
[RelayCommand]
private void Edit() => _onEdit(this);
}

View File

@ -1,14 +0,0 @@
namespace OF_DL.Gui.ViewModels;
public sealed class FileNameFormatSegmentViewModel
{
public FileNameFormatSegmentViewModel(string text, string foreground)
{
Text = text;
Foreground = foreground;
}
public string Text { get; }
public string Foreground { get; }
}

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace OF_DL.Gui.ViewModels;
public partial class MultiSelectOptionViewModel : ViewModelBase
{
public MultiSelectOptionViewModel(string displayName, string propertyName, bool isSelected, string? helpText = null)
{
DisplayName = displayName;
PropertyName = propertyName;
IsSelected = isSelected;
HelpText = helpText?.Trim() ?? string.Empty;
}
public string DisplayName { get; }
public string PropertyName { get; }
public string HelpText { get; }
public bool HasHelpText => !string.IsNullOrWhiteSpace(HelpText);
[ObservableProperty] private bool _isSelected;
}

View File

@ -1,20 +0,0 @@
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;
public event EventHandler? SelectionChanged;
[ObservableProperty]
private bool _isSelected;
partial void OnIsSelectedChanged(bool value)
{
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

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

View File

@ -1,148 +0,0 @@
<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:views="using:OF_DL.Gui.Views"
x:Class="OF_DL.Gui.Views.AboutWindow"
x:DataType="views:AboutWindow"
Width="600"
Height="520"
MinWidth="550"
MinHeight="420"
Title="About OF DL"
Background="{DynamicResource WindowBackgroundBrush}"
mc:Ignorable="d">
<Window.Styles>
<Style Selector="Button.link">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonBackgroundBrush}" />
<Setter Property="Padding" Value="0" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button.link:pointerover">
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonBackgroundHoverBrush}" />
</Style>
</Window.Styles>
<Grid Margin="24"
RowDefinitions="Auto,*">
<TextBlock Grid.Row="0"
FontSize="28"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="About OF DL"
Margin="0,0,0,20" />
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="16">
<!-- Application Info Section -->
<Border Background="{DynamicResource SurfaceBackgroundBrush}"
BorderBrush="{DynamicResource SurfaceBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="24"
BoxShadow="0 1 3 0 #0F000000, 0 1 2 -1 #0F000000">
<StackPanel Spacing="12">
<TextBlock FontSize="16"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="Application" />
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0"
FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="Version" />
<TextBlock Grid.Row="0" Grid.Column="1"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="{Binding ProgramVersion}" />
<TextBlock Grid.Row="1" Grid.Column="0"
Margin="0,10,0,0"
FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="Source Code" />
<Button Grid.Row="1" Grid.Column="1"
Margin="0,10,0,0"
Classes="link"
HorizontalAlignment="Left"
Content="{Binding SourceCodeUrl}"
Click="OnOpenSourceCodeClick" />
</Grid>
</StackPanel>
</Border>
<!-- FFmpeg Info Section -->
<Border Background="{DynamicResource SurfaceBackgroundBrush}"
BorderBrush="{DynamicResource SurfaceBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="24"
BoxShadow="0 1 3 0 #0F000000, 0 1 2 -1 #0F000000">
<StackPanel Spacing="12">
<TextBlock FontSize="16"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="External Tools" />
<StackPanel Spacing="12">
<!-- FFmpeg -->
<TextBlock Foreground="{DynamicResource TextPrimaryBrush}"
Text="FFmpeg" />
<Grid ColumnDefinitions="120,*" RowDefinitions="Auto,Auto" Margin="20,0,0,0">
<TextBlock Grid.Row="0" Grid.Column="0"
FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="Version" />
<TextBlock Grid.Row="0" Grid.Column="1"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="{Binding FfmpegVersion}"
TextWrapping="Wrap" />
<TextBlock Grid.Row="1" Grid.Column="0"
Margin="0,8,0,0"
FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="License" />
<Button Grid.Row="1" Grid.Column="1"
Margin="0,8,0,0"
Classes="link"
HorizontalAlignment="Left"
Content="{Binding FfmpegLicenseUrl}"
Click="OnOpenFfmpegLicenseClick" />
</Grid>
<!-- FFprobe -->
<TextBlock Foreground="{DynamicResource TextPrimaryBrush}"
Text="FFprobe"
Margin="0,4,0,0" />
<Grid ColumnDefinitions="120,*" RowDefinitions="Auto,Auto" Margin="20,0,0,0">
<TextBlock Grid.Row="0" Grid.Column="0"
FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="Version" />
<TextBlock Grid.Row="0" Grid.Column="1"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="{Binding FfprobeVersion}"
TextWrapping="Wrap" />
<TextBlock Grid.Row="1" Grid.Column="0"
Margin="0,8,0,0"
FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="License" />
<Button Grid.Row="1" Grid.Column="1"
Margin="0,8,0,0"
Classes="link"
HorizontalAlignment="Left"
Content="{Binding FfprobeLicenseUrl}"
Click="OnOpenFfprobeLicenseClick" />
</Grid>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Window>

View File

@ -1,76 +0,0 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform;
using OF_DL.Gui.Helpers;
using Serilog;
namespace OF_DL.Gui.Views;
public partial class AboutWindow : Window
{
private const string UnknownValue = "Unknown";
private const string UnknownToolVersion = "Not detected";
public string ProgramVersion { get; }
public string FfmpegVersion { get; }
public string FfprobeVersion { get; }
public string SourceCodeUrl { get; } = "https://git.ofdl.tools/sim0n00ps/OF-DL";
public string FfmpegLicenseUrl { get; } = "https://ffmpeg.org/legal.html";
public string FfprobeLicenseUrl { get; } = "https://ffmpeg.org/legal.html";
public AboutWindow()
: this(UnknownValue, UnknownToolVersion, UnknownToolVersion)
{
}
public AboutWindow(
string programVersion,
string ffmpegVersion,
string ffprobeVersion)
{
ProgramVersion = programVersion;
FfmpegVersion = ffmpegVersion;
FfprobeVersion = ffprobeVersion;
InitializeComponent();
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://OF DL.Gui/Assets/icon.ico")));
DataContext = this;
}
private async void OnOpenSourceCodeClick(object? sender, RoutedEventArgs e)
{
try
{
await WebLinkHelper.OpenOrCopyAsync(this, SourceCodeUrl, sender as Control);
}
catch (Exception exception)
{
Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message);
}
}
private async void OnOpenFfmpegLicenseClick(object? sender, RoutedEventArgs e)
{
try
{
await WebLinkHelper.OpenOrCopyAsync(this, FfmpegLicenseUrl, sender as Control);
}
catch (Exception exception)
{
Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message);
}
}
private async void OnOpenFfprobeLicenseClick(object? sender, RoutedEventArgs e)
{
try
{
await WebLinkHelper.OpenOrCopyAsync(this, FfprobeLicenseUrl, sender as Control);
}
catch (Exception exception)
{
Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message);
}
}
}

View File

@ -1,140 +0,0 @@
<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:views="using:OF_DL.Gui.Views"
x:Class="OF_DL.Gui.Views.FaqWindow"
x:DataType="views:FaqWindow"
Width="800"
Height="680"
MinWidth="650"
MinHeight="550"
Title="FAQ"
Background="{DynamicResource WindowBackgroundBrush}"
mc:Ignorable="d">
<Window.Styles>
<Style Selector="Border.faqCard">
<Setter Property="Background" Value="{DynamicResource SurfaceBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SurfaceBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="20" />
<Setter Property="Margin" Value="0,0,0,16" />
<Setter Property="BoxShadow" Value="0 1 3 0 #0F000000, 0 1 2 -1 #0F000000" />
<Setter Property="Transitions">
<Transitions>
<BoxShadowsTransition Property="BoxShadow" Duration="0:0:0.2" />
</Transitions>
</Setter>
</Style>
<Style Selector="Border.faqCard:pointerover">
<Setter Property="BoxShadow" Value="0 4 6 -1 #19000000, 0 2 4 -1 #0F000000" />
</Style>
<Style Selector="TextBlock.question">
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.answer">
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="LineHeight" Value="22" />
</Style>
<Style Selector="Button.link">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonBackgroundBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
<Style Selector="Button.link:pointerover">
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonBackgroundHoverBrush}" />
</Style>
<Style Selector="Border.codeBlock">
<Setter Property="Background" Value="{DynamicResource PreviewBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource PreviewBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="12" />
<Setter Property="Margin" Value="0,8,0,0" />
</Style>
</Window.Styles>
<Grid Margin="24"
RowDefinitions="Auto,*">
<!-- Header -->
<TextBlock Grid.Row="0"
FontSize="32"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="Frequently Asked Questions"
Margin="0,0,0,20" />
<!-- FAQ Items -->
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="views:FaqEntry">
<Border Classes="faqCard">
<StackPanel Spacing="12">
<!-- Question -->
<TextBlock Classes="question"
Text="{Binding Question}" />
<!-- Answer Paragraphs -->
<ItemsControl ItemsSource="{Binding Paragraphs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="answer"
Text="{Binding .}"
Margin="0,0,0,8" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Links -->
<ItemsControl IsVisible="{Binding HasLinks}"
ItemsSource="{Binding Links}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="8" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="views:FaqLink">
<Button Classes="link"
Content="{Binding Label}"
ToolTip.Tip="{Binding Url}"
CommandParameter="{Binding Url}"
Click="OnLinkClick" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Code Snippet -->
<Border IsVisible="{Binding HasCodeSnippet}"
Classes="codeBlock">
<TextBlock Text="{Binding CodeSnippet}"
FontFamily="Consolas,Courier New,monospace"
FontSize="13"
Foreground="{DynamicResource TextPrimaryBrush}"
TextWrapping="Wrap" />
</Border>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Window>

View File

@ -1,149 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform;
using OF_DL.Gui.Helpers;
using OF_DL.Helpers;
using Serilog;
namespace OF_DL.Gui.Views;
public class FaqLink(string label, string url)
{
public string Label { get; } = label;
public string Url { get; } = url;
}
public class FaqEntry
{
public FaqEntry(
string question,
IEnumerable<string> paragraphs,
IEnumerable<FaqLink>? links = null,
string? codeSnippet = null)
{
Question = question;
Paragraphs = paragraphs.ToList();
Links = links?.ToList() ?? [];
CodeSnippet = codeSnippet;
}
public string Question { get; }
public IReadOnlyList<string> Paragraphs { get; }
public IReadOnlyList<FaqLink> Links { get; }
public bool HasLinks => Links.Count > 0;
public string? CodeSnippet { get; }
public bool HasCodeSnippet => !string.IsNullOrWhiteSpace(CodeSnippet);
}
public partial class FaqWindow : Window
{
private const string DockerRunCommand =
"docker run --rm -it -v $HOME/ofdl/data/:/data -v $HOME/ofdl/config/:/config -p 8080:8080 git.ofdl.tools/sim0n00ps/of-dl:latest";
public FaqWindow()
{
InitializeComponent();
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://OF DL.Gui/Assets/icon.ico")));
BuildEntries();
DataContext = this;
}
public ObservableCollection<FaqEntry> Entries { get; } = [];
private void BuildEntries()
{
Entries.Clear();
bool isDocker = EnvironmentHelper.IsRunningInDocker();
bool isWindows = EnvironmentHelper.IsRunningOnWindows();
Entries.Add(new FaqEntry(
"Why are some users missing from the users list?",
[
"Users may be missing from the users list if those subscriptions have expired or are restricted. You can enable expired subscriptions and restricted subscriptions from the \"Subscriptions\" section in \"Configuration\" (accessible from the Edit menu)."
]));
if (!isDocker)
{
Entries.Add(new FaqEntry(
"Where are the downloads saved?",
[
"Content is downloaded to the configured download directory. You can view or change the download directory from the \"Download Behavior\" section in \"Configuration\" (accessible from the Edit menu)."
]));
}
else
{
Entries.Add(new FaqEntry(
"Where are the downloads saved?",
[
"Your docker run command specifies the directory that content will be downloaded to. For instance, given the following docker run command, content will be downloaded to the $HOME/ofdl/data/ directory.",
"Do not change the download directory from the \"Download Behavior\" section in \"Configuration\" (accessible from the Edit menu) unless you know what you are doing. If the download directory is set to a value other than /data, content will not be downloaded where you expect and may not be accessible to you."
],
null,
DockerRunCommand));
}
Entries.Add(new FaqEntry(
"Why am I seeing a warning about CDM keys for DRM videos?",
[
"If you don't have your own CDM keys, a service will be used to download DRM videos. However, it is recommended that you use your own CDM keys for more reliable and faster downloads. The process is very easy if you use the bot on our Discord server.",
"See the documentation on CDM keys for more information:"
],
[
new FaqLink("https://docs.ofdl.tools/config/cdm/#discord-method",
"https://docs.ofdl.tools/config/cdm/#discord-method")
]));
if (isDocker)
{
Entries.Add(new FaqEntry(
"Why am I seeing a FFmpeg or FFprobe is missing error?",
[
"FFmpeg and FFprobe are required for OF DL to function. Both programs are included in the Docker image.",
"If you are seeing this error, the most likely cause is that you have manually defined a FFmpeg Path and/or FFprobe Path in your configuration. To fix this, erase the configured paths in the \"External\" section in \"Configuration\" (accessible from the Edit menu). Once you save the changes, OF DL will automatically detect the correct paths."
]));
}
else if (isWindows)
{
Entries.Add(new FaqEntry(
"Why am I seeing a FFmpeg or FFprobe is missing error?",
[
"FFmpeg and FFprobe are required for OF DL to function. Both programs are included with OF DL in the release .zip file. Make sure both files (ffmpeg.exe and ffprobe.exe) are in the same directory as OF DL.exe.",
"If you have already done this and still see an error, the most likely cause is that you have manually defined a FFmpeg Path and/or FFprobe Path in your configuration. To fix this, erase the configured paths in the \"External\" section in \"Configuration\" (accessible from the Edit menu). Once you save the changes, OF DL will automatically detect the correct paths."
]));
}
else
{
Entries.Add(new FaqEntry(
"Why am I seeing a FFmpeg or FFprobe is missing error?",
[
"FFmpeg and FFprobe are required for OF DL to function. Install both programs.",
"If the FFmpeg and FFprobe paths are not manually defined in the \"External\" section in \"Configuration\" (accessible from the Edit menu), OF DL searches for programs named ffmpeg and ffprobe in the same directory as OF DL and in the directories defined in the PATH environment variable."
]));
}
}
private async void OnLinkClick(object? sender, RoutedEventArgs e)
{
try
{
if (sender is not Button { CommandParameter: string url } || string.IsNullOrWhiteSpace(url))
{
return;
}
await WebLinkHelper.OpenOrCopyAsync(this, url, sender as Control);
}
catch (Exception exception)
{
Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,358 +0,0 @@
using System.Collections.Specialized;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using Avalonia.Threading;
using OF_DL.Helpers;
using OF_DL.Gui.Helpers;
using OF_DL.Gui.ViewModels;
using Serilog;
namespace OF_DL.Gui.Views;
public partial class MainWindow : Window
{
private bool _hasInitialized;
private bool _activityLogAutoScroll = true;
private bool _activityLogUserInteracted;
private bool _isActivityLogProgrammaticScroll;
private ScrollViewer? _activityLogScrollViewer;
private CancellationTokenSource? _copyToastCancellationTokenSource;
public MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel;
public MainWindow()
{
InitializeComponent();
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://OF DL.Gui/Assets/icon.ico")));
// Start maximized if running in Docker
if (EnvironmentHelper.IsRunningInDocker())
{
WindowState = WindowState.Maximized;
}
Opened += OnOpened;
Closed += OnClosed;
}
private async void OnOpened(object? sender, EventArgs e)
{
if (_hasInitialized)
{
return;
}
_hasInitialized = true;
InitializeActivityLogAutoScroll();
if (DataContext is MainWindowViewModel vm)
{
await vm.InitializeAsync();
}
}
private void OnClosed(object? sender, EventArgs e)
{
if (ViewModel != null)
{
ViewModel.ActivityLog.CollectionChanged -= OnActivityLogCollectionChanged;
}
if (_activityLogScrollViewer != null)
{
_activityLogScrollViewer.ScrollChanged -= OnActivityLogScrollChanged;
}
ActivityLogListBox.PointerWheelChanged -= OnActivityLogPointerInteracted;
ActivityLogListBox.PointerPressed -= OnActivityLogPointerInteracted;
_copyToastCancellationTokenSource?.Cancel();
_copyToastCancellationTokenSource?.Dispose();
_copyToastCancellationTokenSource = null;
}
private async void OnBrowseFfmpegPathClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm)
{
return;
}
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 OnBrowseFfprobePathClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm)
{
return;
}
TopLevel? topLevel = GetTopLevel(this);
if (topLevel?.StorageProvider == null)
{
return;
}
IReadOnlyList<IStorageFile> selectedFiles = await topLevel.StorageProvider.OpenFilePickerAsync(
new FilePickerOpenOptions { Title = "Select FFprobe executable", AllowMultiple = false });
IStorageFile? selectedFile = selectedFiles.FirstOrDefault();
if (selectedFile == null)
{
return;
}
string? localPath = selectedFile.TryGetLocalPath();
if (!string.IsNullOrWhiteSpace(localPath))
{
vm.SetFfprobePath(localPath);
return;
}
vm.SetFfprobePath(selectedFile.Name);
}
private async void OnBrowseDownloadPathClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm)
{
return;
}
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);
}
private async void OnJoinDiscordClick(object? sender, RoutedEventArgs e)
{
try
{
await WebLinkHelper.OpenOrCopyAsync(this, Constants.DiscordInviteUrl,
dockerFeedbackAsync: ShowCopyToastAsync);
}
catch (Exception exception)
{
Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message);
}
}
private async void OnDocumentationClick(object? sender, RoutedEventArgs e)
{
try
{
await WebLinkHelper.OpenOrCopyAsync(this, Constants.DocumentationUrl,
dockerFeedbackAsync: ShowCopyToastAsync);
}
catch (Exception exception)
{
Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message);
}
}
private async void OnAuthLegacyMethodsClick(object? sender, RoutedEventArgs e)
{
try
{
await WebLinkHelper.OpenOrCopyAsync(this, Constants.LegacyAuthDocumentationUrl,
dockerFeedbackAsync: ShowCopyToastAsync);
}
catch (Exception exception)
{
Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message);
}
}
private void OnFaqClick(object? sender, RoutedEventArgs e)
{
FaqWindow faqWindow = new() { WindowStartupLocation = WindowStartupLocation.CenterOwner };
faqWindow.Show(this);
}
private void OnAboutClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm)
{
return;
}
AboutWindow aboutWindow = new(vm.ProgramVersion, vm.FfmpegVersion, vm.FfprobeVersion)
{
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
aboutWindow.Show(this);
}
private async Task ShowCopyToastAsync(string message)
{
if (_copyToastCancellationTokenSource != null)
{
await _copyToastCancellationTokenSource.CancelAsync();
_copyToastCancellationTokenSource.Dispose();
}
CancellationTokenSource cancellationTokenSource = new();
_copyToastCancellationTokenSource = cancellationTokenSource;
CopyToastTextBlock.Text = message;
CopyToastBorder.Opacity = 0;
CopyToastBorder.IsVisible = true;
CopyToastBorder.Opacity = 1;
try
{
await Task.Delay(TimeSpan.FromSeconds(2), cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
return;
}
CopyToastBorder.Opacity = 0;
try
{
await Task.Delay(TimeSpan.FromMilliseconds(180), cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
return;
}
if (!cancellationTokenSource.IsCancellationRequested)
{
CopyToastBorder.IsVisible = false;
}
}
private void OnModalOverlayClicked(object? sender, PointerPressedEventArgs e)
{
// Only handle clicks on the overlay itself (the Grid background)
if (DataContext is not MainWindowViewModel vm)
{
return;
}
// Execute cancel command on any open modal
vm.CreatorConfigEditor.ModalViewModel.CancelCommand.Execute(null);
vm.CancelSinglePostOrMessageCommand.Execute(null);
vm.CancelDownloadSelectionWarningCommand.Execute(null);
vm.CancelMissingCdmWarningCommand.Execute(null);
}
private void OnModalContentClicked(object? sender, PointerPressedEventArgs e) =>
// Stop the event from bubbling up to the overlay
e.Handled = true;
private void InitializeActivityLogAutoScroll()
{
if (ViewModel != null)
{
ViewModel.ActivityLog.CollectionChanged -= OnActivityLogCollectionChanged;
ViewModel.ActivityLog.CollectionChanged += OnActivityLogCollectionChanged;
}
_activityLogScrollViewer = ActivityLogListBox.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
if (_activityLogScrollViewer != null)
{
_activityLogScrollViewer.ScrollChanged -= OnActivityLogScrollChanged;
_activityLogScrollViewer.ScrollChanged += OnActivityLogScrollChanged;
}
ActivityLogListBox.PointerWheelChanged -= OnActivityLogPointerInteracted;
ActivityLogListBox.PointerWheelChanged += OnActivityLogPointerInteracted;
ActivityLogListBox.PointerPressed -= OnActivityLogPointerInteracted;
ActivityLogListBox.PointerPressed += OnActivityLogPointerInteracted;
}
private void OnActivityLogCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (!_activityLogAutoScroll)
{
return;
}
Dispatcher.UIThread.Post(ScrollActivityLogToBottom);
}
private void OnActivityLogPointerInteracted(object? sender, PointerEventArgs e) =>
_activityLogUserInteracted = true;
private void OnActivityLogScrollChanged(object? sender, ScrollChangedEventArgs e)
{
if (_isActivityLogProgrammaticScroll || _activityLogScrollViewer == null || !_activityLogUserInteracted)
{
return;
}
_activityLogAutoScroll = IsAtBottom(_activityLogScrollViewer);
_activityLogUserInteracted = false;
}
private void ScrollActivityLogToBottom()
{
if (ViewModel == null || ViewModel.ActivityLog.Count == 0)
{
return;
}
_isActivityLogProgrammaticScroll = true;
try
{
ActivityLogListBox.ScrollIntoView(ViewModel.ActivityLog[^1]);
}
finally
{
_isActivityLogProgrammaticScroll = false;
}
}
private static bool IsAtBottom(ScrollViewer scrollViewer, double tolerance = 2) =>
scrollViewer.Offset.Y + scrollViewer.Viewport.Height >= scrollViewer.Extent.Height - tolerance;
}

View File

@ -1,16 +0,0 @@
# Stealth Script Creation
## Requirements
- NodeJS (with npx CLI tool)
## Instructions
- Open a terminal in this directory (OF DL.Gui/chromium-scripts)
- Run `npx -y extract-stealth-evasions`
- Copy the `stealth.js` file into the other chromium-scripts directory as well (`OF DL.Cli/chromium-scripts/`)
## References
See the readme.md file and source code for the stealth
script [here](https://github.com/berstend/puppeteer-extra/tree/master/packages/extract-stealth-evasions).

File diff suppressed because one or more lines are too long

View File

@ -1,41 +0,0 @@
{
"app-token": "33d57ade8c02dbc5a333db99ff9ae26a",
"static_param": "RyY8GpixStP90t68HWIJ8Qzo745n0hy0",
"prefix": "30586",
"suffix": "67000213",
"checksum_constant": 521,
"checksum_indexes": [
0,
2,
3,
7,
7,
8,
8,
10,
11,
13,
14,
16,
17,
17,
17,
19,
19,
20,
21,
21,
23,
23,
24,
24,
27,
27,
29,
30,
31,
34,
35,
39
]
}

View File

@ -515,7 +515,7 @@ public class ApiServiceTests
MethodInfo method = typeof(ApiService).GetMethod("TryGetDrmInfo",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("TryGetDrmInfo not found.");
object?[] args = [files, null, null, null, null];
object?[] args = { files, null, null, null, null };
bool result = (bool)method.Invoke(null, args)!;
manifestDash = (string)args[1]!;
cloudFrontPolicy = (string)args[2]!;

View File

@ -23,8 +23,6 @@ public class ConfigServiceTests
Assert.Equal(service.CurrentConfig.LoggingLevel, loggingService.LastLevel);
Assert.Equal("", service.CurrentConfig.FFprobePath);
Assert.Equal(0.98, service.CurrentConfig.DrmVideoDurationMatchThreshold, 3);
Assert.Equal(Theme.dark, service.CurrentConfig.Theme);
Assert.False(service.CurrentConfig.HideMissingCdmKeysWarning);
}
[Fact]
@ -82,44 +80,6 @@ public class ConfigServiceTests
Assert.Equal(0.95, service.CurrentConfig.DrmVideoDurationMatchThreshold, 3);
}
[Fact]
public async Task LoadConfigurationAsync_ParsesAppearanceTheme()
{
using TempFolder temp = new();
using CurrentDirectoryScope _ = new(temp.Path);
FakeLoggingService loggingService = new();
ConfigService service = new(loggingService);
await service.SaveConfigurationAsync();
string hocon = await File.ReadAllTextAsync("config.conf");
hocon = hocon.Replace("Theme = \"light\"", "Theme = \"dark\"");
await File.WriteAllTextAsync("config.conf", hocon);
bool result = await service.LoadConfigurationAsync([]);
Assert.True(result);
Assert.Equal(Theme.dark, service.CurrentConfig.Theme);
}
[Fact]
public async Task LoadConfigurationAsync_ParsesHideMissingCdmKeysWarning()
{
using TempFolder temp = new();
using CurrentDirectoryScope _ = new(temp.Path);
FakeLoggingService loggingService = new();
ConfigService service = new(loggingService);
await service.SaveConfigurationAsync();
string hocon = await File.ReadAllTextAsync("config.conf");
hocon = hocon.Replace("HideMissingCdmKeysWarning = false", "HideMissingCdmKeysWarning = true");
await File.WriteAllTextAsync("config.conf", hocon);
bool result = await service.LoadConfigurationAsync([]);
Assert.True(result);
Assert.True(service.CurrentConfig.HideMissingCdmKeysWarning);
}
[Fact]
public void ApplyToggleableSelections_UpdatesConfigAndReturnsChange()
{
@ -142,4 +102,5 @@ public class ConfigServiceTests
Assert.Equal("/downloads", service.CurrentConfig.DownloadPath);
Assert.Equal(LoggingLevel.Warning, loggingService.LastLevel);
}
}

View File

@ -82,10 +82,9 @@ public class DownloadServiceTests
DownloadService service =
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result =
await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
true, false);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
true, false);
Assert.NotNull(result);
Assert.Equal("ofdl-key", result.Value.decryptionKey);
@ -101,10 +100,9 @@ public class DownloadServiceTests
DownloadService service =
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result =
await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
false, false);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
false, false);
Assert.NotNull(result);
Assert.Equal("cdm-key", result.Value.decryptionKey);
@ -126,8 +124,7 @@ public class DownloadServiceTests
await File.WriteAllTextAsync(tempFilename, "abc");
MediaTrackingDbService dbService = new();
DownloadService service =
CreateService(new FakeConfigService(new Config { ShowScrapeSize = false }), dbService);
DownloadService service = CreateService(new FakeConfigService(new Config { ShowScrapeSize = false }), dbService);
ProgressRecorder progress = new();
MethodInfo? finalizeMethod = typeof(DownloadService).GetMethod("FinalizeDrmDownload",
@ -139,7 +136,7 @@ public class DownloadServiceTests
tempFilename, DateTime.UtcNow, folder, path, customFileName, filename, 1L, "Posts", progress
]);
bool result = await Assert.IsType<Task<bool>>(resultObject);
bool result = await Assert.IsType<Task<bool>>(resultObject!);
Assert.True(result);
Assert.True(File.Exists(tempFilename));
Assert.NotNull(dbService.LastUpdateMedia);

View File

@ -42,8 +42,6 @@ internal sealed class ProgressRecorder : IProgressReporter
{
public long Total { get; private set; }
public CancellationToken CancellationToken { get; } = CancellationToken.None;
public void ReportProgress(long increment) => Total += increment;
}
@ -139,53 +137,44 @@ internal sealed class StaticApiService : IApiService
new() { { "X-Test", "value" } };
public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username,
string folder, CancellationToken cancellationToken = default) => Task.FromResult(MediaToReturn);
string folder) => Task.FromResult(MediaToReturn);
public Task<Dictionary<string, long>?> GetLists(string endpoint) => throw new NotImplementedException();
public Task<List<string>?> GetListUsers(string endpoint) => throw new NotImplementedException();
public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
string username, List<long> paidPostIds, IStatusReporter statusReporter,
CancellationToken cancellationToken = default) =>
public Task<OF_DL.Models.Entities.Purchased.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
string username, List<long> paidPostIds, IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
public Task<OF_DL.Models.Entities.Posts.PostCollection> GetPosts(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Posts.SinglePostCollection> GetPost(string endpoint, string folder) =>
throw new NotImplementedException();
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder,
CancellationToken cancellationToken = default) =>
public Task<OF_DL.Models.Entities.Streams.StreamsCollection> GetStreams(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Archived.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Messages.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Purchased.PaidMessageCollection> GetPaidMessages(string endpoint,
string folder, string username, IStatusReporter statusReporter) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Purchased.SinglePaidMessageCollection> GetPaidMessage(string endpoint,
string folder) => throw new NotImplementedException();
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users) =>
throw new NotImplementedException();
public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<List<OF_DL.Models.Entities.Purchased.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
string folder, Dictionary<string, long> users) => throw new NotImplementedException();
public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint,
string folder, string username, IStatusReporter statusReporter,
CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint,
string folder, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
string folder, Dictionary<string, long> users, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default) =>
public Task<UserEntities.User?> GetUserInfo(string endpoint) =>
throw new NotImplementedException();
public Task<JObject?> GetUserInfoById(string endpoint) =>
@ -227,55 +216,52 @@ internal sealed class ConfigurableApiService : IApiService
ListUsersHandler?.Invoke(endpoint) ?? Task.FromResult<List<string>?>(null);
public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username,
string folder, CancellationToken cancellationToken = default) =>
string folder) =>
MediaHandler?.Invoke(mediaType, endpoint, username, folder) ??
Task.FromResult<Dictionary<long, string>?>(null);
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder,
CancellationToken cancellationToken = default) =>
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder) =>
PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection());
public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder,
CancellationToken cancellationToken = default) =>
public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder) =>
PaidMessageHandler?.Invoke(endpoint, folder) ??
Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection());
public Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default) =>
public Task<UserEntities.User?> GetUserInfo(string endpoint) =>
UserInfoHandler?.Invoke(endpoint) ?? Task.FromResult<UserEntities.User?>(null);
public Task<JObject?> GetUserInfoById(string endpoint) =>
UserInfoByIdHandler?.Invoke(endpoint) ?? Task.FromResult<JObject?>(null);
public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
List<long> paidPostIds, IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder,
string username, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
string username, IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users,
CancellationToken cancellationToken = default) =>
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users) =>
throw new NotImplementedException();
public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
Dictionary<string, long> users, CancellationToken cancellationToken = default) =>
Dictionary<string, long> users) =>
throw new NotImplementedException();
public Dictionary<string, string> GetDynamicHeaders(string path, string queryParam) =>
@ -307,8 +293,7 @@ internal sealed class OrchestrationDownloadServiceStub : IDownloadService
string serverFileName, string resolvedFileName, string extension, IProgressReporter progressReporter) =>
throw new NotImplementedException();
public Task<(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)?> GetDecryptionInfo(
string mpdUrl, string policy,
public Task<(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)?> GetDecryptionInfo(string mpdUrl, string policy,
string signature, string kvp, string mediaId, string contentId, string drmType, bool clientIdBlobMissing,
bool devicePrivateKeyMissing) =>
throw new NotImplementedException();
@ -439,8 +424,6 @@ internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
public List<(string contentType, DownloadResult result)> DownloadCompletes { get; } = [];
public List<(string description, long maxValue, bool showSize)> ProgressCalls { get; } = [];
public CancellationToken CancellationToken { get; } = CancellationToken.None;
public Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work) =>
work(new RecordingStatusReporter(statusMessage));
@ -472,9 +455,14 @@ internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
public void OnMessage(string message) => Messages.Add(message);
}
internal sealed class RecordingStatusReporter(string initialStatus) : IStatusReporter
internal sealed class RecordingStatusReporter : IStatusReporter
{
private readonly List<string> _statuses = [initialStatus];
private readonly List<string> _statuses;
public RecordingStatusReporter(string initialStatus)
{
_statuses = [initialStatus];
}
public IReadOnlyList<string> Statuses => _statuses;
@ -497,8 +485,7 @@ internal sealed class FakeAuthService : IAuthService
public Task<bool> LoadFromFileAsync(string filePath = "auth.json") => throw new NotImplementedException();
public Task<bool> LoadFromBrowserAsync(Action<string>? dependencyStatusCallback = null) =>
throw new NotImplementedException();
public Task<bool> LoadFromBrowserAsync() => throw new NotImplementedException();
public Task SaveToFileAsync(string filePath = "auth.json") => throw new NotImplementedException();

View File

@ -3,14 +3,12 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.33516.290
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Cli", "OF DL.Cli\OF DL.Cli.csproj", "{318B2CE3-D046-4276-A2CF-89E6DF32F1B3}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL", "OF DL\OF DL.csproj", "{318B2CE3-D046-4276-A2CF-89E6DF32F1B3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Core", "OF DL.Core\OF DL.Core.csproj", "{7B8B6A26-6732-4B3A-AE62-1CE589DFF8F2}"
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
@ -29,10 +27,6 @@ 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

@ -10,8 +10,6 @@ namespace OF_DL.CLI;
/// </summary>
public class SpectreDownloadEventHandler : IDownloadEventHandler
{
public CancellationToken CancellationToken { get; } = CancellationToken.None;
public async Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work)
{
TaskCompletionSource<T> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);

View File

@ -6,11 +6,9 @@ namespace OF_DL.CLI;
/// <summary>
/// Implementation of IProgressReporter that uses Spectre.Console's ProgressTask for CLI output.
/// </summary>
public class SpectreProgressReporter(ProgressTask task, CancellationToken cancellationToken = default) : IProgressReporter
public class SpectreProgressReporter(ProgressTask task) : IProgressReporter
{
private readonly ProgressTask _task = task ?? throw new ArgumentNullException(nameof(task));
public CancellationToken CancellationToken { get; } = cancellationToken;
public void ReportProgress(long increment) => _task.Increment(increment);
}

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -40,6 +40,12 @@
</ItemGroup>
<ItemGroup>
<None Update="auth.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="config.conf">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="rules.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

View File

@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection;
using OF_DL.CLI;
using OF_DL.Models;
using OF_DL.Enumerations;
using OF_DL.Helpers;
using OF_DL.Models.Config;
using OF_DL.Models.Downloads;
using OF_DL.Models.Entities.Users;
@ -18,13 +17,14 @@ public class Program(IServiceProvider serviceProvider)
private async Task LoadAuthFromBrowser()
{
IAuthService authService = serviceProvider.GetRequiredService<IAuthService>();
bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
// Show the initial message
AnsiConsole.MarkupLine("[yellow]Downloading dependencies. Please wait ...[/]");
// Show instructions based on the environment
await Task.Delay(5000);
if (EnvironmentHelper.IsRunningInDocker())
if (runningInDocker)
{
AnsiConsole.MarkupLine(
"[yellow]In your web browser, navigate to the port forwarded from your docker container.[/]");
@ -72,6 +72,7 @@ public class Program(IServiceProvider serviceProvider)
ServiceCollection services = await ConfigureServices(args);
ServiceProvider serviceProvider = services.BuildServiceProvider();
// Get the Program instance and run
Program program = serviceProvider.GetRequiredService<Program>();
await program.RunAsync();
}
@ -209,6 +210,14 @@ public class Program(IServiceProvider serviceProvider)
Log.Error("Auth failed");
authService.CurrentAuth = null;
if (!configService.CurrentConfig.DisableBrowserAuth)
{
if (File.Exists("auth.json"))
{
File.Delete("auth.json");
}
}
if (!configService.CurrentConfig.NonInteractiveMode &&
!configService.CurrentConfig.DisableBrowserAuth)
{
@ -739,6 +748,11 @@ public class Program(IServiceProvider serviceProvider)
else if (File.Exists("auth.json"))
{
Log.Information("Auth file found but could not be deserialized");
if (!configService.CurrentConfig.DisableBrowserAuth)
{
Log.Debug("Deleting auth.json");
File.Delete("auth.json");
}
if (configService.CurrentConfig.NonInteractiveMode)
{
@ -833,8 +847,8 @@ public class Program(IServiceProvider serviceProvider)
private static void DisplayStartupResult(StartupResult result)
{
// OS
if (result is { IsWindowsVersionValid: true, OsVersionString: not null } &&
EnvironmentHelper.IsRunningOnWindows())
if (result.IsWindowsVersionValid && result.OsVersionString != null &&
Environment.OSVersion.Platform == PlatformID.Win32NT)
{
AnsiConsole.Markup("[green]Valid version of Windows found.\n[/]");
}

View File

@ -0,0 +1,14 @@
# Stealth Script Creation
## Requirements
- NodeJS (with npx CLI tool)
## Instructions
- Open a terminal in this directory (OF DL/chromium-scripts)
- Run `npx -y extract-stealth-evasions`
## References
See the readme.md file and source code for the stealth script [here](https://github.com/berstend/puppeteer-extra/tree/master/packages/extract-stealth-evasions).

View File

@ -12,45 +12,15 @@ if [ ! -f /config/rules.json ]; then
cp /default-config/rules.json /config/rules.json
fi
# Check if --cli flag was passed
if [[ " $@ " =~ " --cli " ]]; then
# CLI mode - no need for X server
# Remove --cli from arguments
args=("$@")
filtered_args=()
for arg in "${args[@]}"; do
if [ "$arg" != "--cli" ]; then
filtered_args+=("$arg")
fi
done
{
supervisord -c /etc/supervisor/conf.d/supervisord.conf &
} &> /dev/null
/app/cli/OF\ DL.Cli "${filtered_args[@]}"
else
# GUI mode - start X server and window manager
{
supervisord -c /etc/supervisor/conf.d/supervisord.conf &
} &> /dev/null
# Wait for the 4 supervisor programs to start: X11 (Xvfb), openbox, X11vnc, and noVNC
# Wait for the 3 supervisor programs to start: X11 (Xvfb), X11vnc, and noVNC
NUM_RUNNING_SERVICES=$(supervisorctl -c /etc/supervisor/conf.d/supervisord.conf status | grep RUNNING | wc -l)
while [ $NUM_RUNNING_SERVICES != "3" ]; do
sleep 1
NUM_RUNNING_SERVICES=$(supervisorctl -c /etc/supervisor/conf.d/supervisord.conf status | grep RUNNING | wc -l)
while [ $NUM_RUNNING_SERVICES != "4" ]; do
sleep 1
NUM_RUNNING_SERVICES=$(supervisorctl -c /etc/supervisor/conf.d/supervisord.conf status | grep RUNNING | wc -l)
done
done
# Wait for X server to be ready to accept connections
echo "Waiting for X server to be ready..."
timeout=30
elapsed=0
until xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; do
if [ $elapsed -ge $timeout ]; then
echo "Timeout waiting for X server"
exit 1
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "X server is ready"
/app/gui/OF\ DL.Gui "$@"
fi
/app/OF\ DL

View File

@ -16,11 +16,6 @@ serverurl=unix:///tmp/supervisor.sock
command=Xvfb :0 -screen 0 "%(ENV_DISPLAY_WIDTH)s"x"%(ENV_DISPLAY_HEIGHT)s"x24
autorestart=true
[program:openbox]
command=openbox
environment=DISPLAY=":0"
autorestart=true
[program:x11vnc]
command=/usr/bin/x11vnc
autorestart=true

View File

@ -387,18 +387,6 @@ Allowed values: `true`, `false`
Description: A folder will be created for each post (containing all the media for that post) if set to `true`.
When set to `false`, post media will be downloaded into the `Posts/Free` folder.
## HideMissingCdmKeysWarning
Type: `boolean`
Default: `false`
Allowed values: `true`, `false`
Description: If set to `false`, OF-DL will show a warning and ask for confirmation before starting downloads when
`device_client_id_blob` and/or `device_private_key` is missing.
If set to `true`, this warning is hidden and downloads start immediately.
## IgnoreOwnMessages
Type: `boolean`
@ -593,17 +581,6 @@ Allowed values: `true`, `false`
Description: Posts and messages that contain #ad or free trial links will be ignored if set to `true`
## Theme
Type: `string`
Default: `"dark"`
Allowed values: `"light"`, `"dark"`
Description: Controls the OF-DL GUI theme.
Set to `"light"` for light mode or `"dark"` for dark mode.
## Timeout
Type: `integer`

View File

@ -68,9 +68,5 @@ information about what it does, its default value, and the allowed values.
- [LimitDownloadRate](/config/all-configuration-options#limitdownloadrate)
- [DownloadLimitInMbPerSec](/config/all-configuration-options#downloadlimitinmbpersec)
- Appearance
- [Theme](/config/all-configuration-options#theme)
- [HideMissingCdmKeysWarning](/config/all-configuration-options#hidemissingcdmkeyswarning)
- Logging
- [LoggingLevel](/config/all-configuration-options#logginglevel)

View File

@ -2,16 +2,14 @@
A Linux release of OF-DL is not available at this time, however you can run OF-DL on Linux using Docker.
Please refer to the [Docker](/installation/docker) page for instructions on how to run OF-DL in a Docker container.
If you do not have Docker installed, you can download it
from [here](https://docs.docker.com/desktop/install/linux-install/).
If you do not have Docker installed, you can download it from [here](https://docs.docker.com/desktop/install/linux-install/).
If you would like to run OF-DL natively on Linux, you can build it from source by following the instructions below.
## Building from source
- Install FFmpeg (and FFprobe)
Follow the installation instructions from FFmpeg ([https://ffmpeg.org/download.html](https://ffmpeg.org/download.html))
for your distro (Ubuntu, Debian, Fedora, etc.) to install FFmpeg and FFprobe
Follow the installtion instructions from FFmpeg ([https://ffmpeg.org/download.html](https://ffmpeg.org/download.html)) for your distro (Ubuntu, Debian, Fedora, etc.) to install FFmpeg and FFprobe
!!! warning
@ -19,9 +17,7 @@ for your distro (Ubuntu, Debian, Fedora, etc.) to install FFmpeg and FFprobe
- Install .NET 10
Follow the installation instructions from
Microsoft ([https://learn.microsoft.com/en-us/dotnet/core/install/linux](https://learn.microsoft.com/en-us/dotnet/core/install/linux))
for your distro (Ubuntu, Debian, Fedora, etc.) to install .NET 10.
Follow the installation instructions from Microsoft ([https://learn.microsoft.com/en-us/dotnet/core/install/linux](https://learn.microsoft.com/en-us/dotnet/core/install/linux)) for your distro (Ubuntu, Debian, Fedora, etc.) to install .NET 10.
- Clone the repo
@ -33,7 +29,7 @@ cd 'OF-DL'
- Build the project. Replace `%VERSION%` with the current version number of OF-DL (e.g. `1.9.20`).
```bash
dotnet publish "OF DL/OF DL.Cli.csproj" -p:Version=%VERSION% -p:PackageVersion=%VERSION% -c Release
dotnet publish "OF DL/OF DL.csproj" -p:Version=%VERSION% -p:PackageVersion=%VERSION% -c Release
cd 'OF DL/bin/Release/net10.0'
```

View File

@ -1,76 +1,42 @@
# Running the Program
Once you are happy you have filled everything in [auth.json](/config/auth) correctly, you can double click OF-DL.exe and
you should see a command prompt window appear, it should look something like this:
Once you are happy you have filled everything in [auth.json](/config/auth) correctly, you can double click OF-DL.exe and you should see a command prompt window appear, it should look something like this:
![CLI welcome banner](/img/welcome_banner.png)
It should locate `config.conf`, `rules.json`, FFmpeg, and FFprobe successfully. If anything doesn't get located
successfully, then make sure the files exist or the path is correct.
OF-DL will open a new window, if needed, to allow you to log into your OnlyFans account. The window will automatically
close once
OF-DL will open a new window, if needed, to allow you to log into your OnlyFans account. The window will automatically close once
the authorization process has finished. If the auth info is correct then you should see a message in green text
`Logged In successfully as {Your Username} {Your User Id}`. However, if the authorization has failed,
then a message in red text will appear
`Auth failed, please check the values in auth.json are correct, press any key to exit.`
This means you need to go back and fill in the `auth.json` file again, this will usually indicate that your `user-agent`
has changed or you need to re-copy your `sess` value.
then a message in red text will appear `Auth failed, please check the values in auth.json are correct, press any key to exit.`
This means you need to go back and fill in the `auth.json` file again, this will usually indicate that your `user-agent` has changed or you need to re-copy your `sess` value.
In GUI mode, the **Help** menu includes:
- **Join Discord** (opens the OF-DL Discord invite; in Docker, copies the link to clipboard)
- **FAQ** (opens the FAQ window; content coming soon)
- **Documentation** (opens https://docs.ofdl.tools/; in Docker, copies the link to clipboard)
- **About** (shows version details and project/license links)
In GUI mode, the main download screen includes:
- **Download Selected** (downloads configured content for selected creators)
- **Download Purchased Tab** (downloads purchased tab content)
- **Download Single Post/Message** (opens a modal to download one post or one paid message by URL)
For **Download Single Post/Message** in GUI:
- Post URL: click `...` on a post and choose **Copy link to post**.
- Message URL: only unlocked PPV paid messages are supported by URL. From the main timeline, open **Purchased**,
find the message, click `...`, and choose **Copy link to message**.
- Other message types cannot be downloaded individually by URL; scrape all messages for that creator instead.
If you're logged in successfully then you will be greeted with a selection prompt. To navigate the menu the can use
the ↑ & ↓ arrows and press `enter` to choose that option.
If you're logged in successfully then you will be greeted with a selection prompt. To navigate the menu the can use the ↑ & ↓ arrows and press `enter` to choose that option.
![CLI main menu](/img/cli_menu.png)
The `Select All` option will go through every account you are currently subscribed to and grab all of the media from the
users.
The `Select All` option will go through every account you are currently subscribed to and grab all of the media from the users.
The `List` option will show you all the lists you have created on OnlyFans and you can then select 1 or more lists to
download the content of the users within those lists.
The `List` option will show you all the lists you have created on OnlyFans and you can then select 1 or more lists to download the content of the users within those lists.
The `Custom` option allows you to select 1 or more accounts you want to scrape media from so if you only want to get
media from a select number of accounts then you can do that.
To navigate the menu the can use the ↑ & ↓ arrows. You can also press keys A-Z on the keyboard whilst in the menu to
easily navigate the menu and for example
pressing the letter 'c' on the keyboard will highlight the first user in the list whose username starts with the
letter 'c'. To select/deselect an account,
The `Custom` option allows you to select 1 or more accounts you want to scrape media from so if you only want to get media from a select number of accounts then you can do that.
To navigate the menu the can use the ↑ & ↓ arrows. You can also press keys A-Z on the keyboard whilst in the menu to easily navigate the menu and for example
pressing the letter 'c' on the keyboard will highlight the first user in the list whose username starts with the letter 'c'. To select/deselect an account,
press the space key, and after you are happy with your selection(s), press the enter key to start downloading.
The `Download Single Post` allows you to download a post from a URL, to get this URL go to any post and press the 3
dots, Copy link to post.
The `Download Single Post` allows you to download a post from a URL, to get this URL go to any post and press the 3 dots, Copy link to post.
The `Download Single Message` allows you to download a message from a URL, to get this URL go to any message in the *
*purchased tab** and press the 3 dots, Copy link to message.
The `Download Single Message` allows you to download a message from a URL, to get this URL go to any message in the **purchased tab** and press the 3 dots, Copy link to message.
The `Download Purchased Tab` option will download all the media from the purchased tab in OnlyFans.
The `Edit config.json` option allows you to change the config from within the program.
The `Change logging level` option allows you to change the logging level that the program uses when writing logs to
files in the `logs` folder.
The `Change logging level` option allows you to change the logging level that the program uses when writing logs to files in the `logs` folder.
The `Logout and Exit` option allows you to remove your authentication from OF-DL. This is useful if you use multiple
OnlyFans accounts.
The `Logout and Exit` option allows you to remove your authentication from OF-DL. This is useful if you use multiple OnlyFans accounts.
After you have made your selection the content should start downloading. Content is downloaded in this order: