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 - name: Build for Windows and Linux
run: | 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:PackageVersion=${{ steps.version.outputs.version }} \
-p:WarningLevel=0 -c Release -r win-x86 \ -p:WarningLevel=0 -c Release -r win-x86 \
--self-contained true -p:PublishSingleFile=true -o outwin --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:PackageVersion=${{ steps.version.outputs.version }} \
-p:WarningLevel=0 -c Release -r linux-x64 \ -p:WarningLevel=0 -c Release -r linux-x64 \
--self-contained true -p:PublishSingleFile=true -o outlin --self-contained true -p:PublishSingleFile=true -o outlin
- name: Copy and patch extra files - name: Copy and patch extra files
run: | run: |
cp ./OF\ DL.Cli/rules.json outwin/ cp ./OF\ DL/rules.json outwin/
chmod +x ./outlin/OF\ DL chmod +x ./outlin/OF\ DL
cd outwin cd outwin

View File

@ -2,36 +2,30 @@
Note: Keep AGENTS.md updated as project structure, key services, or workflows change. Note: Keep AGENTS.md updated as project structure, key services, or workflows change.
This repo is **OF DL** (also known as OF-DL), a C# app suite (console + Avalonia desktop GUI) that downloads media This repo is **OF DL** (also known as OF-DL), a C# console app that downloads media from a user's OnlyFans account(s).
from a user's OnlyFans account(s).
This document is for AI agents helping developers modify the project. It focuses on architecture, data flow, and the This document is for AI agents helping developers modify the project. It focuses on architecture, data flow, and the
most important change points. most important change points.
## Quick Flow ## Quick Flow
1. `Program.Main` 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. 2. `StartupService.CheckVersionAsync` checks the latest release tag (`OFDLV*`) from `git.ofdl.tools` when not in DEBUG.
3. `StartupService.ValidateEnvironmentAsync` validates OS, FFmpeg, `rules.json`, and Widevine device files. 3. `StartupService.ValidateEnvironmentAsync` validates OS, FFmpeg, `rules.json`, and Widevine device files.
4. `AuthService` loads `auth.json` or opens a browser login (PuppeteerSharp) and persists auth data. 4. `AuthService` loads `auth.json` or opens a browser login (PuppeteerSharp) and persists auth data.
5. `ApiService` signs every API request with dynamic rules and the current auth. 5. `ApiService` signs every API request with dynamic rules and the current auth.
6. `DownloadOrchestrationService` selects creators, prepares folders/DBs, and calls `DownloadService` per media type. 6. `DownloadOrchestrationService` selects creators, prepares folders/DBs, and calls `DownloadService` per media type.
7. `DownloadService` downloads media, handles DRM, and records metadata in SQLite. 7. `DownloadService` downloads media, handles DRM, and records metadata in SQLite.
8. `OF DL.Gui` starts with config validation, then auth validation/browser login, then loads users/lists and provides
multi-select download controls.
## Project Layout ## Project Layout
- `OF DL.Cli/CLI/` contains Spectre.Console UI helpers and progress reporting (CLI-only). - `OF DL/Program.cs` orchestrates startup, config/auth loading, and the interactive flow (CLI entrypoint).
- `OF DL.Gui/` contains the Avalonia desktop UI (`App`, `MainWindow`, `AboutWindow`, `FaqWindow`, MVVM view models, and - `OF DL/CLI/` contains Spectre.Console UI helpers and progress reporting (CLI-only).
GUI event handlers).
- `OF DL.Gui/Helpers/` contains GUI-specific utility helpers (for example, Docker-aware web-link behavior shared across
windows).
- `OF DL.Core/Services/` contains application services (API, auth, download, config, DB, startup, logging, filenames). - `OF DL.Core/Services/` contains application services (API, auth, download, config, DB, startup, logging, filenames).
- `OF DL.Core/Models/` holds configuration, auth, API request/response models, downloads/startup results, DTOs, - `OF DL.Core/Models/` holds configuration, auth, API request/response models, downloads/startup results, DTOs,
entities, and mapping helpers. entities, and mapping helpers.
- `OF DL.Core/Widevine/` implements Widevine CDM handling and key derivation. - `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 - `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. - `docs/` and `mkdocs.yml` define the documentation site.
- `site/` is generated MkDocs output and should not be edited by hand. - `site/` is generated MkDocs output and should not be edited by hand.
- `docker/` contains container entrypoint and supervisor configuration. - `docker/` contains container entrypoint and supervisor configuration.
@ -92,27 +86,20 @@ most important change points.
## Execution and Testing ## 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: - Build from the repo root:
```bash ```bash
dotnet build OF DL.sln dotnet build OF DL.sln
``` ```
- Run from source (GUI mode, default): - Run from source (runtime files are read from the current working directory):
```bash ```bash
dotnet run --project "OF DL/OF DL.Cli.csproj" dotnet run --project "OF DL/OF DL.csproj"
``` ```
- Run CLI mode: - If you want a local `rules.json` fallback, run from `OF DL/` or copy `OF DL/rules.json` into your working directory.
```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.
- Run tests: - Run tests:
```bash ```bash
@ -233,16 +220,12 @@ cookies/user-agent. Output is written to `{filename}_source.mp4`, then moved and
## Where to Look First ## Where to Look First
- `OF DL/Program.cs` for the execution path and menu flow. - `OF DL/Program.cs` for the execution path and menu flow.
- `OF DL.Gui/ViewModels/MainWindowViewModel.cs` for GUI startup flow (config -> auth -> users/lists -> selection).
- `OF DL.Gui/Views/MainWindow.axaml` for GUI layout and interaction points.
- `OF DL.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/ApiService.cs` for OF API calls and header signing.
- `OF DL.Core/Services/DownloadService.cs` for downloads and DRM handling. - `OF DL.Core/Services/DownloadService.cs` for downloads and DRM handling.
- `OF DL.Core/Services/DownloadOrchestrationService.cs` for creator selection and flow control. - `OF DL.Core/Services/DownloadOrchestrationService.cs` for creator selection and flow control.
- `OF DL.Core/Widevine/` for CDM key generation and license parsing. - `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/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/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. - `docs/` for public documentation; update docs whenever user-facing behavior or configuration changes.
## Documentation updates for common changes: ## Documentation updates for common changes:

View File

@ -4,18 +4,16 @@ ARG VERSION
# Copy source code # Copy source code
COPY ["OF DL.sln", "/src/OF DL.sln"] 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.Core", "/src/OF DL.Core"]
COPY ["OF DL.Gui", "/src/OF DL.Gui"]
WORKDIR "/src" WORKDIR "/src"
# Build release # 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/OF DL.csproj" -p:WarningLevel=0 -p:Version=$VERSION -c Release -o out
RUN dotnet publish "OF DL.Cli/OF DL.Cli.csproj" -p:WarningLevel=0 -p:Version=$VERSION -c Release -o outcli
# Generate default config.conf files # 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 # Set download path in default config.conf to /data
sed -e 's/DownloadPath = ""/DownloadPath = "\/data"/' /src/config.conf > /src/updated_config.conf && \ sed -e 's/DownloadPath = ""/DownloadPath = "\/data"/' /src/config.conf > /src/updated_config.conf && \
mv /src/updated_config.conf /src/config.conf mv /src/updated_config.conf /src/config.conf
@ -33,7 +31,6 @@ RUN apt-get update \
x11vnc \ x11vnc \
novnc \ novnc \
npm \ npm \
openbox \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN npx playwright install-deps 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 RUN mkdir /data /config /config/logs /default-config
# Copy release # Copy release
COPY --from=build /src/outcli /app/cli COPY --from=build /src/out /app
COPY --from=build /src/outgui /app/gui
# Copy default configuration files # Copy default configuration files
COPY --from=build /src/config.conf /default-config 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/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/entrypoint.sh /app/entrypoint.sh COPY docker/entrypoint.sh /app/entrypoint.sh
@ -68,5 +64,5 @@ ENV DEBIAN_FRONTEND="noninteractive" \
EXPOSE 8080 EXPOSE 8080
WORKDIR /config WORKDIR /config
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] ENTRYPOINT ["/usr/bin/tini", "--"]
CMD [] 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 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 string ApiUrl = "https://onlyfans.com/api2/v2";
public const int ApiPageSize = 50; 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))] [JsonConverter(typeof(StringEnumConverter))]
public LoggingLevel LoggingLevel { get; set; } = LoggingLevel.Error; 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 IgnoreOwnMessages { get; set; }
[ToggleableConfig] public bool DisableBrowserAuth { get; set; } [ToggleableConfig] public bool DisableBrowserAuth { get; set; }
@ -107,11 +102,8 @@ public class Config : IFileNameFormatConfig
[ToggleableConfig] public bool DisableTextSanitization { get; set; } [ToggleableConfig] public bool DisableTextSanitization { get; set; }
public string? PaidPostFileNameFormat { get; set; } = ""; public string? PaidPostFileNameFormat { get; set; } = "";
public string? PostFileNameFormat { get; set; } = ""; public string? PostFileNameFormat { get; set; } = "";
public string? PaidMessageFileNameFormat { get; set; } = ""; public string? PaidMessageFileNameFormat { get; set; } = "";
public string? MessageFileNameFormat { get; set; } = ""; public string? MessageFileNameFormat { get; set; } = "";
public IFileNameFormatConfig GetCreatorFileNameFormatConfig(string username) public IFileNameFormatConfig GetCreatorFileNameFormatConfig(string username)

View File

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

View File

@ -2,7 +2,6 @@ using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Playwright; using Microsoft.Playwright;
using Newtonsoft.Json; using Newtonsoft.Json;
using OF_DL.Helpers;
using OF_DL.Models; using OF_DL.Models;
using Serilog; using Serilog;
using UserEntities = OF_DL.Models.Entities.Users; 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. /// Launches a browser session and extracts auth data after login.
/// </summary> /// </summary>
/// <returns>True when auth data is captured successfully.</returns> /// <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 try
{ {
bool runningInDocker = EnvironmentHelper.IsRunningInDocker(); bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
await SetupBrowser(runningInDocker); 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(); CurrentAuth = await GetAuthFromBrowser();
return CurrentAuth != null; return CurrentAuth != null;
} }
catch (Exception ex) catch (Exception ex)
{ {
statusCallback?.Invoke("Failed to get auth from browser.");
Log.Error(ex, "Failed to load auth from browser"); Log.Error(ex, "Failed to load auth from browser");
return false; return false;
} }
@ -122,7 +107,7 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
{ {
string json = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented); string json = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented);
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json);
Log.Debug("Auth saved to file: {FilePath}", filePath); Log.Debug($"Auth saved to file: {filePath}");
} }
catch (Exception ex) 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) if (runningInDocker)
{ {
@ -149,16 +134,15 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
if (folders.Any()) if (folders.Any())
{ {
Log.Information("chromium already downloaded. Skipping install step."); Log.Information("chromium already downloaded. Skipping install step.");
return; return Task.CompletedTask;
} }
} }
int exitCode = Program.Main(["install", "--with-deps", "chromium"]); int exitCode = Program.Main(["install", "--with-deps", "chromium"]);
if (exitCode != 0) return exitCode != 0
{ ? throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}")
throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}"); : Task.CompletedTask;
} }
});
private static async Task<string> GetBcToken(IPage page) => private static async Task<string> GetBcToken(IPage page) =>
await page.EvaluateAsync<string>("window.localStorage.getItem('bcTokenSha') || ''"); 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"), LimitDownloadRate = hoconConfig.GetBoolean("Performance.LimitDownloadRate"),
DownloadLimitInMbPerSec = hoconConfig.GetInt("Performance.DownloadLimitInMbPerSec"), 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 // Logging/Debug Settings
LoggingLevel = Enum.Parse<LoggingLevel>(hoconConfig.GetString("Logging.LoggingLevel"), true) 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($" DownloadLimitInMbPerSec = {config.DownloadLimitInMbPerSec}");
hocon.AppendLine("}"); 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/Debug Settings");
hocon.AppendLine("Logging {"); hocon.AppendLine("Logging {");
hocon.AppendLine($" LoggingLevel = \"{config.LoggingLevel.ToString().ToLower()}\""); hocon.AppendLine($" LoggingLevel = \"{config.LoggingLevel.ToString().ToLower()}\"");
@ -502,12 +490,15 @@ public class ConfigService(ILoggingService loggingService) : IConfigService
return configChanged; return configChanged;
} }
private VideoResolution ParseVideoResolution(string value) => private VideoResolution ParseVideoResolution(string value)
value.Equals("source", StringComparison.OrdinalIgnoreCase) {
? VideoResolution.source if (value.Equals("source", StringComparison.OrdinalIgnoreCase))
: Enum.Parse<VideoResolution>("_" + value, true); {
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) => private static double ParseDrmVideoDurationMatchThreshold(string value) =>
!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) !double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)

View File

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

View File

@ -217,7 +217,7 @@ public class DownloadService(
Engine ffmpeg = new(configService.CurrentConfig.FFmpegPath); Engine ffmpeg = new(configService.CurrentConfig.FFmpegPath);
ffmpeg.Error += OnError; ffmpeg.Error += OnError;
ffmpeg.Complete += (_, _) => { _completionSource.TrySetResult(true); }; ffmpeg.Complete += (_, _) => { _completionSource.TrySetResult(true); };
await ffmpeg.ExecuteAsync(parameters, progressReporter.CancellationToken); await ffmpeg.ExecuteAsync(parameters, CancellationToken.None);
bool ffmpegSuccess = await _completionSource.Task; bool ffmpegSuccess = await _completionSource.Task;
if (!ffmpegSuccess || !File.Exists(tempFilename)) if (!ffmpegSuccess || !File.Exists(tempFilename))
@ -263,10 +263,6 @@ public class DownloadService(
Constants.DrmDownloadMaxRetries, mediaId); Constants.DrmDownloadMaxRetries, mediaId);
return false; return false;
} }
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
ExceptionLoggerHelper.LogException(ex); ExceptionLoggerHelper.LogException(ex);
@ -412,14 +408,14 @@ public class DownloadService(
{ {
string firstFullPath = Path.GetFullPath(firstPath); string firstFullPath = Path.GetFullPath(firstPath);
string secondFullPath = Path.GetFullPath(secondPath); string secondFullPath = Path.GetFullPath(secondPath);
StringComparison comparison = EnvironmentHelper.IsRunningOnWindows() StringComparison comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase ? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal; : StringComparison.Ordinal;
return string.Equals(firstFullPath, secondFullPath, comparison); return string.Equals(firstFullPath, secondFullPath, comparison);
} }
catch catch
{ {
StringComparison comparison = EnvironmentHelper.IsRunningOnWindows() StringComparison comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase ? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal; : StringComparison.Ordinal;
return string.Equals(firstPath, secondPath, comparison); return string.Equals(firstPath, secondPath, comparison);
@ -939,10 +935,9 @@ public class DownloadService(
using HttpClient client = new(); using HttpClient client = new();
HttpRequestMessage request = new() { Method = HttpMethod.Get, RequestUri = new Uri(url) }; HttpRequestMessage request = new() { Method = HttpMethod.Get, RequestUri = new Uri(url) };
using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
progressReporter.CancellationToken);
response.EnsureSuccessStatusCode(); 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. // Wrap the body stream with the ThrottledStream to limit read rate.
await using (ThrottledStream throttledStream = new(body, await using (ThrottledStream throttledStream = new(body,
@ -954,14 +949,14 @@ public class DownloadService(
true); true);
byte[] buffer = new byte[16384]; byte[] buffer = new byte[16384];
int read; int read;
while ((read = await throttledStream.ReadAsync(buffer, progressReporter.CancellationToken)) > 0) while ((read = await throttledStream.ReadAsync(buffer, CancellationToken.None)) > 0)
{ {
if (configService.CurrentConfig.ShowScrapeSize) if (configService.CurrentConfig.ShowScrapeSize)
{ {
progressReporter.ReportProgress(read); 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> /// <summary>
/// Retrieves media URLs for stories or highlights. /// Retrieves media URLs for stories or highlights.
/// </summary> /// </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> /// <summary>
/// Retrieves paid posts and their media. /// Retrieves paid posts and their media.
/// </summary> /// </summary>
Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username, Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username,
List<long> paidPostIds, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default); IStatusReporter statusReporter);
/// <summary> /// <summary>
/// Retrieves posts and their media. /// Retrieves posts and their media.
/// </summary> /// </summary>
Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds, Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default); IStatusReporter statusReporter);
/// <summary> /// <summary>
/// Retrieves a single post and its media. /// Retrieves a single post and its media.
/// </summary> /// </summary>
Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default); Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder);
/// <summary> /// <summary>
/// Retrieves streams and their media. /// Retrieves streams and their media.
/// </summary> /// </summary>
Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds, Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default); IStatusReporter statusReporter);
/// <summary> /// <summary>
/// Retrieves archived posts and their media. /// Retrieves archived posts and their media.
/// </summary> /// </summary>
Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder, Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default); IStatusReporter statusReporter);
/// <summary> /// <summary>
/// Retrieves messages and their media. /// Retrieves messages and their media.
/// </summary> /// </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> /// <summary>
/// Retrieves paid messages and their media. /// Retrieves paid messages and their media.
/// </summary> /// </summary>
Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder, string username, Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder, string username,
IStatusReporter statusReporter, CancellationToken cancellationToken = default); IStatusReporter statusReporter);
/// <summary> /// <summary>
/// Retrieves a single paid message and its media. /// Retrieves a single paid message and its media.
/// </summary> /// </summary>
Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default); Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder);
/// <summary> /// <summary>
/// Retrieves users that appear in the Purchased tab. /// Retrieves users that appear in the Purchased tab.
/// </summary> /// </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> /// <summary>
/// Retrieves Purchased tab content grouped by user. /// Retrieves Purchased tab content grouped by user.
/// </summary> /// </summary>
Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder, Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
Dictionary<string, long> users, CancellationToken cancellationToken = default); Dictionary<string, long> users);
/// <summary> /// <summary>
/// Retrieves user information. /// Retrieves user information.
/// </summary> /// </summary>
Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default); Task<UserEntities.User?> GetUserInfo(string endpoint);
/// <summary> /// <summary>
/// Retrieves user information by ID. /// Retrieves user information by ID.

View File

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

View File

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

View File

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

View File

@ -11,9 +11,4 @@ public interface IProgressReporter
/// </summary> /// </summary>
/// <param name="increment">The amount to increment progress by</param> /// <param name="increment">The amount to increment progress by</param>
void ReportProgress(long increment); 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 public class LoggingService : ILoggingService
{ {
private readonly ILogEventSink? _optionalErrorSink; public LoggingService()
public LoggingService(ILogEventSink? optionalErrorSink = null)
{ {
_optionalErrorSink = optionalErrorSink;
LevelSwitch = new LoggingLevelSwitch(); LevelSwitch = new LoggingLevelSwitch();
InitializeLogger(); InitializeLogger();
} }
@ -41,17 +38,10 @@ public class LoggingService : ILoggingService
// Set the initial level to Error (until we've read from config) // Set the initial level to Error (until we've read from config)
LevelSwitch.MinimumLevel = LogEventLevel.Error; LevelSwitch.MinimumLevel = LogEventLevel.Error;
LoggerConfiguration loggerConfiguration = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(LevelSwitch) .MinimumLevel.ControlledBy(LevelSwitch)
.WriteTo.File("logs/OFDL.txt", rollingInterval: RollingInterval.Day); .WriteTo.File("logs/OFDL.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
if (_optionalErrorSink != null)
{
loggerConfiguration = loggerConfiguration.WriteTo.Sink(_optionalErrorSink,
LogEventLevel.Error);
}
Log.Logger = loggerConfiguration.CreateLogger();
Log.Debug("Logging service initialized"); Log.Debug("Logging service initialized");
} }

View File

@ -1,5 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
using Newtonsoft.Json; using Newtonsoft.Json;
using OF_DL.Helpers; using OF_DL.Helpers;
using OF_DL.Models; using OF_DL.Models;
@ -23,9 +24,9 @@ public class StartupService(IConfigService configService, IAuthService authServi
// OS validation // OS validation
OperatingSystem os = Environment.OSVersion; OperatingSystem os = Environment.OSVersion;
result.OsVersionString = os.VersionString; 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; result.IsWindowsVersionValid = false;
Log.Error("Windows version prior to 10.x: {0}", os.VersionString); 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 }) if (result is { FfmpegFound: true, FfmpegPath: not null })
{ {
// Escape backslashes for Windows // Escape backslashes for Windows
if (EnvironmentHelper.IsRunningOnWindows() && if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
result.FfmpegPath.Contains(@":\") && result.FfmpegPath.Contains(@":\") &&
!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 }) if (result is { FfprobeFound: true, FfprobePath: not null })
{ {
// Escape backslashes for Windows // Escape backslashes for Windows
if (EnvironmentHelper.IsRunningOnWindows() && if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
result.FfprobePath.Contains(@":\") && result.FfprobePath.Contains(@":\") &&
!result.FfprobePath.Contains(@":\\")) !result.FfprobePath.Contains(@":\\"))
{ {
@ -210,7 +211,7 @@ public class StartupService(IConfigService configService, IAuthService authServi
string? ffmpegDirectory = Path.GetDirectoryName(result.FfmpegPath); string? ffmpegDirectory = Path.GetDirectoryName(result.FfmpegPath);
if (!string.IsNullOrEmpty(ffmpegDirectory)) if (!string.IsNullOrEmpty(ffmpegDirectory))
{ {
string ffprobeFileName = EnvironmentHelper.IsRunningOnWindows() string ffprobeFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "ffprobe.exe" ? "ffprobe.exe"
: "ffprobe"; : "ffprobe";
string inferredFfprobePath = Path.Combine(ffmpegDirectory, ffprobeFileName); 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", MethodInfo method = typeof(ApiService).GetMethod("TryGetDrmInfo",
BindingFlags.NonPublic | BindingFlags.Static) BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("TryGetDrmInfo not found."); ?? 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)!; bool result = (bool)method.Invoke(null, args)!;
manifestDash = (string)args[1]!; manifestDash = (string)args[1]!;
cloudFrontPolicy = (string)args[2]!; cloudFrontPolicy = (string)args[2]!;

View File

@ -23,8 +23,6 @@ public class ConfigServiceTests
Assert.Equal(service.CurrentConfig.LoggingLevel, loggingService.LastLevel); Assert.Equal(service.CurrentConfig.LoggingLevel, loggingService.LastLevel);
Assert.Equal("", service.CurrentConfig.FFprobePath); Assert.Equal("", service.CurrentConfig.FFprobePath);
Assert.Equal(0.98, service.CurrentConfig.DrmVideoDurationMatchThreshold, 3); Assert.Equal(0.98, service.CurrentConfig.DrmVideoDurationMatchThreshold, 3);
Assert.Equal(Theme.dark, service.CurrentConfig.Theme);
Assert.False(service.CurrentConfig.HideMissingCdmKeysWarning);
} }
[Fact] [Fact]
@ -82,44 +80,6 @@ public class ConfigServiceTests
Assert.Equal(0.95, service.CurrentConfig.DrmVideoDurationMatchThreshold, 3); 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] [Fact]
public void ApplyToggleableSelections_UpdatesConfigAndReturnsChange() public void ApplyToggleableSelections_UpdatesConfigAndReturnsChange()
{ {
@ -142,4 +102,5 @@ public class ConfigServiceTests
Assert.Equal("/downloads", service.CurrentConfig.DownloadPath); Assert.Equal("/downloads", service.CurrentConfig.DownloadPath);
Assert.Equal(LoggingLevel.Warning, loggingService.LastLevel); Assert.Equal(LoggingLevel.Warning, loggingService.LastLevel);
} }
} }

View File

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

View File

@ -42,8 +42,6 @@ internal sealed class ProgressRecorder : IProgressReporter
{ {
public long Total { get; private set; } public long Total { get; private set; }
public CancellationToken CancellationToken { get; } = CancellationToken.None;
public void ReportProgress(long increment) => Total += increment; public void ReportProgress(long increment) => Total += increment;
} }
@ -139,53 +137,44 @@ internal sealed class StaticApiService : IApiService
new() { { "X-Test", "value" } }; new() { { "X-Test", "value" } };
public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, 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<Dictionary<string, long>?> GetLists(string endpoint) => throw new NotImplementedException();
public Task<List<string>?> GetListUsers(string endpoint) => throw new NotImplementedException(); public Task<List<string>?> GetListUsers(string endpoint) => throw new NotImplementedException();
public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, public Task<OF_DL.Models.Entities.Purchased.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
string username, List<long> paidPostIds, IStatusReporter statusReporter, string username, List<long> paidPostIds, IStatusReporter statusReporter) =>
CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, public Task<OF_DL.Models.Entities.Posts.PostCollection> GetPosts(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => List<long> paidPostIds, IStatusReporter statusReporter) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Posts.SinglePostCollection> GetPost(string endpoint, string folder) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder, public Task<OF_DL.Models.Entities.Streams.StreamsCollection> GetStreams(string endpoint, string folder,
CancellationToken cancellationToken = default) => 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(); throw new NotImplementedException();
public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, public Task<List<OF_DL.Models.Entities.Purchased.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => string folder, Dictionary<string, long> users) => throw new NotImplementedException();
throw new NotImplementedException();
public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder, public Task<UserEntities.User?> GetUserInfo(string endpoint) =>
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) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<JObject?> GetUserInfoById(string endpoint) => public Task<JObject?> GetUserInfoById(string endpoint) =>
@ -227,55 +216,52 @@ internal sealed class ConfigurableApiService : IApiService
ListUsersHandler?.Invoke(endpoint) ?? Task.FromResult<List<string>?>(null); ListUsersHandler?.Invoke(endpoint) ?? Task.FromResult<List<string>?>(null);
public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, 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) ?? MediaHandler?.Invoke(mediaType, endpoint, username, folder) ??
Task.FromResult<Dictionary<long, string>?>(null); Task.FromResult<Dictionary<long, string>?>(null);
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder, public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder) =>
CancellationToken cancellationToken = default) =>
PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection()); PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection());
public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder) =>
CancellationToken cancellationToken = default) =>
PaidMessageHandler?.Invoke(endpoint, folder) ?? PaidMessageHandler?.Invoke(endpoint, folder) ??
Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection()); 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); UserInfoHandler?.Invoke(endpoint) ?? Task.FromResult<UserEntities.User?>(null);
public Task<JObject?> GetUserInfoById(string endpoint) => public Task<JObject?> GetUserInfoById(string endpoint) =>
UserInfoByIdHandler?.Invoke(endpoint) ?? Task.FromResult<JObject?>(null); UserInfoByIdHandler?.Invoke(endpoint) ?? Task.FromResult<JObject?>(null);
public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username, 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(); throw new NotImplementedException();
public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds, public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) => IStatusReporter statusReporter) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds, public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) => IStatusReporter statusReporter) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder, public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) => IStatusReporter statusReporter) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder, public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) => IStatusReporter statusReporter) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder, public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder,
string username, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => string username, IStatusReporter statusReporter) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users) =>
CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder, public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
Dictionary<string, long> users, CancellationToken cancellationToken = default) => Dictionary<string, long> users) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Dictionary<string, string> GetDynamicHeaders(string path, string queryParam) => 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) => string serverFileName, string resolvedFileName, string extension, IProgressReporter progressReporter) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)?> GetDecryptionInfo( public Task<(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)?> GetDecryptionInfo(string mpdUrl, string policy,
string mpdUrl, string policy,
string signature, string kvp, string mediaId, string contentId, string drmType, bool clientIdBlobMissing, string signature, string kvp, string mediaId, string contentId, string drmType, bool clientIdBlobMissing,
bool devicePrivateKeyMissing) => bool devicePrivateKeyMissing) =>
throw new NotImplementedException(); throw new NotImplementedException();
@ -439,8 +424,6 @@ internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
public List<(string contentType, DownloadResult result)> DownloadCompletes { get; } = []; public List<(string contentType, DownloadResult result)> DownloadCompletes { get; } = [];
public List<(string description, long maxValue, bool showSize)> ProgressCalls { 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) => public Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work) =>
work(new RecordingStatusReporter(statusMessage)); work(new RecordingStatusReporter(statusMessage));
@ -472,9 +455,14 @@ internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
public void OnMessage(string message) => Messages.Add(message); 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; 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> LoadFromFileAsync(string filePath = "auth.json") => throw new NotImplementedException();
public Task<bool> LoadFromBrowserAsync(Action<string>? dependencyStatusCallback = null) => public Task<bool> LoadFromBrowserAsync() => throw new NotImplementedException();
throw new NotImplementedException();
public Task SaveToFileAsync(string filePath = "auth.json") => 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 # Visual Studio Version 17
VisualStudioVersion = 17.5.33516.290 VisualStudioVersion = 17.5.33516.290
MinimumVisualStudioVersion = 10.0.40219.1 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 EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Core", "OF DL.Core\OF DL.Core.csproj", "{7B8B6A26-6732-4B3A-AE62-1CE589DFF8F2}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Core", "OF DL.Core\OF DL.Core.csproj", "{7B8B6A26-6732-4B3A-AE62-1CE589DFF8F2}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Tests", "OF DL.Tests\OF DL.Tests.csproj", "{FF5EC4D7-6369-4A78-8C02-E370343E797C}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Tests", "OF DL.Tests\OF DL.Tests.csproj", "{FF5EC4D7-6369-4A78-8C02-E370343E797C}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Gui", "OF DL.Gui\OF DL.Gui.csproj", "{495749B1-DD15-4637-85AA-49841A86A510}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -29,10 +27,6 @@ Global
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF5EC4D7-6369-4A78-8C02-E370343E797C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.Build.0 = Release|Any CPU {FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.Build.0 = Release|Any CPU
{495749B1-DD15-4637-85AA-49841A86A510}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{495749B1-DD15-4637-85AA-49841A86A510}.Debug|Any CPU.Build.0 = Debug|Any CPU
{495749B1-DD15-4637-85AA-49841A86A510}.Release|Any CPU.ActiveCfg = Release|Any CPU
{495749B1-DD15-4637-85AA-49841A86A510}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

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

View File

@ -6,11 +6,9 @@ namespace OF_DL.CLI;
/// <summary> /// <summary>
/// Implementation of IProgressReporter that uses Spectre.Console's ProgressTask for CLI output. /// Implementation of IProgressReporter that uses Spectre.Console's ProgressTask for CLI output.
/// </summary> /// </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)); private readonly ProgressTask _task = task ?? throw new ArgumentNullException(nameof(task));
public CancellationToken CancellationToken { get; } = cancellationToken;
public void ReportProgress(long increment) => _task.Increment(increment); 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>
<ItemGroup> <ItemGroup>
<None Update="auth.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="config.conf">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="rules.json"> <None Update="rules.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>

View File

@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection;
using OF_DL.CLI; using OF_DL.CLI;
using OF_DL.Models; using OF_DL.Models;
using OF_DL.Enumerations; using OF_DL.Enumerations;
using OF_DL.Helpers;
using OF_DL.Models.Config; using OF_DL.Models.Config;
using OF_DL.Models.Downloads; using OF_DL.Models.Downloads;
using OF_DL.Models.Entities.Users; using OF_DL.Models.Entities.Users;
@ -18,13 +17,14 @@ public class Program(IServiceProvider serviceProvider)
private async Task LoadAuthFromBrowser() private async Task LoadAuthFromBrowser()
{ {
IAuthService authService = serviceProvider.GetRequiredService<IAuthService>(); IAuthService authService = serviceProvider.GetRequiredService<IAuthService>();
bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
// Show the initial message // Show the initial message
AnsiConsole.MarkupLine("[yellow]Downloading dependencies. Please wait ...[/]"); AnsiConsole.MarkupLine("[yellow]Downloading dependencies. Please wait ...[/]");
// Show instructions based on the environment // Show instructions based on the environment
await Task.Delay(5000); await Task.Delay(5000);
if (EnvironmentHelper.IsRunningInDocker()) if (runningInDocker)
{ {
AnsiConsole.MarkupLine( AnsiConsole.MarkupLine(
"[yellow]In your web browser, navigate to the port forwarded from your docker container.[/]"); "[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); ServiceCollection services = await ConfigureServices(args);
ServiceProvider serviceProvider = services.BuildServiceProvider(); ServiceProvider serviceProvider = services.BuildServiceProvider();
// Get the Program instance and run
Program program = serviceProvider.GetRequiredService<Program>(); Program program = serviceProvider.GetRequiredService<Program>();
await program.RunAsync(); await program.RunAsync();
} }
@ -209,6 +210,14 @@ public class Program(IServiceProvider serviceProvider)
Log.Error("Auth failed"); Log.Error("Auth failed");
authService.CurrentAuth = null; authService.CurrentAuth = null;
if (!configService.CurrentConfig.DisableBrowserAuth)
{
if (File.Exists("auth.json"))
{
File.Delete("auth.json");
}
}
if (!configService.CurrentConfig.NonInteractiveMode && if (!configService.CurrentConfig.NonInteractiveMode &&
!configService.CurrentConfig.DisableBrowserAuth) !configService.CurrentConfig.DisableBrowserAuth)
{ {
@ -739,6 +748,11 @@ public class Program(IServiceProvider serviceProvider)
else if (File.Exists("auth.json")) else if (File.Exists("auth.json"))
{ {
Log.Information("Auth file found but could not be deserialized"); 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) if (configService.CurrentConfig.NonInteractiveMode)
{ {
@ -833,8 +847,8 @@ public class Program(IServiceProvider serviceProvider)
private static void DisplayStartupResult(StartupResult result) private static void DisplayStartupResult(StartupResult result)
{ {
// OS // OS
if (result is { IsWindowsVersionValid: true, OsVersionString: not null } && if (result.IsWindowsVersionValid && result.OsVersionString != null &&
EnvironmentHelper.IsRunningOnWindows()) Environment.OSVersion.Platform == PlatformID.Win32NT)
{ {
AnsiConsole.Markup("[green]Valid version of Windows found.\n[/]"); 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 cp /default-config/rules.json /config/rules.json
fi 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
/app/cli/OF\ DL.Cli "${filtered_args[@]}"
else
# GUI mode - start X server and window manager
{ {
supervisord -c /etc/supervisor/conf.d/supervisord.conf & supervisord -c /etc/supervisor/conf.d/supervisord.conf &
} &> /dev/null } &> /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) NUM_RUNNING_SERVICES=$(supervisorctl -c /etc/supervisor/conf.d/supervisord.conf status | grep RUNNING | wc -l)
while [ $NUM_RUNNING_SERVICES != "4" ]; do while [ $NUM_RUNNING_SERVICES != "3" ]; do
sleep 1 sleep 1
NUM_RUNNING_SERVICES=$(supervisorctl -c /etc/supervisor/conf.d/supervisord.conf status | grep RUNNING | wc -l) 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 /app/OF\ DL
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

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 command=Xvfb :0 -screen 0 "%(ENV_DISPLAY_WIDTH)s"x"%(ENV_DISPLAY_HEIGHT)s"x24
autorestart=true autorestart=true
[program:openbox]
command=openbox
environment=DISPLAY=":0"
autorestart=true
[program:x11vnc] [program:x11vnc]
command=/usr/bin/x11vnc command=/usr/bin/x11vnc
autorestart=true 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`. 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. 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 ## IgnoreOwnMessages
Type: `boolean` 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` 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 ## Timeout
Type: `integer` 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) - [LimitDownloadRate](/config/all-configuration-options#limitdownloadrate)
- [DownloadLimitInMbPerSec](/config/all-configuration-options#downloadlimitinmbpersec) - [DownloadLimitInMbPerSec](/config/all-configuration-options#downloadlimitinmbpersec)
- Appearance
- [Theme](/config/all-configuration-options#theme)
- [HideMissingCdmKeysWarning](/config/all-configuration-options#hidemissingcdmkeyswarning)
- Logging - Logging
- [LoggingLevel](/config/all-configuration-options#logginglevel) - [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. 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. 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 If you do not have Docker installed, you can download it from [here](https://docs.docker.com/desktop/install/linux-install/).
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. 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 ## Building from source
- Install FFmpeg (and FFprobe) - Install FFmpeg (and FFprobe)
Follow the installation instructions from FFmpeg ([https://ffmpeg.org/download.html](https://ffmpeg.org/download.html)) 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
for your distro (Ubuntu, Debian, Fedora, etc.) to install FFmpeg and FFprobe
!!! warning !!! warning
@ -19,9 +17,7 @@ for your distro (Ubuntu, Debian, Fedora, etc.) to install FFmpeg and FFprobe
- Install .NET 10 - Install .NET 10
Follow the installation instructions from 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.
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 - 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`). - Build the project. Replace `%VERSION%` with the current version number of OF-DL (e.g. `1.9.20`).
```bash ```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' cd 'OF DL/bin/Release/net10.0'
``` ```

View File

@ -1,76 +1,42 @@
# Running the Program # 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 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:
you should see a command prompt window appear, it should look something like this:
![CLI welcome banner](/img/welcome_banner.png) ![CLI welcome banner](/img/welcome_banner.png)
It should locate `config.conf`, `rules.json`, FFmpeg, and FFprobe successfully. If anything doesn't get located 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. 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 OF-DL will open a new window, if needed, to allow you to log into your OnlyFans account. The window will automatically close once
close once
the authorization process has finished. If the auth info is correct then you should see a message in green text 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, `Logged In successfully as {Your Username} {Your User Id}`. However, if the authorization has failed,
then a message in red text will appear then a message in red text will appear `Auth failed, please check the values in auth.json are correct, press any key to exit.`
`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.
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: 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.
- **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.
![CLI main menu](/img/cli_menu.png) ![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 The `Select All` option will go through every account you are currently subscribed to and grab all of the media from the users.
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 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.
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 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.
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
To navigate the menu the can use the ↑ & ↓ arrows. You can also press keys A-Z on the keyboard whilst in the menu to 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,
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. 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 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.
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 * 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.
*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 `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 `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 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.
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 The `Logout and Exit` option allows you to remove your authentication from OF-DL. This is useful if you use multiple OnlyFans accounts.
OnlyFans accounts.
After you have made your selection the content should start downloading. Content is downloaded in this order: After you have made your selection the content should start downloading. Content is downloaded in this order: