forked from sim0n00ps/OF-DL
Compare commits
61 Commits
master
...
add-initia
| Author | SHA1 | Date | |
|---|---|---|---|
| 54e092601c | |||
| 02986d39dc | |||
| 584ce09644 | |||
| 1ee4ebe865 | |||
| e2db74743f | |||
| 523eb9b8f1 | |||
| 0654c9ab09 | |||
| 383b97f238 | |||
| c8edf760ba | |||
| 35ec1f2bfd | |||
| 26b98b8d31 | |||
| 1bc47ad62b | |||
| 0af7066086 | |||
| d0de99a00c | |||
| 4ea2a6107f | |||
| 73bd188699 | |||
| aaed5bc906 | |||
| 2a75f5c868 | |||
| b3e6ca4b5f | |||
| f1d3ac7ea3 | |||
| 49cddd0608 | |||
| c035fa5721 | |||
| 6b345ea986 | |||
| daca54da2e | |||
| 3749cd1568 | |||
| 2a727c7121 | |||
| bb04e0518a | |||
| e409e4a16c | |||
| 0a709f97ea | |||
| f536a34772 | |||
| 2dcb9a3753 | |||
| 4d3ae0e19a | |||
| 3b8e575a21 | |||
| 603c998ae9 | |||
| 6b7cb29e2e | |||
| f9089a339a | |||
| 65b25e6336 | |||
| 8facc470f0 | |||
| 4ae09a5991 | |||
| 36dbb3de5d | |||
| a74ebc810a | |||
| 35bde51e7d | |||
| d662d9be4d | |||
| ac4061f1ca | |||
| 34ad00ce03 | |||
| 56b951ace0 | |||
| ac1c814633 | |||
| e58ac7d2a6 | |||
| b6872a2b9e | |||
| da40f3d0c5 | |||
| fccee9a520 | |||
| f4479a77ba | |||
| 661b61be66 | |||
| 7667939eba | |||
| 162811b267 | |||
| 85e299db41 | |||
| 35f7d98112 | |||
| 7cccdd58a0 | |||
| 5af26156c7 | |||
| 712f11dc4b | |||
| ec8bf47de5 |
@ -29,26 +29,41 @@ jobs:
|
||||
|
||||
- name: Build for Windows and Linux
|
||||
run: |
|
||||
dotnet publish "OF DL/OF DL.csproj" -p:Version=${{ steps.version.outputs.version }} \
|
||||
dotnet publish "OF DL.Cli/OF DL.Cli.csproj" -p:Version=${{ steps.version.outputs.version }} \
|
||||
-p:PackageVersion=${{ steps.version.outputs.version }} \
|
||||
-p:WarningLevel=0 -c Release -r win-x86 \
|
||||
--self-contained true -p:PublishSingleFile=true -o outwin
|
||||
-p:WarningLevel=0 -c Release -r win-x86 -o outwin-cli
|
||||
|
||||
dotnet publish "OF DL/OF DL.csproj" -p:Version=${{ steps.version.outputs.version }} \
|
||||
dotnet publish "OF DL.Gui/OF DL.Gui.csproj" -p:Version=${{ steps.version.outputs.version }} \
|
||||
-p:PackageVersion=${{ steps.version.outputs.version }} \
|
||||
-p:WarningLevel=0 -c Release -r linux-x64 \
|
||||
--self-contained true -p:PublishSingleFile=true -o outlin
|
||||
-p:WarningLevel=0 -c Release -r win-x86 -o outwin-gui
|
||||
|
||||
dotnet publish "OF DL.Cli/OF DL.Cli.csproj" -p:Version=${{ steps.version.outputs.version }} \
|
||||
-p:PackageVersion=${{ steps.version.outputs.version }} \
|
||||
-p:WarningLevel=0 -c Release -r linux-x64 -o outlin-cli
|
||||
|
||||
- name: Copy and patch extra files
|
||||
run: |
|
||||
cp ./OF\ DL/rules.json outwin/
|
||||
chmod +x ./outlin/OF\ DL
|
||||
cd outwin
|
||||
cd outlin-cli
|
||||
chmod +x "OF DL.Cli"
|
||||
|
||||
echo "➤ Running OF DL binary (timeout)"
|
||||
timeout --preserve-status --kill-after=5s 30s ../outlin/OF\ DL --non-interactive || true
|
||||
timeout --preserve-status --kill-after=5s 30s "./OF DL.Cli" --non-interactive || true
|
||||
echo "➤ Binary finished"
|
||||
|
||||
echo "➤ Combine OF DL.Cli and OF DL.Gui into a single release folder"
|
||||
cd ..
|
||||
mv outwin-gui outwin
|
||||
cd outwin
|
||||
mv "../outwin-cli/OF DL.Cli.exe" .
|
||||
mv ../outlin-cli/config.conf .
|
||||
rm *.pdb
|
||||
mv "OF DL.Gui.exe" "OF DL.exe"
|
||||
mv "OF DL.Cli.exe" "OF DL - Classic.exe"
|
||||
|
||||
echo "➤ Remove unneeded playwright binaries"
|
||||
rm -rf .playwright/node/darwin*
|
||||
rm -rf .playwright/node/linux*
|
||||
|
||||
echo "➤ Creating folder for CDM"
|
||||
mkdir -p cdm/devices/chrome_1610
|
||||
|
||||
@ -57,12 +72,8 @@ jobs:
|
||||
cp /home/rhys/ffmpeg/ffmpeg-7.1.1-essentials_build/bin/ffprobe.exe .
|
||||
cp /home/rhys/ffmpeg/ffmpeg-7.1.1-essentials_build/LICENSE LICENSE.ffmpeg
|
||||
|
||||
echo "➤ Remove unneeded playwright binaries"
|
||||
rm -rf .playwright/node/darwin*
|
||||
rm -rf .playwright/node/linux*
|
||||
|
||||
echo "➤ Creating release zip"
|
||||
zip -r ../OFDLV${{ steps.version.outputs.version }}.zip OF\ DL.exe e_sqlite3.dll rules.json config.conf cdm chromium-scripts .playwright playwright.ps1 ffmpeg.exe ffprobe.exe LICENSE.ffmpeg
|
||||
zip -r ../OFDLV${{ steps.version.outputs.version }}.zip .playwright *
|
||||
cd ..
|
||||
|
||||
- name: Create release and upload artifact
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -374,3 +374,9 @@ venv/
|
||||
|
||||
# Generated docs
|
||||
/site
|
||||
|
||||
# Builds
|
||||
/outwin
|
||||
/outwin-cli
|
||||
/outwin-gui
|
||||
/outlin-cli
|
||||
|
||||
269
AGENTS.md
269
AGENTS.md
@ -1,238 +1,123 @@
|
||||
# AGENTS.md
|
||||
|
||||
Note: Keep AGENTS.md updated as project structure, key services, or workflows change.
|
||||
## Purpose
|
||||
|
||||
This repo is **OF DL** (also known as OF-DL), a C# console app that downloads media from a user's OnlyFans account(s).
|
||||
This document is for AI agents helping developers modify the project. It focuses on architecture, data flow, and the
|
||||
most important change points.
|
||||
OF DL (OF-DL) is a C# (`net10.0`) app suite with:
|
||||
|
||||
## Quick Flow
|
||||
- A modern Avalonia GUI
|
||||
- A classic CLI
|
||||
- Shared core services for auth, API calls, downloads, DRM handling, and metadata storage
|
||||
|
||||
1. `Program.Main` builds DI, loads `config.conf`, and runs the interactive flow.
|
||||
2. `StartupService.CheckVersionAsync` checks the latest release tag (`OFDLV*`) from `git.ofdl.tools` when not in DEBUG.
|
||||
3. `StartupService.ValidateEnvironmentAsync` validates OS, FFmpeg, `rules.json`, and Widevine device files.
|
||||
4. `AuthService` loads `auth.json` or opens a browser login (PuppeteerSharp) and persists auth data.
|
||||
5. `ApiService` signs every API request with dynamic rules and the current auth.
|
||||
6. `DownloadOrchestrationService` selects creators, prepares folders/DBs, and calls `DownloadService` per media type.
|
||||
7. `DownloadService` downloads media, handles DRM, and records metadata in SQLite.
|
||||
## Architecture at a glance
|
||||
|
||||
## Project Layout
|
||||
1. `AuthService` loads `auth.json` or runs browser login and saves auth.
|
||||
2. `ApiService` signs OnlyFans API requests with dynamic rules.
|
||||
3. `DownloadOrchestrationService` selects creators/lists and coordinates download jobs.
|
||||
4. `DownloadService` downloads/decrypts media and records metadata via `DBService`.
|
||||
|
||||
- `OF DL/Program.cs` orchestrates startup, config/auth loading, and the interactive flow (CLI entrypoint).
|
||||
- `OF DL/CLI/` contains Spectre.Console UI helpers and progress reporting (CLI-only).
|
||||
- `OF DL.Core/Services/` contains application services (API, auth, download, config, DB, startup, logging, filenames).
|
||||
- `OF DL.Core/Models/` holds configuration, auth, API request/response models, downloads/startup results, DTOs,
|
||||
entities, and mapping helpers.
|
||||
- `OF DL.Core/Widevine/` implements Widevine CDM handling and key derivation.
|
||||
- `OF DL.Core/Helpers/`, `OF DL.Core/Utils/`, `OF DL.Core/Crypto/`, `OF DL.Core/Enumerations/` contain shared core
|
||||
logic.
|
||||
- `docs/` and `mkdocs.yml` define the documentation site.
|
||||
- `site/` is generated MkDocs output and should not be edited by hand.
|
||||
- `docker/` contains container entrypoint and supervisor configuration.
|
||||
## Key directories
|
||||
|
||||
## Key Services
|
||||
- `OF DL.Cli/`: CLI-specific UI/helpers
|
||||
- `OF DL.Gui/`: Avalonia UI, view models, windows
|
||||
- `OF DL.Core/Services/`: business logic (auth/api/download/config/startup/db/logging)
|
||||
- `OF DL.Core/Models/`: DTOs, entities, config/auth/download/startup models, mappers
|
||||
- `OF DL.Core/Widevine/`: Widevine CDM logic
|
||||
- `docs/`: MkDocs source
|
||||
- `docker/`: container entrypoint/runtime config
|
||||
|
||||
- `ApiService` (`OF DL.Core/Services/ApiService.cs`) builds signed headers, performs HTTP requests, and maps DTOs to
|
||||
entities. It also handles DRM-related calls like MPD/PSSH extraction and license requests.
|
||||
- `AuthService` (`OF DL.Core/Services/AuthService.cs`) loads `auth.json` or performs browser-based login with
|
||||
PuppeteerSharp,
|
||||
then persists auth. It also normalizes cookies.
|
||||
- `ConfigService` (`OF DL.Core/Services/ConfigService.cs`) loads `config.conf` (HOCON), migrates legacy `config.json`,
|
||||
and
|
||||
updates global settings (logging, text sanitization).
|
||||
- `DownloadService` (`OF DL.Core/Services/DownloadService.cs`) downloads all media (images, video, audio) and handles
|
||||
DRM
|
||||
video decryption and FFmpeg execution.
|
||||
- `DownloadOrchestrationService` (`OF DL.Core/Services/DownloadOrchestrationService.cs`) coordinates user selection,
|
||||
subscription lists, per-user folder prep, and per-media-type download execution.
|
||||
- `DBService` (`OF DL.Core/Services/DBService.cs`) manages SQLite metadata DBs for downloaded media and a `users.db`
|
||||
index.
|
||||
- `StartupService` (`OF DL.Core/Services/StartupService.cs`) validates FFmpeg, rules.json, Widevine device files, and
|
||||
performs release version checks.
|
||||
- `LoggingService` (`OF DL.Core/Services/LoggingService.cs`) writes logs to `logs/OFDL.txt` and updates log level based
|
||||
on
|
||||
config.
|
||||
- `FileNameService` (`OF DL.Core/Services/FileNameService.cs`) formats filenames using the custom format rules from
|
||||
config.
|
||||
## Files and entry points to check first
|
||||
|
||||
## Models
|
||||
- `OF DL.Gui/ViewModels/MainWindowViewModel.cs`
|
||||
- `OF DL.Gui/Views/MainWindow.axaml`
|
||||
- `OF DL.Core/Services/ApiService.cs`
|
||||
- `OF DL.Core/Services/AuthService.cs`
|
||||
- `OF DL.Core/Services/ConfigService.cs`
|
||||
- `OF DL.Core/Services/DownloadOrchestrationService.cs`
|
||||
- `OF DL.Core/Services/DownloadService.cs`
|
||||
- `OF DL.Core/Services/StartupService.cs`
|
||||
- `OF DL.Core/Services/DBService.cs`
|
||||
- `OF DL.Core/Helpers/EnvironmentHelper.cs`
|
||||
|
||||
- DTOs live under `OF DL.Core/Models/Dtos/` and mirror API response JSON.
|
||||
- Entities live under `OF DL.Core/Models/Entities/` and represent the internal domain used by download logic.
|
||||
- Mappers in `OF DL.Core/Models/Mappers/` convert DTOs into entities to isolate API changes from downstream logic.
|
||||
- Non-DTO/Entity models are grouped by concern under `OF DL.Core/Models/OfdlApi/`, `Auth/`, `Config/`, `Downloads/`,
|
||||
and `Startup/`.
|
||||
- Classes in `OF DL.Core/Models/OfdlApi/` mirror request and response JOSN OF DL APIs (custom and gitea)
|
||||
- Classes in `OF DL.Core/Models/Config/` are used for reading and storing application configuration
|
||||
- Classes in `OF DL.Core/Models/Downloads/` contain counts and application state for downloads
|
||||
## Runtime files (relative to working directory)
|
||||
|
||||
## Configuration
|
||||
- `config.conf` (primary config)
|
||||
- `auth.json` (saved auth)
|
||||
- `rules.json` (dynamic rules fallback)
|
||||
- `users.db` (global user index)
|
||||
- `chromium-data/` (browser profile for auth)
|
||||
- `cdm/` (Widevine device files)
|
||||
|
||||
- Primary config file is `config.conf` (HOCON). `ConfigService` migrates legacy `config.json` if found and creates a
|
||||
default `config.conf` if missing.
|
||||
- `Config` lives in `OF DL.Core/Models/Config/Config.cs` and is populated by `ConfigService.LoadConfigFromFileAsync`.
|
||||
- `ConfigService.UpdateConfig` is the central place where runtime config changes are applied (logging level and text
|
||||
sanitization).
|
||||
- CLI flag `--non-interactive` forces non-interactive mode; `ConfigService.IsCliNonInteractive` and
|
||||
`Config.NonInteractiveMode` both gate prompts.
|
||||
- FFmpeg path is read from `config.conf`, `auth.json`, or auto-detected from PATH/current directory.
|
||||
Default download root when `DownloadPath` is blank:
|
||||
|
||||
## Runtime Files (relative to the working directory)
|
||||
- `__user_data__/sites/OnlyFans/{username}`
|
||||
|
||||
- `config.conf`, `auth.json`, and `rules.json` are loaded from the current working directory.
|
||||
- `cdm/` (Widevine device files), `chrome-data/` (Puppeteer profile), and `logs/` are created under the working
|
||||
directory.
|
||||
- `users.db` is stored at the working directory root.
|
||||
## Commands
|
||||
|
||||
## Execution and Testing
|
||||
|
||||
- .NET SDK: 8.x (`net8.0` for all projects).
|
||||
- Build from the repo root:
|
||||
Build GUI:
|
||||
|
||||
```bash
|
||||
dotnet build OF DL.sln
|
||||
dotnet build "OF DL.Gui/OF DL.Gui.csproj"
|
||||
```
|
||||
|
||||
- Run from source (runtime files are read from the current working directory):
|
||||
Build CLI:
|
||||
|
||||
```bash
|
||||
dotnet run --project "OF DL/OF DL.csproj"
|
||||
dotnet build "OF DL.Cli/OF DL.Cli.csproj"
|
||||
```
|
||||
|
||||
- If you want a local `rules.json` fallback, run from `OF DL/` or copy `OF DL/rules.json` into your working directory.
|
||||
- Run tests:
|
||||
Tests:
|
||||
|
||||
```bash
|
||||
dotnet test "OF DL.Tests/OF DL.Tests.csproj"
|
||||
```
|
||||
|
||||
- Optional coverage (coverlet collector):
|
||||
Coverage (optional):
|
||||
|
||||
```bash
|
||||
dotnet test "OF DL.Tests/OF DL.Tests.csproj" --collect:"XPlat Code Coverage"
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
## High-impact technical details
|
||||
|
||||
- Auth data is stored in `auth.json` using the `Auth` model in `OF DL.Core/Models/Auth/Auth.cs`.
|
||||
- `AuthService.LoadFromBrowserAsync` launches Chrome via PuppeteerSharp, waits for login, then extracts `auth_id` and
|
||||
`sess` cookies, `bcTokenSha` from localStorage (used as `X_BC`), and `USER_AGENT` from the browser.
|
||||
- `AuthService.ValidateCookieString` rewrites the cookie string so it contains only `auth_id` and `sess` and ensures a
|
||||
trailing `;`.
|
||||
- `AuthService` uses `chrome-data/` as its user data directory; `Logout` deletes `chrome-data` and `auth.json`.
|
||||
Dynamic rules and request signing (`ApiService.GetDynamicHeaders`):
|
||||
|
||||
Environment variables used by auth:
|
||||
- Remote rules URL: `https://git.ofdl.tools/sim0n00ps/dynamic-rules/raw/branch/main/rules.json`
|
||||
- Falls back to local `rules.json`
|
||||
- Signed headers include `app-token`, `sign`, `time`, `user-id`, `user-agent`, `x-bc`, `cookie`
|
||||
|
||||
- `OFDL_DOCKER=true` toggles Docker-specific instructions and browser flags.
|
||||
- `OFDL_PUPPETEER_EXECUTABLE_PATH` overrides the Chromium path for PuppeteerSharp.
|
||||
DRM decryption:
|
||||
|
||||
## Dynamic Rules and Signature Headers
|
||||
- Preferred: local CDM device files under `cdm/devices/chrome_1610/`
|
||||
- Fallback path exists when CDM files are missing (`ofdl.tools/WV`)
|
||||
- Primary flow is in `DownloadService` + DRM helpers in `ApiService`
|
||||
|
||||
- All OnlyFans API requests use dynamic headers from `ApiService.GetDynamicHeaders`.
|
||||
- Dynamic rules are fetched from `https://git.ofdl.tools/sim0n00ps/dynamic-rules/raw/branch/main/rules.json` with
|
||||
fallback to local `rules.json` in the current working directory. The repo ships `OF DL/rules.json` as the default
|
||||
rules file.
|
||||
- Cache durations: 15 minutes for remote rules, 5 minutes for local rules.
|
||||
- `DynamicRules` shape is defined in `OF DL.Core/Models/OfdlApi/DynamicRules.cs` and includes `app-token`,
|
||||
`static_param`,
|
||||
`prefix`, `suffix`, `checksum_constant`, and `checksum_indexes`.
|
||||
## Documentation update rules
|
||||
|
||||
Signature algorithm in `GetDynamicHeaders`:
|
||||
Update docs whenever user-facing behavior changes.
|
||||
|
||||
- `input = "{static_param}\n{timestamp_ms}\n{path+query}\n{user_id}"`
|
||||
- `hash = SHA1(input)` lower-case hex string
|
||||
- `checksum = sum(hashString[index] char values) + checksum_constant`
|
||||
- `sign = "{prefix}:{hash}:{checksum_hex}:{suffix}"`
|
||||
- Config shape/options changed: update
|
||||
- `docs/config/configuration.md`
|
||||
- `docs/config/all-configuration-options.md`
|
||||
- `docs/config/custom-filename-formats.md` (if filename tokens/formats changed)
|
||||
- Auth/login behavior changed: update `docs/config/auth.md`
|
||||
- GUI/CLI workflow changed: update
|
||||
- `docs/operation/modern-version.md`
|
||||
- `docs/operation/classic-version.md` (if applicable)
|
||||
- Docker runtime/paths changed: update `docs/installation/docker.md`
|
||||
|
||||
Headers included in signed requests:
|
||||
## Coding style essentials
|
||||
|
||||
- `app-token`, `sign`, `time`, `user-id`, `user-agent`, `x-bc`, `cookie`.
|
||||
Follow `.editorconfig`. Most important rules:
|
||||
|
||||
## Widevine CDM and DRM Decryption
|
||||
- 4 spaces (2 for XML/YAML/project files), no tabs
|
||||
- C# braces on new lines
|
||||
- Prefer predefined types (`int`, `string`)
|
||||
- `using` directives outside namespace, `System` first
|
||||
- Private fields `_camelCase`; private static `s_` prefix
|
||||
|
||||
- Runtime Widevine device files are expected at `cdm/devices/chrome_1610/device_client_id_blob` and
|
||||
`cdm/devices/chrome_1610/device_private_key` (relative to the working directory). Paths are defined in
|
||||
`OF DL.Core/Widevine/Constants.cs` and validated in `StartupService`.
|
||||
## Agent guardrails
|
||||
|
||||
DRM flow is primarily in `DownloadService.GetDecryptionInfo` and `ApiService` DRM helpers:
|
||||
|
||||
- `ApiService.GetDRMMPDPSSH` downloads the MPD manifest and extracts the `cenc:pssh` value.
|
||||
- `ApiService.GetDRMMPDLastModified` uses CloudFront signed cookies and returns MPD `Last-Modified`.
|
||||
- `DownloadService.GetDecryptionInfo` builds DRM headers (via `GetDynamicHeaders`) and hits the license endpoint.
|
||||
|
||||
Two decryption paths exist:
|
||||
|
||||
- If CDM device files exist, `ApiService.GetDecryptionKeyCDM` uses `Widevine/CDMApi`.
|
||||
- If missing, `ApiService.GetDecryptionKeyOFDL` calls `https://ofdl.tools/WV` as a fallback.
|
||||
|
||||
`DownloadService.DownloadDrmMedia` runs FFmpeg with `-cenc_decryption_key`, CloudFront cookies, and auth
|
||||
cookies/user-agent. Output is written to `{filename}_source.mp4`, then moved and recorded in SQLite.
|
||||
|
||||
## Download Paths, Data, and Logs
|
||||
|
||||
- Default download root is `__user_data__/sites/OnlyFans/{username}` when `DownloadPath` is blank. This is computed in
|
||||
`DownloadOrchestrationService.ResolveDownloadPath`.
|
||||
- Each creator folder gets a `Metadata/user_data.db` (SQLite) managed by `DBService`.
|
||||
- A global `users.db` in the working directory tracks subscribed creators and user IDs.
|
||||
- Logs are written to `logs/OFDL.txt` (rolling daily); FFmpeg report files are also written under `logs/` when debug
|
||||
logging is enabled.
|
||||
|
||||
## Docs (MkDocs)
|
||||
|
||||
- Docs source lives under `docs/` and configuration is in `mkdocs.yml`.
|
||||
- Build the site with `mkdocs build --clean` (outputs to `site/`).
|
||||
- Preview locally with `mkdocs serve`.
|
||||
|
||||
## CI/CD (Gitea Workflows)
|
||||
|
||||
- `/.gitea/workflows/publish-docs.yml` builds and deploys docs on tag pushes matching `OFDLV*` and on manual dispatch.
|
||||
- `/.gitea/workflows/publish-docker.yml` builds multi-arch Docker images on `OFDLV*` tags and pushes to the Gitea
|
||||
registry.
|
||||
- `/.gitea/workflows/publish-release.yml` publishes Windows and Linux builds on `OFDLV*` tags and creates a draft
|
||||
release.
|
||||
|
||||
## Docker Image
|
||||
|
||||
- Built via `/.gitea/workflows/publish-docker.yml` on tag pushes `OFDLV*`.
|
||||
- Image tags: `git.ofdl.tools/sim0n00ps/of-dl:latest` and `git.ofdl.tools/sim0n00ps/of-dl:{version}`.
|
||||
- Build args include `VERSION` (tag name with `OFDLV` stripped).
|
||||
- Platforms: `linux/amd64` and `linux/arm64`.
|
||||
- Runtime uses `/config` as the working directory and `/data` for downloads; `docker/entrypoint.sh` seeds
|
||||
`/config/config.conf` and `/config/rules.json` from `/default-config`.
|
||||
|
||||
## Release Checklist
|
||||
|
||||
1. Update docs under `docs/` and verify locally with `mkdocs build --clean` or `mkdocs serve`.
|
||||
2. Tag the release as `OFDLV{version}` and push the tag.
|
||||
3. Verify the draft release artifact and publish the release in Gitea.
|
||||
|
||||
## Coding Style (from .editorconfig)
|
||||
|
||||
- Indentation: 4 spaces by default, 2 spaces for XML/YAML/project files. No tabs.
|
||||
- Line endings: LF for `*.sh`, CRLF for `*.cmd`/`*.bat`.
|
||||
- C# braces on new lines (`csharp_new_line_before_open_brace = all`).
|
||||
- Prefer predefined types (`int`, `string`) and avoid `var` except when type is apparent.
|
||||
- `using` directives go outside the namespace and `System` namespaces are sorted first.
|
||||
- Private/internal fields use `_camelCase`; private/internal static fields use `s_` prefix.
|
||||
- `const` fields should be PascalCase.
|
||||
- Prefer braces for control blocks and expression-bodied members (silent preferences).
|
||||
|
||||
## Where to Look First
|
||||
|
||||
- `OF DL/Program.cs` for the execution path and menu flow.
|
||||
- `OF DL.Core/Services/ApiService.cs` for OF API calls and header signing.
|
||||
- `OF DL.Core/Services/DownloadService.cs` for downloads and DRM handling.
|
||||
- `OF DL.Core/Services/DownloadOrchestrationService.cs` for creator selection and flow control.
|
||||
- `OF DL.Core/Widevine/` for CDM key generation and license parsing.
|
||||
- `OF DL.Core/Models/Config/Config.cs` and `OF DL.Core/Services/ConfigService.cs` for config shape and parsing.
|
||||
- `OF DL.Core/Services/AuthService.cs` for user-facing authentication behavior and browser login flow.
|
||||
- `docs/` for public documentation; update docs whenever user-facing behavior or configuration changes.
|
||||
|
||||
## Documentation updates for common changes:
|
||||
|
||||
- Config option added/removed/changed in `Config` or `config.conf`: update `docs/config/all-configuration-options.md` (
|
||||
full spec), `docs/config/configuration.md` (organized list), and `docs/config/custom-filename-formats.md` if filename
|
||||
tokens or formats are affected.
|
||||
- Authentication flow changes (browser login, legacy methods, required fields): update `docs/config/auth.md`.
|
||||
- CLI/menu flow or download workflow changes: update `docs/running-the-program.md`.
|
||||
- Docker runtime or container flags/paths change: update `docs/installation/docker.md`.
|
||||
- Prefer small, targeted changes in the service/viewmodel responsible for the behavior.
|
||||
- Keep GUI and CLI behavior aligned when changes affect both.
|
||||
- Do not manually edit generated docs in `site/`.
|
||||
- If you add new significant workflow/service structure, update this file.
|
||||
|
||||
39
Dockerfile
39
Dockerfile
@ -4,22 +4,23 @@ ARG VERSION
|
||||
|
||||
# Copy source code
|
||||
COPY ["OF DL.sln", "/src/OF DL.sln"]
|
||||
COPY ["OF DL", "/src/OF DL"]
|
||||
COPY ["OF DL.Cli", "/src/OF DL.Cli"]
|
||||
COPY ["OF DL.Core", "/src/OF DL.Core"]
|
||||
COPY ["OF DL.Gui", "/src/OF DL.Gui"]
|
||||
|
||||
WORKDIR "/src"
|
||||
|
||||
# Build release
|
||||
RUN dotnet publish "OF DL/OF DL.csproj" -p:WarningLevel=0 -p:Version=$VERSION -c Release -o out
|
||||
|
||||
RUN dotnet publish "OF DL.Gui/OF DL.Gui.csproj" -p:WarningLevel=0 -p:Version=$VERSION -c Release -o outgui \
|
||||
&& dotnet publish "OF DL.Cli/OF DL.Cli.csproj" -p:WarningLevel=0 -p:Version=$VERSION -c Release -o outcli \
|
||||
# Generate default config.conf files
|
||||
RUN /src/out/OF\ DL --non-interactive || true && \
|
||||
&& /src/outcli/OF\ DL.Cli --non-interactive || true && \
|
||||
# Set download path in default config.conf to /data
|
||||
sed -e 's/DownloadPath = ""/DownloadPath = "\/data"/' /src/config.conf > /src/updated_config.conf && \
|
||||
mv /src/updated_config.conf /src/config.conf
|
||||
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final
|
||||
FROM ubuntu:noble AS final
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update \
|
||||
@ -31,25 +32,25 @@ RUN apt-get update \
|
||||
x11vnc \
|
||||
novnc \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npx playwright install-deps
|
||||
|
||||
RUN apt-get remove --purge -y npm \
|
||||
&& apt-get autoremove -y
|
||||
|
||||
openbox \
|
||||
&& npx playwright install-deps \
|
||||
&& apt-get remove --purge -y npm \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
# Redirect webroot to vnc.html instead of displaying directory listing
|
||||
RUN echo "<!DOCTYPE html><html><head><meta http-equiv=\"Refresh\" content=\"0; url='vnc.html'\" /></head><body></body></html>" > /usr/share/novnc/index.html
|
||||
|
||||
&& echo "<!DOCTYPE html><html><head><meta http-equiv=\"Refresh\" content=\"0; url='vnc.html'\" /></head><body></body></html>" > /usr/share/novnc/index.html \
|
||||
# Create directories for configuration and downloaded files
|
||||
RUN mkdir /data /config /config/logs /default-config
|
||||
&& mkdir -p /data /config /config/logs /default-config
|
||||
|
||||
# Copy release
|
||||
COPY --from=build /src/out /app
|
||||
COPY --from=build /src/outgui /app
|
||||
ARG cli_src="/src/outcli/OF DL.Cli"
|
||||
ARG cli_target="/app/OF DL.Cli"
|
||||
COPY --from=build ${cli_src} ${cli_target}
|
||||
|
||||
# Copy default configuration files
|
||||
COPY --from=build /src/config.conf /default-config
|
||||
COPY --from=build ["/src/OF DL/rules.json", "/default-config"]
|
||||
COPY --from=build ["/src/OF DL.Cli/rules.json", "/default-config"]
|
||||
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY docker/entrypoint.sh /app/entrypoint.sh
|
||||
@ -64,5 +65,5 @@ ENV DEBIAN_FRONTEND="noninteractive" \
|
||||
|
||||
EXPOSE 8080
|
||||
WORKDIR /config
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
|
||||
CMD []
|
||||
|
||||
@ -10,6 +10,8 @@ namespace OF_DL.CLI;
|
||||
/// </summary>
|
||||
public class SpectreDownloadEventHandler : IDownloadEventHandler
|
||||
{
|
||||
public CancellationToken CancellationToken { get; } = CancellationToken.None;
|
||||
|
||||
public async Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work)
|
||||
{
|
||||
TaskCompletionSource<T> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@ -6,9 +6,11 @@ namespace OF_DL.CLI;
|
||||
/// <summary>
|
||||
/// Implementation of IProgressReporter that uses Spectre.Console's ProgressTask for CLI output.
|
||||
/// </summary>
|
||||
public class SpectreProgressReporter(ProgressTask task) : IProgressReporter
|
||||
public class SpectreProgressReporter(ProgressTask task, CancellationToken cancellationToken = default) : IProgressReporter
|
||||
{
|
||||
private readonly ProgressTask _task = task ?? throw new ArgumentNullException(nameof(task));
|
||||
|
||||
public CancellationToken CancellationToken { get; } = cancellationToken;
|
||||
|
||||
public void ReportProgress(long increment) => _task.Increment(increment);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@ -7,6 +7,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationIcon>Icon\download.ico</ApplicationIcon>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@ -40,12 +42,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="auth.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="config.conf">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="rules.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using OF_DL.CLI;
|
||||
using OF_DL.Models;
|
||||
using OF_DL.Enumerations;
|
||||
using OF_DL.Helpers;
|
||||
using OF_DL.Models.Config;
|
||||
using OF_DL.Models.Downloads;
|
||||
using OF_DL.Models.Entities.Users;
|
||||
@ -17,14 +18,13 @@ public class Program(IServiceProvider serviceProvider)
|
||||
private async Task LoadAuthFromBrowser()
|
||||
{
|
||||
IAuthService authService = serviceProvider.GetRequiredService<IAuthService>();
|
||||
bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
|
||||
|
||||
// Show the initial message
|
||||
AnsiConsole.MarkupLine("[yellow]Downloading dependencies. Please wait ...[/]");
|
||||
|
||||
// Show instructions based on the environment
|
||||
await Task.Delay(5000);
|
||||
if (runningInDocker)
|
||||
if (EnvironmentHelper.IsRunningInDocker())
|
||||
{
|
||||
AnsiConsole.MarkupLine(
|
||||
"[yellow]In your web browser, navigate to the port forwarded from your docker container.[/]");
|
||||
@ -72,7 +72,6 @@ public class Program(IServiceProvider serviceProvider)
|
||||
ServiceCollection services = await ConfigureServices(args);
|
||||
ServiceProvider serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// Get the Program instance and run
|
||||
Program program = serviceProvider.GetRequiredService<Program>();
|
||||
await program.RunAsync();
|
||||
}
|
||||
@ -210,14 +209,6 @@ public class Program(IServiceProvider serviceProvider)
|
||||
Log.Error("Auth failed");
|
||||
authService.CurrentAuth = null;
|
||||
|
||||
if (!configService.CurrentConfig.DisableBrowserAuth)
|
||||
{
|
||||
if (File.Exists("auth.json"))
|
||||
{
|
||||
File.Delete("auth.json");
|
||||
}
|
||||
}
|
||||
|
||||
if (!configService.CurrentConfig.NonInteractiveMode &&
|
||||
!configService.CurrentConfig.DisableBrowserAuth)
|
||||
{
|
||||
@ -303,8 +294,9 @@ public class Program(IServiceProvider serviceProvider)
|
||||
}
|
||||
else if (config.NonInteractiveMode && !string.IsNullOrEmpty(config.NonInteractiveModeListName))
|
||||
{
|
||||
Dictionary<string, long> selectedUsers =
|
||||
ListUserSelectionResult listSelectionResult =
|
||||
await orchestrationService.GetUsersForListAsync(config.NonInteractiveModeListName, users, lists);
|
||||
Dictionary<string, long> selectedUsers = listSelectionResult.SelectedUsers;
|
||||
hasSelectedUsersKVP = new KeyValuePair<bool, Dictionary<string, long>>(true, selectedUsers);
|
||||
}
|
||||
else
|
||||
@ -748,11 +740,6 @@ public class Program(IServiceProvider serviceProvider)
|
||||
else if (File.Exists("auth.json"))
|
||||
{
|
||||
Log.Information("Auth file found but could not be deserialized");
|
||||
if (!configService.CurrentConfig.DisableBrowserAuth)
|
||||
{
|
||||
Log.Debug("Deleting auth.json");
|
||||
File.Delete("auth.json");
|
||||
}
|
||||
|
||||
if (configService.CurrentConfig.NonInteractiveMode)
|
||||
{
|
||||
@ -847,8 +834,8 @@ public class Program(IServiceProvider serviceProvider)
|
||||
private static void DisplayStartupResult(StartupResult result)
|
||||
{
|
||||
// OS
|
||||
if (result.IsWindowsVersionValid && result.OsVersionString != null &&
|
||||
Environment.OSVersion.Platform == PlatformID.Win32NT)
|
||||
if (result is { IsWindowsVersionValid: true, OsVersionString: not null } &&
|
||||
EnvironmentHelper.IsRunningOnWindows())
|
||||
{
|
||||
AnsiConsole.Markup("[green]Valid version of Windows found.\n[/]");
|
||||
}
|
||||
16
OF DL.Cli/chromium-scripts/CREATING_STEALTH_SCRIPT.md
Normal file
16
OF DL.Cli/chromium-scripts/CREATING_STEALTH_SCRIPT.md
Normal file
@ -0,0 +1,16 @@
|
||||
# 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).
|
||||
9
OF DL.Core/Enumerations/Theme.cs
Normal file
9
OF DL.Core/Enumerations/Theme.cs
Normal file
@ -0,0 +1,9 @@
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
namespace OF_DL.Enumerations;
|
||||
|
||||
public enum Theme
|
||||
{
|
||||
light,
|
||||
dark
|
||||
}
|
||||
@ -2,6 +2,12 @@ namespace OF_DL.Helpers;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
public const string DiscordInviteUrl = "https://discord.com/invite/6bUW8EJ53j";
|
||||
|
||||
public const string DocumentationUrl = "https://docs.ofdl.tools/";
|
||||
|
||||
public const string LegacyAuthDocumentationUrl = "https://docs.ofdl.tools/config/auth/#legacy-methods";
|
||||
|
||||
public const string ApiUrl = "https://onlyfans.com/api2/v2";
|
||||
|
||||
public const int ApiPageSize = 50;
|
||||
|
||||
14
OF DL.Core/Helpers/EnvironmentHelper.cs
Normal file
14
OF DL.Core/Helpers/EnvironmentHelper.cs
Normal file
@ -0,0 +1,14 @@
|
||||
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();
|
||||
}
|
||||
@ -89,6 +89,13 @@ public class Config : IFileNameFormatConfig
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public LoggingLevel LoggingLevel { get; set; } = LoggingLevel.Error;
|
||||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public Theme Theme { get; set; } = Theme.dark;
|
||||
|
||||
[ToggleableConfig] public bool HideMissingCdmKeysWarning { get; set; }
|
||||
|
||||
[ToggleableConfig] public bool HideShowScrapeSizeWarning { get; set; }
|
||||
|
||||
[ToggleableConfig] public bool IgnoreOwnMessages { get; set; }
|
||||
|
||||
[ToggleableConfig] public bool DisableBrowserAuth { get; set; }
|
||||
@ -102,8 +109,11 @@ public class Config : IFileNameFormatConfig
|
||||
[ToggleableConfig] public bool DisableTextSanitization { get; set; }
|
||||
|
||||
public string? PaidPostFileNameFormat { get; set; } = "";
|
||||
|
||||
public string? PostFileNameFormat { get; set; } = "";
|
||||
|
||||
public string? PaidMessageFileNameFormat { get; set; } = "";
|
||||
|
||||
public string? MessageFileNameFormat { get; set; } = "";
|
||||
|
||||
public IFileNameFormatConfig GetCreatorFileNameFormatConfig(string username)
|
||||
|
||||
@ -27,3 +27,10 @@ public class UserListResult
|
||||
|
||||
public string? IgnoredListError { get; set; }
|
||||
}
|
||||
|
||||
public class ListUserSelectionResult
|
||||
{
|
||||
public Dictionary<string, long> SelectedUsers { get; set; } = new();
|
||||
|
||||
public List<string> UnavailableUsernames { get; set; } = [];
|
||||
}
|
||||
|
||||
@ -159,8 +159,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// Retrieves user information from the API.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The user endpoint.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>The user entity when available.</returns>
|
||||
public async Task<UserEntities.User?> GetUserInfo(string endpoint)
|
||||
public async Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetUserInfo: {endpoint}");
|
||||
|
||||
@ -171,6 +172,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
UserEntities.User user = new();
|
||||
Dictionary<string, string> getParams = new()
|
||||
{
|
||||
@ -180,7 +182,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
HttpClient client = new();
|
||||
HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint);
|
||||
|
||||
using HttpResponseMessage response = await client.SendAsync(request);
|
||||
using HttpResponseMessage response = await client.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
@ -194,6 +196,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
user = UserMapper.FromDto(userDto) ?? new UserEntities.User();
|
||||
return user;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ExceptionLoggerHelper.LogException(ex);
|
||||
@ -236,6 +242,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
return jObject;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ExceptionLoggerHelper.LogException(ex);
|
||||
@ -412,11 +422,13 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// <param name="endpoint">The endpoint to query.</param>
|
||||
/// <param name="username">Optional username context.</param>
|
||||
/// <param name="folder">The creator folder path.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A mediaId-to-URL map.</returns>
|
||||
public async Task<Dictionary<long, string>?> GetMedia(MediaType mediatype,
|
||||
string endpoint,
|
||||
string? username,
|
||||
string folder)
|
||||
string folder,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetMedia - {username}");
|
||||
|
||||
@ -427,6 +439,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Dictionary<long, string> returnUrls = new();
|
||||
const int limit = 5;
|
||||
int offset = 0;
|
||||
@ -452,7 +465,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
break;
|
||||
}
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient());
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient(),
|
||||
cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
@ -531,7 +545,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
{
|
||||
Log.Debug("Media Highlights - " + endpoint);
|
||||
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(loopbody))
|
||||
{
|
||||
Log.Warning("Received empty body from API");
|
||||
@ -576,7 +591,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
highlightRequest.Headers.Add(keyValuePair.Key, keyValuePair.Value);
|
||||
}
|
||||
|
||||
using HttpResponseMessage highlightResponse = await highlightClient.SendAsync(highlightRequest);
|
||||
using HttpResponseMessage highlightResponse =
|
||||
await highlightClient.SendAsync(highlightRequest, cancellationToken);
|
||||
highlightResponse.EnsureSuccessStatusCode();
|
||||
string highlightBody = await highlightResponse.Content.ReadAsStringAsync();
|
||||
HighlightDtos.HighlightMediaDto? highlightMediaDto =
|
||||
@ -630,6 +646,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
return returnUrls;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ExceptionLoggerHelper.LogException(ex);
|
||||
@ -647,10 +667,11 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// <param name="username">The creator username.</param>
|
||||
/// <param name="paidPostIds">A list to collect paid media IDs.</param>
|
||||
/// <param name="statusReporter">Status reporter.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A paid post collection.</returns>
|
||||
public async Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
|
||||
string username,
|
||||
List<long> paidPostIds, IStatusReporter statusReporter)
|
||||
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetPaidPosts - {username}");
|
||||
|
||||
@ -666,7 +687,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
{ "author", username }
|
||||
};
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
PurchasedDtos.PurchasedDto? paidPostsDto =
|
||||
DeserializeJson<PurchasedDtos.PurchasedDto>(body, s_mJsonSerializerSettings);
|
||||
PurchasedEntities.Purchased paidPosts = PurchasedMapper.FromDto(paidPostsDto);
|
||||
@ -676,7 +698,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
getParams["offset"] = paidPosts.List.Count.ToString();
|
||||
while (true)
|
||||
{
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
PurchasedDtos.PurchasedDto? newPaidPostsDto =
|
||||
DeserializeJson<PurchasedDtos.PurchasedDto>(loopbody, s_mJsonSerializerSettings);
|
||||
PurchasedEntities.Purchased newPaidPosts = PurchasedMapper.FromDto(newPaidPostsDto);
|
||||
@ -829,9 +852,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// <param name="folder">The creator folder path.</param>
|
||||
/// <param name="paidPostIds">Paid post media IDs to skip.</param>
|
||||
/// <param name="statusReporter">Status reporter.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A post collection.</returns>
|
||||
public async Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
|
||||
IStatusReporter statusReporter)
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetPosts - {endpoint}");
|
||||
|
||||
@ -869,7 +893,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
ref getParams,
|
||||
downloadAsOf);
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient());
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient(),
|
||||
cancellationToken);
|
||||
PostDtos.PostDto? postsDto =
|
||||
DeserializeJson<PostDtos.PostDto>(body, s_mJsonSerializerSettings);
|
||||
PostEntities.Post posts = PostMapper.FromDto(postsDto);
|
||||
@ -883,7 +908,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
while (true)
|
||||
{
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
PostDtos.PostDto? newPostsDto =
|
||||
DeserializeJson<PostDtos.PostDto>(loopbody, s_mJsonSerializerSettings);
|
||||
PostEntities.Post newposts = PostMapper.FromDto(newPostsDto);
|
||||
@ -1018,8 +1044,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The post endpoint.</param>
|
||||
/// <param name="folder">The creator folder path.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A single post collection.</returns>
|
||||
public async Task<SinglePostCollection> GetPost(string endpoint, string folder)
|
||||
public async Task<SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetPost - {endpoint}");
|
||||
|
||||
@ -1028,7 +1055,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
SinglePostCollection singlePostCollection = new();
|
||||
Dictionary<string, string> getParams = new() { { "skip_users", "all" } };
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient());
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient(),
|
||||
cancellationToken);
|
||||
PostDtos.SinglePostDto? singlePostDto =
|
||||
DeserializeJson<PostDtos.SinglePostDto>(body, s_mJsonSerializerSettings);
|
||||
PostEntities.SinglePost singlePost = PostMapper.FromDto(singlePostDto);
|
||||
@ -1166,10 +1194,11 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// <param name="folder">The creator folder path.</param>
|
||||
/// <param name="paidPostIds">Paid post media IDs to skip.</param>
|
||||
/// <param name="statusReporter">Status reporter.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A streams collection.</returns>
|
||||
public async Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder,
|
||||
List<long> paidPostIds,
|
||||
IStatusReporter statusReporter)
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetStreams - {endpoint}");
|
||||
|
||||
@ -1195,7 +1224,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
ref getParams,
|
||||
configService.CurrentConfig.CustomDate);
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient());
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient(),
|
||||
cancellationToken);
|
||||
StreamsDtos.StreamsDto? streamsDto =
|
||||
DeserializeJson<StreamsDtos.StreamsDto>(body, s_mJsonSerializerSettings);
|
||||
StreamEntities.Streams streams = StreamsMapper.FromDto(streamsDto);
|
||||
@ -1209,7 +1239,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
while (true)
|
||||
{
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
StreamsDtos.StreamsDto? newStreamsDto =
|
||||
DeserializeJson<StreamsDtos.StreamsDto>(loopbody, s_mJsonSerializerSettings);
|
||||
StreamEntities.Streams newstreams = StreamsMapper.FromDto(newStreamsDto);
|
||||
@ -1318,9 +1349,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// <param name="endpoint">The archived posts endpoint.</param>
|
||||
/// <param name="folder">The creator folder path.</param>
|
||||
/// <param name="statusReporter">Status reporter.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>An archived collection.</returns>
|
||||
public async Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
|
||||
IStatusReporter statusReporter)
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetArchived - {endpoint}");
|
||||
|
||||
@ -1348,7 +1380,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
ref getParams,
|
||||
configService.CurrentConfig.CustomDate);
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
if (body == null)
|
||||
{
|
||||
throw new Exception("Failed to retrieve archived posts. Received null response.");
|
||||
@ -1366,7 +1399,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
archived.TailMarker);
|
||||
while (true)
|
||||
{
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
if (loopbody == null)
|
||||
{
|
||||
throw new Exception("Failed to retrieve archived posts. Received null response.");
|
||||
@ -1472,9 +1506,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// <param name="endpoint">The messages endpoint.</param>
|
||||
/// <param name="folder">The creator folder path.</param>
|
||||
/// <param name="statusReporter">Status reporter.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A message collection.</returns>
|
||||
public async Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
|
||||
IStatusReporter statusReporter)
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetMessages - {endpoint}");
|
||||
|
||||
@ -1487,7 +1522,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
};
|
||||
int currentUserId = GetCurrentUserIdOrDefault();
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
MessageDtos.MessagesDto? messagesDto =
|
||||
DeserializeJson<MessageDtos.MessagesDto>(body, s_mJsonSerializerSettings);
|
||||
MessageEntities.Messages messages = MessagesMapper.FromDto(messagesDto);
|
||||
@ -1497,7 +1533,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
getParams["id"] = messages.List[^1].Id.ToString();
|
||||
while (true)
|
||||
{
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
MessageDtos.MessagesDto? newMessagesDto =
|
||||
DeserializeJson<MessageDtos.MessagesDto>(loopbody, s_mJsonSerializerSettings);
|
||||
MessageEntities.Messages newMessages = MessagesMapper.FromDto(newMessagesDto);
|
||||
@ -1661,8 +1698,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The paid message endpoint.</param>
|
||||
/// <param name="folder">The creator folder path.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A single paid message collection.</returns>
|
||||
public async Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder)
|
||||
public async Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetPaidMessage - {endpoint}");
|
||||
|
||||
@ -1673,7 +1711,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
new() { { "limit", Constants.ApiPageSize.ToString() }, { "order", "desc" } };
|
||||
int currentUserId = GetCurrentUserIdOrDefault();
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
MessageDtos.SingleMessageDto? messageDto =
|
||||
DeserializeJson<MessageDtos.SingleMessageDto>(body, s_mJsonSerializerSettings);
|
||||
MessageEntities.SingleMessage message = MessagesMapper.FromDto(messageDto);
|
||||
@ -1807,10 +1846,11 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// <param name="folder">The creator folder path.</param>
|
||||
/// <param name="username">The creator username.</param>
|
||||
/// <param name="statusReporter">Status reporter.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A paid message collection.</returns>
|
||||
public async Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder,
|
||||
string username,
|
||||
IStatusReporter statusReporter)
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetPaidMessages - {username}");
|
||||
|
||||
@ -1827,7 +1867,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
};
|
||||
int currentUserId = GetCurrentUserIdOrDefault();
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
PurchasedDtos.PurchasedDto? paidMessagesDto =
|
||||
DeserializeJson<PurchasedDtos.PurchasedDto>(body, s_mJsonSerializerSettings);
|
||||
PurchasedEntities.Purchased paidMessages = PurchasedMapper.FromDto(paidMessagesDto);
|
||||
@ -1837,6 +1878,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
getParams["offset"] = paidMessages.List.Count.ToString();
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
PurchasedEntities.Purchased newpaidMessages;
|
||||
Dictionary<string, string> loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams);
|
||||
@ -1850,7 +1892,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value);
|
||||
}
|
||||
|
||||
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest))
|
||||
using (HttpResponseMessage loopresponse =
|
||||
await loopclient.SendAsync(looprequest, cancellationToken))
|
||||
{
|
||||
loopresponse.EnsureSuccessStatusCode();
|
||||
string loopbody = await loopresponse.Content.ReadAsStringAsync();
|
||||
@ -2031,8 +2074,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The purchased tab endpoint.</param>
|
||||
/// <param name="users">Known users map.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A username-to-userId map.</returns>
|
||||
public async Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users)
|
||||
public async Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetPurchasedTabUsers - {endpoint}");
|
||||
|
||||
@ -2047,7 +2091,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
{ "skip_users", "all" }
|
||||
};
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
if (body == null)
|
||||
{
|
||||
throw new Exception("Failed to get purchased tab users. null body returned.");
|
||||
@ -2061,6 +2107,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
getParams["offset"] = purchased.List.Count.ToString();
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
PurchasedEntities.Purchased newPurchased;
|
||||
Dictionary<string, string> loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams);
|
||||
@ -2074,7 +2121,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value);
|
||||
}
|
||||
|
||||
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest))
|
||||
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest, cancellationToken))
|
||||
{
|
||||
loopresponse.EnsureSuccessStatusCode();
|
||||
string loopbody = await loopresponse.Content.ReadAsStringAsync();
|
||||
@ -2099,6 +2146,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
foreach (PurchasedEntities.ListItem purchase in
|
||||
purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
long fromUserId = purchase.FromUser?.Id ?? 0;
|
||||
long authorId = purchase.Author?.Id ?? 0;
|
||||
|
||||
@ -2182,6 +2230,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
return purchasedTabUsers;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ExceptionLoggerHelper.LogException(ex);
|
||||
@ -2196,9 +2248,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
/// <param name="endpoint">The purchased tab endpoint.</param>
|
||||
/// <param name="folder">The base download folder.</param>
|
||||
/// <param name="users">Known users map.</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A list of purchased tab collections.</returns>
|
||||
public async Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
|
||||
Dictionary<string, long> users)
|
||||
Dictionary<string, long> users, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug($"Calling GetPurchasedTab - {endpoint}");
|
||||
|
||||
@ -2214,7 +2267,9 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
{ "skip_users", "all" }
|
||||
};
|
||||
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(),
|
||||
cancellationToken);
|
||||
PurchasedDtos.PurchasedDto? purchasedDto =
|
||||
DeserializeJson<PurchasedDtos.PurchasedDto>(body, s_mJsonSerializerSettings);
|
||||
PurchasedEntities.Purchased purchased = PurchasedMapper.FromDto(purchasedDto);
|
||||
@ -2223,6 +2278,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
getParams["offset"] = purchased.List.Count.ToString();
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
PurchasedEntities.Purchased newPurchased;
|
||||
Dictionary<string, string> loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams);
|
||||
@ -2236,7 +2292,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value);
|
||||
}
|
||||
|
||||
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest))
|
||||
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest, cancellationToken))
|
||||
{
|
||||
loopresponse.EnsureSuccessStatusCode();
|
||||
string loopbody = await loopresponse.Content.ReadAsStringAsync();
|
||||
@ -2261,6 +2317,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
foreach (PurchasedEntities.ListItem purchase in
|
||||
purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (purchase.FromUser != null)
|
||||
{
|
||||
if (!userPurchases.ContainsKey(purchase.FromUser.Id))
|
||||
@ -2284,6 +2341,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
foreach (KeyValuePair<long, List<PurchasedEntities.ListItem>> user in userPurchases)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
PurchasedEntities.PurchasedTabCollection purchasedTabCollection = new();
|
||||
JObject? userObject = await GetUserInfoById($"/users/list?x[]={user.Key}");
|
||||
purchasedTabCollection.UserId = user.Key;
|
||||
@ -2566,6 +2624,10 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
return purchasedTabCollections;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ExceptionLoggerHelper.LogException(ex);
|
||||
@ -2755,12 +2817,12 @@ public class ApiService(IAuthService authService, IConfigService configService,
|
||||
|
||||
|
||||
private async Task<string?> BuildHeaderAndExecuteRequests(Dictionary<string, string> getParams, string endpoint,
|
||||
HttpClient client)
|
||||
HttpClient client, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Debug("Calling BuildHeaderAndExecuteRequests");
|
||||
|
||||
HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint);
|
||||
using HttpResponseMessage response = await client.SendAsync(request);
|
||||
using HttpResponseMessage response = await client.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Playwright;
|
||||
using Newtonsoft.Json;
|
||||
using OF_DL.Helpers;
|
||||
using OF_DL.Models;
|
||||
using Serilog;
|
||||
using UserEntities = OF_DL.Models.Entities.Users;
|
||||
@ -73,19 +74,42 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
|
||||
/// Launches a browser session and extracts auth data after login.
|
||||
/// </summary>
|
||||
/// <returns>True when auth data is captured successfully.</returns>
|
||||
public async Task<bool> LoadFromBrowserAsync()
|
||||
public async Task<bool> LoadFromBrowserAsync(Action<string>? statusCallback = null)
|
||||
{
|
||||
statusCallback?.Invoke("Preparing browser dependencies ...");
|
||||
|
||||
try
|
||||
{
|
||||
bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
|
||||
await SetupBrowser();
|
||||
}
|
||||
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
|
||||
{
|
||||
if (EnvironmentHelper.IsRunningInDocker())
|
||||
{
|
||||
Log.Information("Running in Docker. Disabling sandbox and GPU.");
|
||||
_options.Args =
|
||||
[
|
||||
"--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu",
|
||||
"--disable-blink-features=AutomationControlled", "--disable-infobars"
|
||||
];
|
||||
}
|
||||
|
||||
await SetupBrowser(runningInDocker);
|
||||
CurrentAuth = await GetAuthFromBrowser();
|
||||
|
||||
return CurrentAuth != null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
statusCallback?.Invoke("Failed to get auth from browser.");
|
||||
Log.Error(ex, "Failed to load auth from browser");
|
||||
return false;
|
||||
}
|
||||
@ -107,7 +131,7 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
|
||||
{
|
||||
string json = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented);
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
Log.Debug($"Auth saved to file: {filePath}");
|
||||
Log.Debug("Auth saved to file: {FilePath}", filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -115,33 +139,38 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
|
||||
}
|
||||
}
|
||||
|
||||
private Task SetupBrowser(bool runningInDocker)
|
||||
private static Task SetupBrowser() => Task.Run(async () =>
|
||||
{
|
||||
if (runningInDocker)
|
||||
if (await IsChromiumInstalledAsync())
|
||||
{
|
||||
Log.Information("Running in Docker. Disabling sandbox and GPU.");
|
||||
_options.Args =
|
||||
[
|
||||
"--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu",
|
||||
"--disable-blink-features=AutomationControlled", "--disable-infobars"
|
||||
];
|
||||
|
||||
// If chromium is already downloaded, skip installation
|
||||
string? playwrightBrowsersPath = Environment.GetEnvironmentVariable("PLAYWRIGHT_BROWSERS_PATH");
|
||||
IEnumerable<string> folders = Directory.GetDirectories(playwrightBrowsersPath ?? "/config/chromium")
|
||||
.Where(folder => folder.Contains("chromium-"));
|
||||
|
||||
if (folders.Any())
|
||||
{
|
||||
Log.Information("chromium already downloaded. Skipping install step.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
Log.Information("Chromium already downloaded. Skipping install step.");
|
||||
return;
|
||||
}
|
||||
|
||||
int exitCode = Program.Main(["install", "--with-deps", "chromium"]);
|
||||
return exitCode != 0
|
||||
? throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}")
|
||||
: Task.CompletedTask;
|
||||
if (exitCode != 0)
|
||||
{
|
||||
throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}");
|
||||
}
|
||||
});
|
||||
|
||||
private static async Task<bool> IsChromiumInstalledAsync()
|
||||
{
|
||||
string? playwrightBrowsersPath = Environment.GetEnvironmentVariable("PLAYWRIGHT_BROWSERS_PATH");
|
||||
if (
|
||||
!string.IsNullOrWhiteSpace(playwrightBrowsersPath) &&
|
||||
Directory.Exists(playwrightBrowsersPath) &&
|
||||
Directory.EnumerateDirectories(playwrightBrowsersPath).Any(folder =>
|
||||
Path.GetFileName(folder).StartsWith("chromium-", StringComparison.OrdinalIgnoreCase))
|
||||
)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
using IPlaywright playwright = await Playwright.CreateAsync();
|
||||
string executablePath = playwright.Chromium.ExecutablePath;
|
||||
|
||||
return !string.IsNullOrWhiteSpace(executablePath) && File.Exists(executablePath);
|
||||
}
|
||||
|
||||
private static async Task<string> GetBcToken(IPage page) =>
|
||||
|
||||
@ -236,6 +236,15 @@ public class ConfigService(ILoggingService loggingService) : IConfigService
|
||||
LimitDownloadRate = hoconConfig.GetBoolean("Performance.LimitDownloadRate"),
|
||||
DownloadLimitInMbPerSec = hoconConfig.GetInt("Performance.DownloadLimitInMbPerSec"),
|
||||
|
||||
// Appearance Settings
|
||||
Theme = ParseTheme(hoconConfig.GetString("Appearance.Theme", "dark")),
|
||||
HideMissingCdmKeysWarning =
|
||||
bool.TryParse(hoconConfig.GetString("Appearance.HideMissingCdmKeysWarning", "false"),
|
||||
out bool hideMissingCdmKeysWarning) && hideMissingCdmKeysWarning,
|
||||
HideShowScrapeSizeWarning =
|
||||
bool.TryParse(hoconConfig.GetString("Appearance.HideShowScrapeSizeWarning", "false"),
|
||||
out bool hideShowScrapeSizeWarning) && hideShowScrapeSizeWarning,
|
||||
|
||||
// Logging/Debug Settings
|
||||
LoggingLevel = Enum.Parse<LoggingLevel>(hoconConfig.GetString("Logging.LoggingLevel"), true)
|
||||
};
|
||||
@ -407,6 +416,13 @@ public class ConfigService(ILoggingService loggingService) : IConfigService
|
||||
hocon.AppendLine($" DownloadLimitInMbPerSec = {config.DownloadLimitInMbPerSec}");
|
||||
hocon.AppendLine("}");
|
||||
|
||||
hocon.AppendLine("# Appearance Settings");
|
||||
hocon.AppendLine("Appearance {");
|
||||
hocon.AppendLine($" Theme = \"{config.Theme.ToString().ToLower()}\"");
|
||||
hocon.AppendLine($" HideMissingCdmKeysWarning = {config.HideMissingCdmKeysWarning.ToString().ToLower()}");
|
||||
hocon.AppendLine($" HideShowScrapeSizeWarning = {config.HideShowScrapeSizeWarning.ToString().ToLower()}");
|
||||
hocon.AppendLine("}");
|
||||
|
||||
hocon.AppendLine("# Logging/Debug Settings");
|
||||
hocon.AppendLine("Logging {");
|
||||
hocon.AppendLine($" LoggingLevel = \"{config.LoggingLevel.ToString().ToLower()}\"");
|
||||
@ -490,15 +506,12 @@ public class ConfigService(ILoggingService loggingService) : IConfigService
|
||||
return configChanged;
|
||||
}
|
||||
|
||||
private VideoResolution ParseVideoResolution(string value)
|
||||
{
|
||||
if (value.Equals("source", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VideoResolution.source;
|
||||
}
|
||||
private VideoResolution ParseVideoResolution(string value) =>
|
||||
value.Equals("source", StringComparison.OrdinalIgnoreCase)
|
||||
? VideoResolution.source
|
||||
: Enum.Parse<VideoResolution>("_" + value, true);
|
||||
|
||||
return Enum.Parse<VideoResolution>("_" + value, true);
|
||||
}
|
||||
private static Theme ParseTheme(string value) => Enum.TryParse(value, true, out Theme theme) ? theme : Theme.dark;
|
||||
|
||||
private static double ParseDrmVideoDurationMatchThreshold(string value) =>
|
||||
!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)
|
||||
|
||||
@ -96,6 +96,13 @@ public class DownloadOrchestrationService(
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the user's lists only.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary of list names to list IDs.</returns>
|
||||
public async Task<Dictionary<string, long>> GetUserListsAsync() =>
|
||||
await apiService.GetLists("/lists") ?? new Dictionary<string, long>();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the users that belong to a specific list.
|
||||
/// </summary>
|
||||
@ -103,13 +110,26 @@ public class DownloadOrchestrationService(
|
||||
/// <param name="allUsers">All available users.</param>
|
||||
/// <param name="lists">Known lists keyed by name.</param>
|
||||
/// <returns>The users that belong to the list.</returns>
|
||||
public async Task<Dictionary<string, long>> GetUsersForListAsync(
|
||||
public async Task<ListUserSelectionResult> GetUsersForListAsync(
|
||||
string listName, Dictionary<string, long> allUsers, Dictionary<string, long> lists)
|
||||
{
|
||||
ListUserSelectionResult result = new();
|
||||
long listId = lists[listName];
|
||||
List<string> listUsernames = await apiService.GetListUsers($"/lists/{listId}/users") ?? [];
|
||||
return allUsers.Where(x => listUsernames.Contains(x.Key))
|
||||
HashSet<string> listUsernamesSet = listUsernames.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
HashSet<string> allUsernamesSet = allUsers.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
result.SelectedUsers = allUsers
|
||||
.Where(x => listUsernamesSet.Contains(x.Key))
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
result.UnavailableUsernames = listUsernames
|
||||
.Where(username => !allUsernamesSet.Contains(username))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(username => username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -164,11 +184,14 @@ public class DownloadOrchestrationService(
|
||||
eventHandler.OnUserStarting(username);
|
||||
Log.Debug($"Scraping Data for {username}");
|
||||
|
||||
eventHandler.CancellationToken.ThrowIfCancellationRequested();
|
||||
await PrepareUserFolderAsync(username, userId, path);
|
||||
|
||||
if (config.DownloadAvatarHeaderPhoto)
|
||||
{
|
||||
UserEntities.User? userInfo = await apiService.GetUserInfo($"/users/{username}");
|
||||
eventHandler.CancellationToken.ThrowIfCancellationRequested();
|
||||
UserEntities.User? userInfo =
|
||||
await apiService.GetUserInfo($"/users/{username}", eventHandler.CancellationToken);
|
||||
if (userInfo != null)
|
||||
{
|
||||
await downloadService.DownloadAvatarHeader(userInfo.Avatar, userInfo.Header, path, username);
|
||||
@ -233,9 +256,10 @@ public class DownloadOrchestrationService(
|
||||
|
||||
if (config.DownloadStories)
|
||||
{
|
||||
eventHandler.CancellationToken.ThrowIfCancellationRequested();
|
||||
eventHandler.OnMessage("Getting Stories");
|
||||
Dictionary<long, string>? tempStories = await apiService.GetMedia(MediaType.Stories,
|
||||
$"/users/{userId}/stories", null, path);
|
||||
$"/users/{userId}/stories", null, path, eventHandler.CancellationToken);
|
||||
|
||||
if (tempStories is { Count: > 0 })
|
||||
{
|
||||
@ -246,7 +270,7 @@ public class DownloadOrchestrationService(
|
||||
: tempStories.Count;
|
||||
|
||||
DownloadResult result = await eventHandler.WithProgressAsync(
|
||||
$"Downloading {tempStories.Count} Stories", totalSize, config.ShowScrapeSize,
|
||||
$"Downloading {tempStories.Count} stories", totalSize, config.ShowScrapeSize,
|
||||
async reporter => await downloadService.DownloadStories(username, userId, path,
|
||||
PaidPostIds.ToHashSet(), reporter));
|
||||
|
||||
@ -261,9 +285,10 @@ public class DownloadOrchestrationService(
|
||||
|
||||
if (config.DownloadHighlights)
|
||||
{
|
||||
eventHandler.CancellationToken.ThrowIfCancellationRequested();
|
||||
eventHandler.OnMessage("Getting Highlights");
|
||||
Dictionary<long, string>? tempHighlights = await apiService.GetMedia(MediaType.Highlights,
|
||||
$"/users/{userId}/stories/highlights", null, path);
|
||||
$"/users/{userId}/stories/highlights", null, path, eventHandler.CancellationToken);
|
||||
|
||||
if (tempHighlights is { Count: > 0 })
|
||||
{
|
||||
@ -274,7 +299,7 @@ public class DownloadOrchestrationService(
|
||||
: tempHighlights.Count;
|
||||
|
||||
DownloadResult result = await eventHandler.WithProgressAsync(
|
||||
$"Downloading {tempHighlights.Count} Highlights", totalSize, config.ShowScrapeSize,
|
||||
$"Downloading {tempHighlights.Count} highlights", totalSize, config.ShowScrapeSize,
|
||||
async reporter => await downloadService.DownloadHighlights(username, userId, path,
|
||||
PaidPostIds.ToHashSet(), reporter));
|
||||
|
||||
@ -348,9 +373,12 @@ public class DownloadOrchestrationService(
|
||||
long totalSize = config.ShowScrapeSize
|
||||
? await downloadService.CalculateTotalFileSize(post.SinglePosts.Values.ToList())
|
||||
: post.SinglePosts.Count;
|
||||
int postCount = post.SinglePostObjects.Count;
|
||||
string postLabel = postCount == 1 ? "Post" : "Posts";
|
||||
|
||||
DownloadResult result = await eventHandler.WithProgressAsync(
|
||||
"Downloading Post", totalSize, config.ShowScrapeSize,
|
||||
$"Downloading {post.SinglePosts.Count} media from {postCount} {postLabel.ToLowerInvariant()}", totalSize,
|
||||
config.ShowScrapeSize,
|
||||
async reporter => await downloadService.DownloadSinglePost(username, path, users,
|
||||
clientIdBlobMissing, devicePrivateKeyMissing, post, reporter));
|
||||
|
||||
@ -380,13 +408,19 @@ public class DownloadOrchestrationService(
|
||||
{
|
||||
Config config = configService.CurrentConfig;
|
||||
|
||||
eventHandler.OnMessage("Fetching purchased tab users...");
|
||||
eventHandler.CancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
Dictionary<string, long> purchasedTabUsers =
|
||||
await apiService.GetPurchasedTabUsers("/posts/paid/all", users);
|
||||
await apiService.GetPurchasedTabUsers("/posts/paid/all", users, eventHandler.CancellationToken);
|
||||
|
||||
eventHandler.OnMessage("Checking folders for users in Purchased Tab");
|
||||
eventHandler.CancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (KeyValuePair<string, long> user in purchasedTabUsers)
|
||||
{
|
||||
eventHandler.CancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
string path = ResolveDownloadPath(user.Key);
|
||||
Log.Debug($"Download path: {path}");
|
||||
|
||||
@ -398,7 +432,7 @@ public class DownloadOrchestrationService(
|
||||
Log.Debug($"Created folder for {user.Key}");
|
||||
}
|
||||
|
||||
await apiService.GetUserInfo($"/users/{user.Key}");
|
||||
await apiService.GetUserInfo($"/users/{user.Key}", eventHandler.CancellationToken);
|
||||
await dbService.CreateDb(path);
|
||||
}
|
||||
|
||||
@ -408,11 +442,16 @@ public class DownloadOrchestrationService(
|
||||
|
||||
Log.Debug($"Download path: {basePath}");
|
||||
|
||||
eventHandler.OnMessage("Fetching purchased tab content...");
|
||||
eventHandler.CancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
List<PurchasedEntities.PurchasedTabCollection> purchasedTabCollections =
|
||||
await apiService.GetPurchasedTab("/posts/paid/all", basePath, users);
|
||||
await apiService.GetPurchasedTab("/posts/paid/all", basePath, users, eventHandler.CancellationToken);
|
||||
|
||||
foreach (PurchasedEntities.PurchasedTabCollection purchasedTabCollection in purchasedTabCollections)
|
||||
{
|
||||
eventHandler.CancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
eventHandler.OnUserStarting(purchasedTabCollection.Username);
|
||||
string path = ResolveDownloadPath(purchasedTabCollection.Username);
|
||||
Log.Debug($"Download path: {path}");
|
||||
@ -433,7 +472,7 @@ public class DownloadOrchestrationService(
|
||||
: purchasedTabCollection.PaidPosts.PaidPosts.Count;
|
||||
|
||||
DownloadResult postResult = await eventHandler.WithProgressAsync(
|
||||
$"Downloading {purchasedTabCollection.PaidPosts.PaidPosts.Count} Media from {purchasedTabCollection.PaidPosts.PaidPostObjects.Count} Paid Posts",
|
||||
$"Downloading {purchasedTabCollection.PaidPosts.PaidPosts.Count} media from {purchasedTabCollection.PaidPosts.PaidPostObjects.Count} paid posts",
|
||||
totalSize, config.ShowScrapeSize,
|
||||
async reporter => await downloadService.DownloadPaidPostsPurchasedTab(
|
||||
purchasedTabCollection.Username, path, users,
|
||||
@ -461,7 +500,7 @@ public class DownloadOrchestrationService(
|
||||
: purchasedTabCollection.PaidMessages.PaidMessages.Count;
|
||||
|
||||
DownloadResult msgResult = await eventHandler.WithProgressAsync(
|
||||
$"Downloading {purchasedTabCollection.PaidMessages.PaidMessages.Count} Media from {purchasedTabCollection.PaidMessages.PaidMessageObjects.Count} Paid Messages",
|
||||
$"Downloading {purchasedTabCollection.PaidMessages.PaidMessages.Count} media from {purchasedTabCollection.PaidMessages.PaidMessageObjects.Count} paid messages",
|
||||
totalSize, config.ShowScrapeSize,
|
||||
async reporter => await downloadService.DownloadPaidMessagesPurchasedTab(
|
||||
purchasedTabCollection.Username, path, users,
|
||||
@ -532,7 +571,7 @@ public class DownloadOrchestrationService(
|
||||
: totalCount;
|
||||
|
||||
DownloadResult result = await eventHandler.WithProgressAsync(
|
||||
$"Downloading {totalCount} Media from {messageCount} {messageLabel} ({paidCount} Paid + {previewCount} Preview)",
|
||||
$"Downloading {totalCount} media from {messageCount} {messageLabel} ({paidCount} paid + {previewCount} preview)",
|
||||
totalSize, config.ShowScrapeSize,
|
||||
async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users,
|
||||
clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter));
|
||||
@ -552,7 +591,7 @@ public class DownloadOrchestrationService(
|
||||
: previewCount;
|
||||
|
||||
DownloadResult previewResult = await eventHandler.WithProgressAsync(
|
||||
$"Downloading {previewCount} Preview Media from {messageCount} {messageLabel}",
|
||||
$"Downloading {previewCount} preview media from {messageCount} {messageLabel.ToLowerInvariant()}",
|
||||
previewSize, config.ShowScrapeSize,
|
||||
async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users,
|
||||
clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter));
|
||||
@ -572,7 +611,7 @@ public class DownloadOrchestrationService(
|
||||
: paidCount;
|
||||
|
||||
DownloadResult result = await eventHandler.WithProgressAsync(
|
||||
$"Downloading {paidCount} Paid Media from {messageCount} {messageLabel}",
|
||||
$"Downloading {paidCount} paid media from {messageCount} {messageLabel.ToLowerInvariant()}",
|
||||
totalSize, config.ShowScrapeSize,
|
||||
async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users,
|
||||
clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter));
|
||||
@ -636,7 +675,8 @@ public class DownloadOrchestrationService(
|
||||
: mediaCount;
|
||||
|
||||
DownloadResult result = await eventHandler.WithProgressAsync(
|
||||
$"Downloading {mediaCount} Media from {objectCount} {contentType}", totalSize, config.ShowScrapeSize,
|
||||
$"Downloading {mediaCount} media from {objectCount} {contentType.ToLowerInvariant()}", totalSize,
|
||||
config.ShowScrapeSize,
|
||||
async reporter => await downloadData(data, reporter));
|
||||
|
||||
eventHandler.OnDownloadComplete(contentType, result);
|
||||
|
||||
@ -217,7 +217,7 @@ public class DownloadService(
|
||||
Engine ffmpeg = new(configService.CurrentConfig.FFmpegPath);
|
||||
ffmpeg.Error += OnError;
|
||||
ffmpeg.Complete += (_, _) => { _completionSource.TrySetResult(true); };
|
||||
await ffmpeg.ExecuteAsync(parameters, CancellationToken.None);
|
||||
await ffmpeg.ExecuteAsync(parameters, progressReporter.CancellationToken);
|
||||
|
||||
bool ffmpegSuccess = await _completionSource.Task;
|
||||
if (!ffmpegSuccess || !File.Exists(tempFilename))
|
||||
@ -263,6 +263,10 @@ public class DownloadService(
|
||||
Constants.DrmDownloadMaxRetries, mediaId);
|
||||
return false;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ExceptionLoggerHelper.LogException(ex);
|
||||
@ -408,14 +412,14 @@ public class DownloadService(
|
||||
{
|
||||
string firstFullPath = Path.GetFullPath(firstPath);
|
||||
string secondFullPath = Path.GetFullPath(secondPath);
|
||||
StringComparison comparison = OperatingSystem.IsWindows()
|
||||
StringComparison comparison = EnvironmentHelper.IsRunningOnWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
return string.Equals(firstFullPath, secondFullPath, comparison);
|
||||
}
|
||||
catch
|
||||
{
|
||||
StringComparison comparison = OperatingSystem.IsWindows()
|
||||
StringComparison comparison = EnvironmentHelper.IsRunningOnWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
return string.Equals(firstPath, secondPath, comparison);
|
||||
@ -935,9 +939,10 @@ public class DownloadService(
|
||||
using HttpClient client = new();
|
||||
HttpRequestMessage request = new() { Method = HttpMethod.Get, RequestUri = new Uri(url) };
|
||||
|
||||
using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead,
|
||||
progressReporter.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
Stream body = await response.Content.ReadAsStreamAsync();
|
||||
Stream body = await response.Content.ReadAsStreamAsync(progressReporter.CancellationToken);
|
||||
|
||||
// Wrap the body stream with the ThrottledStream to limit read rate.
|
||||
await using (ThrottledStream throttledStream = new(body,
|
||||
@ -949,14 +954,14 @@ public class DownloadService(
|
||||
true);
|
||||
byte[] buffer = new byte[16384];
|
||||
int read;
|
||||
while ((read = await throttledStream.ReadAsync(buffer, CancellationToken.None)) > 0)
|
||||
while ((read = await throttledStream.ReadAsync(buffer, progressReporter.CancellationToken)) > 0)
|
||||
{
|
||||
if (configService.CurrentConfig.ShowScrapeSize)
|
||||
{
|
||||
progressReporter.ReportProgress(read);
|
||||
}
|
||||
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, read), CancellationToken.None);
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, read), progressReporter.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -35,69 +35,69 @@ public interface IApiService
|
||||
/// <summary>
|
||||
/// Retrieves media URLs for stories or highlights.
|
||||
/// </summary>
|
||||
Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, string folder);
|
||||
Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, string folder, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves paid posts and their media.
|
||||
/// </summary>
|
||||
Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username,
|
||||
List<long> paidPostIds,
|
||||
IStatusReporter statusReporter);
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves posts and their media.
|
||||
/// </summary>
|
||||
Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
|
||||
IStatusReporter statusReporter);
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a single post and its media.
|
||||
/// </summary>
|
||||
Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder);
|
||||
Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves streams and their media.
|
||||
/// </summary>
|
||||
Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds,
|
||||
IStatusReporter statusReporter);
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves archived posts and their media.
|
||||
/// </summary>
|
||||
Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
|
||||
IStatusReporter statusReporter);
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves messages and their media.
|
||||
/// </summary>
|
||||
Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder, IStatusReporter statusReporter);
|
||||
Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder, IStatusReporter statusReporter, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves paid messages and their media.
|
||||
/// </summary>
|
||||
Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder, string username,
|
||||
IStatusReporter statusReporter);
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a single paid message and its media.
|
||||
/// </summary>
|
||||
Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder);
|
||||
Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves users that appear in the Purchased tab.
|
||||
/// </summary>
|
||||
Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users);
|
||||
Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves Purchased tab content grouped by user.
|
||||
/// </summary>
|
||||
Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
|
||||
Dictionary<string, long> users);
|
||||
Dictionary<string, long> users, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves user information.
|
||||
/// </summary>
|
||||
Task<UserEntities.User?> GetUserInfo(string endpoint);
|
||||
Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves user information by ID.
|
||||
|
||||
@ -18,7 +18,10 @@ public interface IAuthService
|
||||
/// <summary>
|
||||
/// Launches a browser session and extracts auth data after login.
|
||||
/// </summary>
|
||||
Task<bool> LoadFromBrowserAsync();
|
||||
/// <param name="statusCallback">
|
||||
/// Optional callback for reporting status messages to be displayed in the UI.
|
||||
/// </param>
|
||||
Task<bool> LoadFromBrowserAsync(Action<string>? statusCallback = null);
|
||||
|
||||
/// <summary>
|
||||
/// Persists the current auth data to disk.
|
||||
|
||||
@ -8,6 +8,11 @@ namespace OF_DL.Services;
|
||||
/// </summary>
|
||||
public interface IDownloadEventHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the cancellation token for the operation.
|
||||
/// </summary>
|
||||
CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Wraps work in a status indicator (spinner) during API fetching.
|
||||
/// The implementation controls how the status is displayed.
|
||||
|
||||
@ -9,10 +9,15 @@ public interface IDownloadOrchestrationService
|
||||
/// </summary>
|
||||
Task<UserListResult> GetAvailableUsersAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Fetch only user lists.
|
||||
/// </summary>
|
||||
Task<Dictionary<string, long>> GetUserListsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Get users for a specific list by name.
|
||||
/// </summary>
|
||||
Task<Dictionary<string, long>> GetUsersForListAsync(
|
||||
Task<ListUserSelectionResult> GetUsersForListAsync(
|
||||
string listName, Dictionary<string, long> allUsers, Dictionary<string, long> lists);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -11,4 +11,9 @@ public interface IProgressReporter
|
||||
/// </summary>
|
||||
/// <param name="increment">The amount to increment progress by</param>
|
||||
void ReportProgress(long increment);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cancellation token for canceling the operation.
|
||||
/// </summary>
|
||||
CancellationToken CancellationToken { get; }
|
||||
}
|
||||
|
||||
@ -7,8 +7,11 @@ namespace OF_DL.Services;
|
||||
|
||||
public class LoggingService : ILoggingService
|
||||
{
|
||||
public LoggingService()
|
||||
private readonly ILogEventSink? _optionalErrorSink;
|
||||
|
||||
public LoggingService(ILogEventSink? optionalErrorSink = null)
|
||||
{
|
||||
_optionalErrorSink = optionalErrorSink;
|
||||
LevelSwitch = new LoggingLevelSwitch();
|
||||
InitializeLogger();
|
||||
}
|
||||
@ -38,10 +41,17 @@ public class LoggingService : ILoggingService
|
||||
// Set the initial level to Error (until we've read from config)
|
||||
LevelSwitch.MinimumLevel = LogEventLevel.Error;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
LoggerConfiguration loggerConfiguration = new LoggerConfiguration()
|
||||
.MinimumLevel.ControlledBy(LevelSwitch)
|
||||
.WriteTo.File("logs/OFDL.txt", rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
.WriteTo.File("logs/OFDL.txt", rollingInterval: RollingInterval.Day);
|
||||
|
||||
if (_optionalErrorSink != null)
|
||||
{
|
||||
loggerConfiguration = loggerConfiguration.WriteTo.Sink(_optionalErrorSink,
|
||||
LogEventLevel.Error);
|
||||
}
|
||||
|
||||
Log.Logger = loggerConfiguration.CreateLogger();
|
||||
|
||||
Log.Debug("Logging service initialized");
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using Newtonsoft.Json;
|
||||
using OF_DL.Helpers;
|
||||
using OF_DL.Models;
|
||||
@ -24,9 +23,9 @@ public class StartupService(IConfigService configService, IAuthService authServi
|
||||
// OS validation
|
||||
OperatingSystem os = Environment.OSVersion;
|
||||
result.OsVersionString = os.VersionString;
|
||||
Log.Debug($"Operating system information: {os.VersionString}");
|
||||
Log.Debug("Operating system information: {OsVersionString}", os.VersionString);
|
||||
|
||||
if (os.Platform == PlatformID.Win32NT && os.Version.Major < 10)
|
||||
if (EnvironmentHelper.IsRunningOnWindows() && os.Version.Major < 10)
|
||||
{
|
||||
result.IsWindowsVersionValid = false;
|
||||
Log.Error("Windows version prior to 10.x: {0}", os.VersionString);
|
||||
@ -40,7 +39,7 @@ public class StartupService(IConfigService configService, IAuthService authServi
|
||||
if (result is { FfmpegFound: true, FfmpegPath: not null })
|
||||
{
|
||||
// Escape backslashes for Windows
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
|
||||
if (EnvironmentHelper.IsRunningOnWindows() &&
|
||||
result.FfmpegPath.Contains(@":\") &&
|
||||
!result.FfmpegPath.Contains(@":\\"))
|
||||
{
|
||||
@ -55,7 +54,7 @@ public class StartupService(IConfigService configService, IAuthService authServi
|
||||
if (result is { FfprobeFound: true, FfprobePath: not null })
|
||||
{
|
||||
// Escape backslashes for Windows
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
|
||||
if (EnvironmentHelper.IsRunningOnWindows() &&
|
||||
result.FfprobePath.Contains(@":\") &&
|
||||
!result.FfprobePath.Contains(@":\\"))
|
||||
{
|
||||
@ -211,7 +210,7 @@ public class StartupService(IConfigService configService, IAuthService authServi
|
||||
string? ffmpegDirectory = Path.GetDirectoryName(result.FfmpegPath);
|
||||
if (!string.IsNullOrEmpty(ffmpegDirectory))
|
||||
{
|
||||
string ffprobeFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
string ffprobeFileName = EnvironmentHelper.IsRunningOnWindows()
|
||||
? "ffprobe.exe"
|
||||
: "ffprobe";
|
||||
string inferredFfprobePath = Path.Combine(ffmpegDirectory, ffprobeFileName);
|
||||
|
||||
118
OF DL.Gui/App.axaml
Normal file
118
OF DL.Gui/App.axaml
Normal file
@ -0,0 +1,118 @@
|
||||
<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>
|
||||
28
OF DL.Gui/App.axaml.cs
Normal file
28
OF DL.Gui/App.axaml.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OF_DL.Gui.Services;
|
||||
using OF_DL.Gui.ViewModels;
|
||||
using OF_DL.Gui.Views;
|
||||
|
||||
namespace OF_DL.Gui;
|
||||
|
||||
public class App : Application
|
||||
{
|
||||
private readonly ServiceProvider _serviceProvider = ServiceCollectionFactory.Create().BuildServiceProvider();
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
MainWindow mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
|
||||
mainWindow.DataContext = _serviceProvider.GetRequiredService<MainWindowViewModel>();
|
||||
desktop.MainWindow = mainWindow;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
BIN
OF DL.Gui/Assets/icon.ico
Normal file
BIN
OF DL.Gui/Assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
211
OF DL.Gui/Controls/FileNameFormatOverlayTextBlock.cs
Normal file
211
OF DL.Gui/Controls/FileNameFormatOverlayTextBlock.cs
Normal file
@ -0,0 +1,211 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
92
OF DL.Gui/Helpers/WebLinkHelper.cs
Normal file
92
OF DL.Gui/Helpers/WebLinkHelper.cs
Normal file
@ -0,0 +1,92 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
56
OF DL.Gui/OF DL.Gui.csproj
Normal file
56
OF DL.Gui/OF DL.Gui.csproj
Normal file
@ -0,0 +1,56 @@
|
||||
<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>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\icon.ico"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OF DL.Core\OF DL.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka" Version="1.5.60"/>
|
||||
<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="BouncyCastle.NetCore" Version="2.2.1"/>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2"/>
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4"/>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3"/>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3"/>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.58.0"/>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4"/>
|
||||
<PackageReference Include="protobuf-net" Version="3.2.56"/>
|
||||
<PackageReference Include="Serilog" Version="4.3.1"/>
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||
<PackageReference Include="System.Reactive" Version="6.1.0"/>
|
||||
<PackageReference Include="xFFmpeg.NET" Version="7.2.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>
|
||||
102
OF DL.Gui/Program.cs
Normal file
102
OF DL.Gui/Program.cs
Normal file
@ -0,0 +1,102 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs
Normal file
160
OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs
Normal file
@ -0,0 +1,160 @@
|
||||
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.";
|
||||
}
|
||||
}
|
||||
24
OF DL.Gui/Services/AvaloniaProgressReporter.cs
Normal file
24
OF DL.Gui/Services/AvaloniaProgressReporter.cs
Normal file
@ -0,0 +1,24 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
OF DL.Gui/Services/AvaloniaStatusReporter.cs
Normal file
18
OF DL.Gui/Services/AvaloniaStatusReporter.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using OF_DL.Services;
|
||||
|
||||
namespace OF_DL.Gui.Services;
|
||||
|
||||
internal sealed class AvaloniaStatusReporter(
|
||||
Action<string> statusAction,
|
||||
Func<bool> isCancellationRequested) : IStatusReporter
|
||||
{
|
||||
public void ReportStatus(string message)
|
||||
{
|
||||
if (isCancellationRequested())
|
||||
{
|
||||
throw new OperationCanceledException("Operation canceled by user.");
|
||||
}
|
||||
|
||||
statusAction(message);
|
||||
}
|
||||
}
|
||||
85
OF DL.Gui/Services/ConfigValidationService.cs
Normal file
85
OF DL.Gui/Services/ConfigValidationService.cs
Normal file
@ -0,0 +1,85 @@
|
||||
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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
45
OF DL.Gui/Services/DownloadErrorLogTracking.cs
Normal file
45
OF DL.Gui/Services/DownloadErrorLogTracking.cs
Normal file
@ -0,0 +1,45 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
32
OF DL.Gui/Services/ServiceCollectionFactory.cs
Normal file
32
OF DL.Gui/Services/ServiceCollectionFactory.cs
Normal file
@ -0,0 +1,32 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
11
OF DL.Gui/ViewModels/AppScreen.cs
Normal file
11
OF DL.Gui/ViewModels/AppScreen.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace OF_DL.Gui.ViewModels;
|
||||
|
||||
public enum AppScreen
|
||||
{
|
||||
Loading,
|
||||
Config,
|
||||
Auth,
|
||||
ManualAuth,
|
||||
UserSelection,
|
||||
Error
|
||||
}
|
||||
150
OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs
Normal file
150
OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs
Normal file
@ -0,0 +1,150 @@
|
||||
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; } = [];
|
||||
}
|
||||
547
OF DL.Gui/ViewModels/ConfigFieldViewModel.cs
Normal file
547
OF DL.Gui/ViewModels/ConfigFieldViewModel.cs
Normal file
@ -0,0 +1,547 @@
|
||||
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",
|
||||
[nameof(Config.HideShowScrapeSizeWarning)] = "Hide Show Scrape Size 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);
|
||||
}
|
||||
14
OF DL.Gui/ViewModels/ConfigSelectOptionViewModel.cs
Normal file
14
OF DL.Gui/ViewModels/ConfigSelectOptionViewModel.cs
Normal file
@ -0,0 +1,14 @@
|
||||
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; }
|
||||
}
|
||||
107
OF DL.Gui/ViewModels/CreatorConfigEditorViewModel.cs
Normal file
107
OF DL.Gui/ViewModels/CreatorConfigEditorViewModel.cs
Normal file
@ -0,0 +1,107 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
436
OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs
Normal file
436
OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs
Normal file
@ -0,0 +1,436 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// ReSharper disable once UnusedParameterInPartialMethod
|
||||
partial void OnPaidPostFileNameFormatChanged(string value) =>
|
||||
HandleFileNameFormatChanged(
|
||||
() => PaidPostFileNameFormatError = string.Empty,
|
||||
UpdatePaidPostPreview);
|
||||
|
||||
// ReSharper disable once UnusedParameterInPartialMethod
|
||||
partial void OnPostFileNameFormatChanged(string value) =>
|
||||
HandleFileNameFormatChanged(
|
||||
() => PostFileNameFormatError = string.Empty,
|
||||
UpdatePostPreview);
|
||||
|
||||
// ReSharper disable once UnusedParameterInPartialMethod
|
||||
partial void OnPaidMessageFileNameFormatChanged(string value) =>
|
||||
HandleFileNameFormatChanged(
|
||||
() => PaidMessageFileNameFormatError = string.Empty,
|
||||
UpdatePaidMessagePreview);
|
||||
|
||||
// ReSharper disable once UnusedParameterInPartialMethod
|
||||
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");
|
||||
}
|
||||
}
|
||||
28
OF DL.Gui/ViewModels/CreatorConfigRowViewModel.cs
Normal file
28
OF DL.Gui/ViewModels/CreatorConfigRowViewModel.cs
Normal file
@ -0,0 +1,28 @@
|
||||
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);
|
||||
}
|
||||
14
OF DL.Gui/ViewModels/FileNameFormatSegmentViewModel.cs
Normal file
14
OF DL.Gui/ViewModels/FileNameFormatSegmentViewModel.cs
Normal file
@ -0,0 +1,14 @@
|
||||
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; }
|
||||
}
|
||||
2548
OF DL.Gui/ViewModels/MainWindowViewModel.cs
Normal file
2548
OF DL.Gui/ViewModels/MainWindowViewModel.cs
Normal file
File diff suppressed because it is too large
Load Diff
24
OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs
Normal file
24
OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs
Normal file
@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
17
OF DL.Gui/ViewModels/SelectableUserViewModel.cs
Normal file
17
OF DL.Gui/ViewModels/SelectableUserViewModel.cs
Normal file
@ -0,0 +1,17 @@
|
||||
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;
|
||||
|
||||
// ReSharper disable once UnusedParameterInPartialMethod
|
||||
partial void OnIsSelectedChanged(bool value) => SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
7
OF DL.Gui/ViewModels/ViewModelBase.cs
Normal file
7
OF DL.Gui/ViewModels/ViewModelBase.cs
Normal file
@ -0,0 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace OF_DL.Gui.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
148
OF DL.Gui/Views/AboutWindow.axaml
Normal file
148
OF DL.Gui/Views/AboutWindow.axaml
Normal file
@ -0,0 +1,148 @@
|
||||
<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>
|
||||
76
OF DL.Gui/Views/AboutWindow.axaml.cs
Normal file
76
OF DL.Gui/Views/AboutWindow.axaml.cs
Normal file
@ -0,0 +1,76 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
OF DL.Gui/Views/FaqWindow.axaml
Normal file
140
OF DL.Gui/Views/FaqWindow.axaml
Normal file
@ -0,0 +1,140 @@
|
||||
<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>
|
||||
149
OF DL.Gui/Views/FaqWindow.axaml.cs
Normal file
149
OF DL.Gui/Views/FaqWindow.axaml.cs
Normal file
@ -0,0 +1,149 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1717
OF DL.Gui/Views/MainWindow.axaml
Normal file
1717
OF DL.Gui/Views/MainWindow.axaml
Normal file
File diff suppressed because it is too large
Load Diff
360
OF DL.Gui/Views/MainWindow.axaml.cs
Normal file
360
OF DL.Gui/Views/MainWindow.axaml.cs
Normal file
@ -0,0 +1,360 @@
|
||||
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.CloseListSelectionWarningCommand.Execute(null);
|
||||
vm.CancelMissingCdmWarningCommand.Execute(null);
|
||||
vm.CancelShowScrapeSizeWarningCommand.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;
|
||||
}
|
||||
16
OF DL.Gui/chromium-scripts/CREATING_STEALTH_SCRIPT.md
Normal file
16
OF DL.Gui/chromium-scripts/CREATING_STEALTH_SCRIPT.md
Normal file
@ -0,0 +1,16 @@
|
||||
# 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).
|
||||
7
OF DL.Gui/chromium-scripts/stealth.min.js
vendored
Normal file
7
OF DL.Gui/chromium-scripts/stealth.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
41
OF DL.Gui/rules.json
Normal file
41
OF DL.Gui/rules.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"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
|
||||
]
|
||||
}
|
||||
@ -515,7 +515,7 @@ public class ApiServiceTests
|
||||
MethodInfo method = typeof(ApiService).GetMethod("TryGetDrmInfo",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)
|
||||
?? throw new InvalidOperationException("TryGetDrmInfo not found.");
|
||||
object?[] args = { files, null, null, null, null };
|
||||
object?[] args = [files, null, null, null, null];
|
||||
bool result = (bool)method.Invoke(null, args)!;
|
||||
manifestDash = (string)args[1]!;
|
||||
cloudFrontPolicy = (string)args[2]!;
|
||||
|
||||
@ -23,6 +23,9 @@ public class ConfigServiceTests
|
||||
Assert.Equal(service.CurrentConfig.LoggingLevel, loggingService.LastLevel);
|
||||
Assert.Equal("", service.CurrentConfig.FFprobePath);
|
||||
Assert.Equal(0.98, service.CurrentConfig.DrmVideoDurationMatchThreshold, 3);
|
||||
Assert.Equal(Theme.dark, service.CurrentConfig.Theme);
|
||||
Assert.False(service.CurrentConfig.HideMissingCdmKeysWarning);
|
||||
Assert.False(service.CurrentConfig.HideShowScrapeSizeWarning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -80,6 +83,63 @@ public class ConfigServiceTests
|
||||
Assert.Equal(0.95, service.CurrentConfig.DrmVideoDurationMatchThreshold, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadConfigurationAsync_ParsesAppearanceTheme()
|
||||
{
|
||||
using TempFolder temp = new();
|
||||
using CurrentDirectoryScope _ = new(temp.Path);
|
||||
FakeLoggingService loggingService = new();
|
||||
ConfigService service = new(loggingService);
|
||||
await service.SaveConfigurationAsync();
|
||||
|
||||
string hocon = await File.ReadAllTextAsync("config.conf");
|
||||
hocon = hocon.Replace("Theme = \"light\"", "Theme = \"dark\"");
|
||||
await File.WriteAllTextAsync("config.conf", hocon);
|
||||
|
||||
bool result = await service.LoadConfigurationAsync([]);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(Theme.dark, service.CurrentConfig.Theme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadConfigurationAsync_ParsesHideMissingCdmKeysWarning()
|
||||
{
|
||||
using TempFolder temp = new();
|
||||
using CurrentDirectoryScope _ = new(temp.Path);
|
||||
FakeLoggingService loggingService = new();
|
||||
ConfigService service = new(loggingService);
|
||||
await service.SaveConfigurationAsync();
|
||||
|
||||
string hocon = await File.ReadAllTextAsync("config.conf");
|
||||
hocon = hocon.Replace("HideMissingCdmKeysWarning = false", "HideMissingCdmKeysWarning = true");
|
||||
await File.WriteAllTextAsync("config.conf", hocon);
|
||||
|
||||
bool result = await service.LoadConfigurationAsync([]);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.True(service.CurrentConfig.HideMissingCdmKeysWarning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadConfigurationAsync_ParsesHideShowScrapeSizeWarning()
|
||||
{
|
||||
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("HideShowScrapeSizeWarning = false", "HideShowScrapeSizeWarning = true");
|
||||
await File.WriteAllTextAsync("config.conf", hocon);
|
||||
|
||||
bool result = await service.LoadConfigurationAsync([]);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.True(service.CurrentConfig.HideShowScrapeSizeWarning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyToggleableSelections_UpdatesConfigAndReturnsChange()
|
||||
{
|
||||
@ -102,5 +162,4 @@ public class ConfigServiceTests
|
||||
Assert.Equal("/downloads", service.CurrentConfig.DownloadPath);
|
||||
Assert.Equal(LoggingLevel.Warning, loggingService.LastLevel);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -79,10 +79,30 @@ public class DownloadOrchestrationServiceTests
|
||||
Dictionary<string, long> allUsers = new() { { "alice", 1 }, { "bob", 2 } };
|
||||
Dictionary<string, long> lists = new() { { "mylist", 5 } };
|
||||
|
||||
Dictionary<string, long> result = await service.GetUsersForListAsync("mylist", allUsers, lists);
|
||||
ListUserSelectionResult result = await service.GetUsersForListAsync("mylist", allUsers, lists);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(2, result["bob"]);
|
||||
Assert.Single(result.SelectedUsers);
|
||||
Assert.Equal(2, result.SelectedUsers["bob"]);
|
||||
Assert.Empty(result.UnavailableUsernames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUsersForListAsync_ReturnsUnavailableUsers()
|
||||
{
|
||||
FakeConfigService configService = new(CreateConfig());
|
||||
ConfigurableApiService apiService = new()
|
||||
{
|
||||
ListUsersHandler = _ => Task.FromResult<List<string>?>(["bob", "carol"])
|
||||
};
|
||||
DownloadOrchestrationService service =
|
||||
new(apiService, configService, new OrchestrationDownloadServiceStub(), new UserTrackingDbService());
|
||||
Dictionary<string, long> allUsers = new() { { "alice", 1 }, { "bob", 2 } };
|
||||
Dictionary<string, long> lists = new() { { "mylist", 5 } };
|
||||
|
||||
ListUserSelectionResult result = await service.GetUsersForListAsync("mylist", allUsers, lists);
|
||||
|
||||
Assert.Single(result.SelectedUsers);
|
||||
Assert.Equal("carol", Assert.Single(result.UnavailableUsernames));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -82,7 +82,8 @@ public class DownloadServiceTests
|
||||
DownloadService service =
|
||||
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
|
||||
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo(
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result =
|
||||
await service.GetDecryptionInfo(
|
||||
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
|
||||
true, false);
|
||||
|
||||
@ -100,7 +101,8 @@ public class DownloadServiceTests
|
||||
DownloadService service =
|
||||
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
|
||||
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo(
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result =
|
||||
await service.GetDecryptionInfo(
|
||||
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
|
||||
false, false);
|
||||
|
||||
@ -124,7 +126,8 @@ public class DownloadServiceTests
|
||||
await File.WriteAllTextAsync(tempFilename, "abc");
|
||||
|
||||
MediaTrackingDbService dbService = new();
|
||||
DownloadService service = CreateService(new FakeConfigService(new Config { ShowScrapeSize = false }), dbService);
|
||||
DownloadService service =
|
||||
CreateService(new FakeConfigService(new Config { ShowScrapeSize = false }), dbService);
|
||||
ProgressRecorder progress = new();
|
||||
|
||||
MethodInfo? finalizeMethod = typeof(DownloadService).GetMethod("FinalizeDrmDownload",
|
||||
@ -136,7 +139,7 @@ public class DownloadServiceTests
|
||||
tempFilename, DateTime.UtcNow, folder, path, customFileName, filename, 1L, "Posts", progress
|
||||
]);
|
||||
|
||||
bool result = await Assert.IsType<Task<bool>>(resultObject!);
|
||||
bool result = await Assert.IsType<Task<bool>>(resultObject);
|
||||
Assert.True(result);
|
||||
Assert.True(File.Exists(tempFilename));
|
||||
Assert.NotNull(dbService.LastUpdateMedia);
|
||||
|
||||
@ -42,6 +42,8 @@ internal sealed class ProgressRecorder : IProgressReporter
|
||||
{
|
||||
public long Total { get; private set; }
|
||||
|
||||
public CancellationToken CancellationToken { get; } = CancellationToken.None;
|
||||
|
||||
public void ReportProgress(long increment) => Total += increment;
|
||||
}
|
||||
|
||||
@ -137,44 +139,53 @@ internal sealed class StaticApiService : IApiService
|
||||
new() { { "X-Test", "value" } };
|
||||
|
||||
public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username,
|
||||
string folder) => Task.FromResult(MediaToReturn);
|
||||
string folder, CancellationToken cancellationToken = default) => Task.FromResult(MediaToReturn);
|
||||
|
||||
public Task<Dictionary<string, long>?> GetLists(string endpoint) => throw new NotImplementedException();
|
||||
|
||||
public Task<List<string>?> GetListUsers(string endpoint) => throw new NotImplementedException();
|
||||
|
||||
public Task<OF_DL.Models.Entities.Purchased.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
|
||||
string username, List<long> paidPostIds, IStatusReporter statusReporter) =>
|
||||
public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
|
||||
string username, List<long> paidPostIds, IStatusReporter statusReporter,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<OF_DL.Models.Entities.Posts.PostCollection> GetPosts(string endpoint, string folder,
|
||||
List<long> paidPostIds, IStatusReporter statusReporter) => throw new NotImplementedException();
|
||||
|
||||
public Task<OF_DL.Models.Entities.Posts.SinglePostCollection> GetPost(string endpoint, string folder) =>
|
||||
public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder,
|
||||
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<OF_DL.Models.Entities.Streams.StreamsCollection> GetStreams(string endpoint, string folder,
|
||||
List<long> paidPostIds, IStatusReporter statusReporter) => throw new NotImplementedException();
|
||||
|
||||
public Task<OF_DL.Models.Entities.Archived.ArchivedCollection> GetArchived(string endpoint, string folder,
|
||||
IStatusReporter statusReporter) => throw new NotImplementedException();
|
||||
|
||||
public Task<OF_DL.Models.Entities.Messages.MessageCollection> GetMessages(string endpoint, string folder,
|
||||
IStatusReporter statusReporter) => throw new NotImplementedException();
|
||||
|
||||
public Task<OF_DL.Models.Entities.Purchased.PaidMessageCollection> GetPaidMessages(string endpoint,
|
||||
string folder, string username, IStatusReporter statusReporter) => throw new NotImplementedException();
|
||||
|
||||
public Task<OF_DL.Models.Entities.Purchased.SinglePaidMessageCollection> GetPaidMessage(string endpoint,
|
||||
string folder) => throw new NotImplementedException();
|
||||
|
||||
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users) =>
|
||||
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<List<OF_DL.Models.Entities.Purchased.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
|
||||
string folder, Dictionary<string, long> users) => throw new NotImplementedException();
|
||||
public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder,
|
||||
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<UserEntities.User?> GetUserInfo(string endpoint) =>
|
||||
public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint,
|
||||
string folder, string username, IStatusReporter statusReporter,
|
||||
CancellationToken cancellationToken = default) => throw new NotImplementedException();
|
||||
|
||||
public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint,
|
||||
string folder, CancellationToken cancellationToken = default) => throw new NotImplementedException();
|
||||
|
||||
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
|
||||
string folder, Dictionary<string, long> users, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<JObject?> GetUserInfoById(string endpoint) =>
|
||||
@ -216,52 +227,55 @@ internal sealed class ConfigurableApiService : IApiService
|
||||
ListUsersHandler?.Invoke(endpoint) ?? Task.FromResult<List<string>?>(null);
|
||||
|
||||
public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username,
|
||||
string folder) =>
|
||||
string folder, CancellationToken cancellationToken = default) =>
|
||||
MediaHandler?.Invoke(mediaType, endpoint, username, folder) ??
|
||||
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());
|
||||
|
||||
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) ??
|
||||
Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection());
|
||||
|
||||
public Task<UserEntities.User?> GetUserInfo(string endpoint) =>
|
||||
public Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default) =>
|
||||
UserInfoHandler?.Invoke(endpoint) ?? Task.FromResult<UserEntities.User?>(null);
|
||||
|
||||
public Task<JObject?> GetUserInfoById(string endpoint) =>
|
||||
UserInfoByIdHandler?.Invoke(endpoint) ?? Task.FromResult<JObject?>(null);
|
||||
|
||||
public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username,
|
||||
List<long> paidPostIds, IStatusReporter statusReporter) =>
|
||||
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
|
||||
IStatusReporter statusReporter) =>
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds,
|
||||
IStatusReporter statusReporter) =>
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
|
||||
IStatusReporter statusReporter) =>
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
|
||||
IStatusReporter statusReporter) =>
|
||||
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder,
|
||||
string username, IStatusReporter statusReporter) =>
|
||||
string username, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
|
||||
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();
|
||||
|
||||
public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
|
||||
Dictionary<string, long> users) =>
|
||||
Dictionary<string, long> users, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Dictionary<string, string> GetDynamicHeaders(string path, string queryParam) =>
|
||||
@ -293,7 +307,8 @@ internal sealed class OrchestrationDownloadServiceStub : IDownloadService
|
||||
string serverFileName, string resolvedFileName, string extension, IProgressReporter progressReporter) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)?> GetDecryptionInfo(string mpdUrl, string policy,
|
||||
public Task<(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)?> GetDecryptionInfo(
|
||||
string mpdUrl, string policy,
|
||||
string signature, string kvp, string mediaId, string contentId, string drmType, bool clientIdBlobMissing,
|
||||
bool devicePrivateKeyMissing) =>
|
||||
throw new NotImplementedException();
|
||||
@ -424,6 +439,8 @@ internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
|
||||
public List<(string contentType, DownloadResult result)> DownloadCompletes { get; } = [];
|
||||
public List<(string description, long maxValue, bool showSize)> ProgressCalls { get; } = [];
|
||||
|
||||
public CancellationToken CancellationToken { get; } = CancellationToken.None;
|
||||
|
||||
public Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work) =>
|
||||
work(new RecordingStatusReporter(statusMessage));
|
||||
|
||||
@ -455,14 +472,9 @@ internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
|
||||
public void OnMessage(string message) => Messages.Add(message);
|
||||
}
|
||||
|
||||
internal sealed class RecordingStatusReporter : IStatusReporter
|
||||
internal sealed class RecordingStatusReporter(string initialStatus) : IStatusReporter
|
||||
{
|
||||
private readonly List<string> _statuses;
|
||||
|
||||
public RecordingStatusReporter(string initialStatus)
|
||||
{
|
||||
_statuses = [initialStatus];
|
||||
}
|
||||
private readonly List<string> _statuses = [initialStatus];
|
||||
|
||||
public IReadOnlyList<string> Statuses => _statuses;
|
||||
|
||||
@ -485,7 +497,8 @@ internal sealed class FakeAuthService : IAuthService
|
||||
|
||||
public Task<bool> LoadFromFileAsync(string filePath = "auth.json") => throw new NotImplementedException();
|
||||
|
||||
public Task<bool> LoadFromBrowserAsync() => throw new NotImplementedException();
|
||||
public Task<bool> LoadFromBrowserAsync(Action<string>? dependencyStatusCallback = null) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task SaveToFileAsync(string filePath = "auth.json") => throw new NotImplementedException();
|
||||
|
||||
|
||||
@ -3,12 +3,14 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.33516.290
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL", "OF DL\OF DL.csproj", "{318B2CE3-D046-4276-A2CF-89E6DF32F1B3}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Cli", "OF DL.Cli\OF DL.Cli.csproj", "{318B2CE3-D046-4276-A2CF-89E6DF32F1B3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Core", "OF DL.Core\OF DL.Core.csproj", "{7B8B6A26-6732-4B3A-AE62-1CE589DFF8F2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Tests", "OF DL.Tests\OF DL.Tests.csproj", "{FF5EC4D7-6369-4A78-8C02-E370343E797C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Gui", "OF DL.Gui\OF DL.Gui.csproj", "{495749B1-DD15-4637-85AA-49841A86A510}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -27,6 +29,10 @@ Global
|
||||
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{495749B1-DD15-4637-85AA-49841A86A510}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{495749B1-DD15-4637-85AA-49841A86A510}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{495749B1-DD15-4637-85AA-49841A86A510}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{495749B1-DD15-4637-85AA-49841A86A510}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
# 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).
|
||||
@ -16,11 +16,40 @@ fi
|
||||
supervisord -c /etc/supervisor/conf.d/supervisord.conf &
|
||||
} &> /dev/null
|
||||
|
||||
# Wait for the 3 supervisor programs to start: X11 (Xvfb), X11vnc, and noVNC
|
||||
# Wait for the 4 supervisor programs to start: X11 (Xvfb), openbox, X11vnc, and noVNC
|
||||
NUM_RUNNING_SERVICES=$(supervisorctl -c /etc/supervisor/conf.d/supervisord.conf status | grep RUNNING | wc -l)
|
||||
while [ $NUM_RUNNING_SERVICES != "3" ]; do
|
||||
while [ $NUM_RUNNING_SERVICES != "4" ]; do
|
||||
sleep 1
|
||||
NUM_RUNNING_SERVICES=$(supervisorctl -c /etc/supervisor/conf.d/supervisord.conf status | grep RUNNING | wc -l)
|
||||
done
|
||||
|
||||
/app/OF\ DL
|
||||
# Wait for X server to be ready to accept connections
|
||||
echo "Waiting for X server to be ready..."
|
||||
timeout=30
|
||||
elapsed=0
|
||||
until xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; do
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for X server"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
echo "X server is ready"
|
||||
|
||||
# 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/OF\ DL.Cli "${filtered_args[@]}"
|
||||
else
|
||||
/app/OF\ DL.Gui "$@"
|
||||
fi
|
||||
|
||||
@ -16,6 +16,11 @@ serverurl=unix:///tmp/supervisor.sock
|
||||
command=Xvfb :0 -screen 0 "%(ENV_DISPLAY_WIDTH)s"x"%(ENV_DISPLAY_HEIGHT)s"x24
|
||||
autorestart=true
|
||||
|
||||
[program:openbox]
|
||||
command=openbox
|
||||
environment=DISPLAY=":0"
|
||||
autorestart=true
|
||||
|
||||
[program:x11vnc]
|
||||
command=/usr/bin/x11vnc
|
||||
autorestart=true
|
||||
|
||||
@ -305,7 +305,6 @@ Default: `0.98`
|
||||
Allowed values: `0.01` to `1.0`
|
||||
|
||||
Description: Minimum required ratio between downloaded DRM video length and expected length.
|
||||
Expected length is read from the MPD first, with media duration metadata used as a fallback.
|
||||
For example, `0.98` requires the downloaded file to be at least 98% of the expected duration.
|
||||
If the download is below this threshold, the program retries the download up to 3 times.
|
||||
|
||||
@ -387,6 +386,30 @@ Allowed values: `true`, `false`
|
||||
Description: A folder will be created for each post (containing all the media for that post) if set to `true`.
|
||||
When set to `false`, post media will be downloaded into the `Posts/Free` folder.
|
||||
|
||||
## HideMissingCdmKeysWarning
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Default: `false`
|
||||
|
||||
Allowed values: `true`, `false`
|
||||
|
||||
Description: This configuration option has no effect in classic (text-based) versions of OF DL. 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.
|
||||
|
||||
## HideShowScrapeSizeWarning
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Default: `false`
|
||||
|
||||
Allowed values: `true`, `false`
|
||||
|
||||
Description: This configuration option has no effect in classic (text-based) versions of OF DL. If set to `false`, OF-DL
|
||||
will show a warning and ask for confirmation before starting downloads when
|
||||
[ShowScrapeSize](#showscrapesize) is enabled. If set to `true`, this warning is hidden and downloads start immediately.
|
||||
|
||||
## IgnoreOwnMessages
|
||||
|
||||
Type: `boolean`
|
||||
@ -395,8 +418,8 @@ Default: `false`
|
||||
|
||||
Allowed values: `true`, `false`
|
||||
|
||||
Description: By default (or when set to `false`), messages that were sent by yourself will be added to the metadata DB
|
||||
and any media which has been sent by yourself will be downloaded. If set to `true`, the program will not add messages
|
||||
Description: By default (or when set to `false`), messages sent by yourself will be added to the metadata DB
|
||||
and any media that has been sent by yourself will be downloaded. If set to `true`, the program will not add messages
|
||||
sent by yourself to the metadata DB and will not download any media which has been sent by yourself.
|
||||
|
||||
## IgnoredUsersListName
|
||||
@ -581,6 +604,17 @@ Allowed values: `true`, `false`
|
||||
|
||||
Description: Posts and messages that contain #ad or free trial links will be ignored if set to `true`
|
||||
|
||||
## Theme
|
||||
|
||||
Type: `string`
|
||||
|
||||
Default: `"dark"`
|
||||
|
||||
Allowed values: `"light"`, `"dark"`
|
||||
|
||||
Description: This configuration option has no effect in classic (text-based) versions of OF DL. Controls the OF DL
|
||||
theme. Set to `"light"` for light mode or `"dark"` for dark mode.
|
||||
|
||||
## Timeout
|
||||
|
||||
Type: `integer`
|
||||
|
||||
@ -3,14 +3,10 @@
|
||||
## Current Method (versions >= 1.9.0)
|
||||
|
||||
OF DL allows you to log in to your OnlyFans account directly. This simplifies the authentication process significantly.
|
||||
When prompted by the application, log into your OnlyFans account. Do not close the opened window, tab, or navigate away to another webpage.
|
||||
When prompted by the application, log into your OnlyFans account. Do not close the opened window, tab, or navigate away
|
||||
to another webpage.
|
||||
The new window will close automatically when the authentication process has finished.
|
||||
|
||||
!!! warning
|
||||
|
||||
Some users have reported that "Sign in with Google" has not been working with this authentication method.
|
||||
If you use the Google sign-in option to log into your OnlyFans account, use one of the [legacy authentication methods](#legacy-methods) described below.
|
||||
|
||||
!!! info
|
||||
|
||||
If you are using docker, follow the special [authentication instructions documented](/installation/docker) to authenticate OF-DL
|
||||
@ -21,11 +17,14 @@ Legacy authentication methods involve creating/editing `auth.json` file yourself
|
||||
|
||||
### Browser Extension
|
||||
|
||||
You can use a browser extension to help get the required info for the `auth.json` file. The extension supports Google Chrome and Firefox and can be found [here](https://github.com/whimsical-c4lic0/OF-DL-Auth-Helper/) (https://github.com/whimsical-c4lic0/OF-DL-Auth-Helper/).
|
||||
You can use a browser extension to help get the required info for the `auth.json` file. The extension supports Google
|
||||
Chrome and Firefox and can be
|
||||
found [here](https://github.com/whimsical-c4lic0/OF-DL-Auth-Helper/) (https://github.com/whimsical-c4lic0/OF-DL-Auth-Helper/).
|
||||
|
||||
### Manual Method
|
||||
|
||||
Open `auth.json` in a text editor of your choice. The default windows notepad is sufficient. When you open `auth.json` for the first time you should see something like this:
|
||||
Open `auth.json` in a text editor of your choice. The default windows notepad is sufficient. When you open `auth.json`
|
||||
for the first time you should see something like this:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -36,7 +35,8 @@ Open `auth.json` in a text editor of your choice. The default windows notepad is
|
||||
}
|
||||
```
|
||||
|
||||
Next, log into OnlyFans, and press F12 to open the dev tools. In the filter box, type `api`, and open any page on OnlyFans (e.g. Messages). You should see some requests appear in the list within the network tab:
|
||||
Next, log into OnlyFans, and press F12 to open the dev tools. In the filter box, type `api`, and open any page on
|
||||
OnlyFans (e.g. Messages). You should see some requests appear in the list within the network tab:
|
||||
|
||||

|
||||
|
||||
@ -56,7 +56,8 @@ The value of `USER_AGENT` will be set to what the `User-Agent` is set to in the
|
||||
|
||||
The value of `X_BC` will be set to what the `X-Bc` is set to in the Request Headers.
|
||||
|
||||
The value of `COOKIE` will be set to `auth_id=YOUR AUTH_ID HERE; sess=YOUR SESS HERE;`, please make sure you copy the values from within the Cookie field found in the Request Headers section.
|
||||
The value of `COOKIE` will be set to `auth_id=YOUR AUTH_ID HERE; sess=YOUR SESS HERE;`, please make sure you copy the
|
||||
values from within the Cookie field found in the Request Headers section.
|
||||
|
||||
If you have done everything correct you should end up with something like this (this is all dummy info):
|
||||
|
||||
|
||||
@ -1,24 +1,36 @@
|
||||
# CDM (optional, but recommended)
|
||||
|
||||
Without Widevine/CDM keys, OF DL uses the 3rd party website cdrm-project.org for decrypting DRM videos. With keys, OF DL directly communicates with OnlyFans. It is highly recommended to use keys, both in case the cdrm-project site is having issues (which occur frequently, in our experience) and it will result in faster download speeds, too. However, this is optional, as things will work as long as cdrm-project is functional.
|
||||
Without Widevine/CDM keys, OF DL uses ofdl.tools for decrypting DRM videos. With keys, OF DL directly communicates with
|
||||
OnlyFans. It is highly recommended to use keys, both in case ofdl.tools has issues (which occur rarely) and for faster
|
||||
download speeds.
|
||||
|
||||
Two files need to be generated, called `device_client_id_blob` and `device_private_key`. In your main OF DL folder (where you have `config.json` and `auth.json`), create a folder called `cdm` (if it does not already exist). Inside it, create a folder called `devices` and inside that, create a folder called `chrome_1610`. Finally, inside this last folder (`chrome_1610`), place the two key files. (Note that this folder name is a legacy name and OFDL does not actually use Chrome itself.)
|
||||
|
||||
## Manual Generation Method
|
||||
|
||||
You can find a tutorial on how to do this [here](https://forum.videohelp.com/threads/408031-Dumping-Your-own-L3-CDM-with-Android-Studio).
|
||||
|
||||
I have also made some [batch scripts](https://github.com/sim0n00ps/L3-Dumping) to run the commands included in the guide linked above that can save you some time and makes the process a little simpler.
|
||||
Two files need to be generated, called `device_client_id_blob` and `device_private_key`. In your main OF DL folder (
|
||||
where you have `config.json` and `auth.json`), create a folder called `cdm` (if it does not already exist). Inside it,
|
||||
create a folder called `devices` and inside that, create a folder called `chrome_1610`. Finally, inside this last
|
||||
folder (`chrome_1610`), place the two key files. (Note that this folder name is a legacy name and OFDL does not actually
|
||||
use Chrome itself for decryption.)
|
||||
|
||||
## Discord Method
|
||||
|
||||
Generating these keys can be complicated, so the team (shout out to Masaki here) have set up a bot on the Discord server to help securely deliver these keys to users who need them. You can join the discord sever [here](https://discord.com/invite/6bUW8EJ53j)
|
||||
Generating these keys can be complicated, so the team (shout out to Masaki here) have set up a bot on the Discord server
|
||||
to help securely deliver these keys to users who need them. You can join the discord
|
||||
sever [here](https://discord.com/invite/6bUW8EJ53j)
|
||||
|
||||
After joining, visit the bot [here](https://discord.com/channels/1198332760947966094/1333835216313122887) (the pinned post in the `#ofdl` support forum)
|
||||
After joining, visit the bot [here](https://discord.com/channels/1198332760947966094/1333835216313122887) (the pinned
|
||||
post in the `#ofdl` support forum)
|
||||
|
||||
## Manual Generation Method
|
||||
|
||||
You can find a tutorial on how to do
|
||||
this [here](https://forum.videohelp.com/threads/408031-Dumping-Your-own-L3-CDM-with-Android-Studio).
|
||||
|
||||
I have also made some [batch scripts](https://github.com/sim0n00ps/L3-Dumping) to run the commands included in the guide
|
||||
linked above that can save you some time and makes the process a little simpler.
|
||||
|
||||
## After install
|
||||
|
||||
Restart OF DL, and you should no longer see the yellow warning message about cdrm-project and instead see two green messages like so:
|
||||
Restart OF DL, and you should no longer see the yellow warning message about cdrm-project and instead see two green
|
||||
messages like so:
|
||||
|
||||
```
|
||||
device_client_id_blob located successfully!
|
||||
|
||||
@ -70,3 +70,8 @@ information about what it does, its default value, and the allowed values.
|
||||
|
||||
- Logging
|
||||
- [LoggingLevel](/config/all-configuration-options#logginglevel)
|
||||
|
||||
- Appearance
|
||||
- [Theme](/config/all-configuration-options#theme)
|
||||
- [HideMissingCdmKeysWarning](/config/all-configuration-options#hidemissingcdmkeyswarning)
|
||||
- [HideShowScrapeSizeWarning](/config/all-configuration-options#hideshowscrapesizewarning)
|
||||
|
||||
@ -16,9 +16,15 @@ To run OF-DL in a docker container, follow these steps:
|
||||
```bash
|
||||
docker run --rm -it -v $HOME/ofdl/data/:/data -v $HOME/ofdl/config/:/config -p 8080:8080 git.ofdl.tools/sim0n00ps/of-dl:latest
|
||||
```
|
||||
If `config.json` and/or `rules.json` don't exist in the `config` directory, files with default values will be created when you run the docker container.
|
||||
If `config.json` and/or `rules.json` don't exist in the `config` directory, files with default values will be created
|
||||
when you run the docker container.
|
||||
If you have your own Widevine keys, those files should be placed under `$HOME/ofdl/config/cdm/devices/chrome_1610/`.
|
||||
5. OF-DL needs to be authenticated with your OnlyFans account. When prompted, open [http://localhost:8080](http://localhost:8080) in a web browser to log in to your OnlyFans account.
|
||||
5. Open [http://localhost:8080](http://localhost:8080) in a web browser, and click the "Connect" button on the webpage.
|
||||
|
||||
!!! info
|
||||
|
||||
If you wish to use the classic text-based version of OF-DL, append `--cli` to the end of your `docker run` command.
|
||||
For instance, `docker run --rm -it -v $HOME/ofdl/data/:/data -v $HOME/ofdl/config/:/config -p 8080:8080 git.ofdl.tools/sim0n00ps/of-dl:latest --cli`
|
||||
|
||||
## Updating OF-DL
|
||||
|
||||
@ -28,12 +34,15 @@ When a new version of OF-DL is released, you can download the latest docker imag
|
||||
docker pull git.ofdl.tools/sim0n00ps/of-dl:latest
|
||||
```
|
||||
|
||||
You can then run the new version of OF-DL by executing the `docker run` command in the [Running OF-DL](#running-of-dl) section above.
|
||||
You can then run the new version of OF-DL by executing the `docker run` command in the [Running OF-DL](#running-of-dl)
|
||||
section above.
|
||||
|
||||
## Building the Docker Image (Optional)
|
||||
|
||||
Since official docker images are provided for OF-DL through Gitea (git.ofdl.tools), you do not need to build the docker image yourself.
|
||||
If you would like to build the docker image yourself, however, start by cloning the OF-DL repository and opening a terminal in the root directory of the repository.
|
||||
Since official docker images are provided for OF-DL through Gitea (git.ofdl.tools), you do not need to build the docker
|
||||
image yourself.
|
||||
If you would like to build the docker image yourself, however, start by cloning the OF-DL repository and opening a
|
||||
terminal in the root directory of the repository.
|
||||
Then, execute the following command while replacing `x.x.x` with the current version of OF-DL:
|
||||
|
||||
```bash
|
||||
|
||||
@ -2,14 +2,16 @@
|
||||
|
||||
A Linux release of OF-DL is not available at this time, however you can run OF-DL on Linux using Docker.
|
||||
Please refer to the [Docker](/installation/docker) page for instructions on how to run OF-DL in a Docker container.
|
||||
If you do not have Docker installed, you can download it from [here](https://docs.docker.com/desktop/install/linux-install/).
|
||||
If you do not have Docker installed, you can download it
|
||||
from [here](https://docs.docker.com/desktop/install/linux-install/).
|
||||
If you would like to run OF-DL natively on Linux, you can build it from source by following the instructions below.
|
||||
|
||||
## Building from source
|
||||
|
||||
- Install FFmpeg (and FFprobe)
|
||||
|
||||
Follow the 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
|
||||
Follow the installation instructions from FFmpeg ([https://ffmpeg.org/download.html](https://ffmpeg.org/download.html))
|
||||
for your distro (Ubuntu, Debian, Fedora, etc.) to install FFmpeg and FFprobe
|
||||
|
||||
!!! warning
|
||||
|
||||
@ -17,7 +19,9 @@ Follow the installtion instructions from FFmpeg ([https://ffmpeg.org/download.ht
|
||||
|
||||
- Install .NET 10
|
||||
|
||||
Follow the installation instructions from Microsoft ([https://learn.microsoft.com/en-us/dotnet/core/install/linux](https://learn.microsoft.com/en-us/dotnet/core/install/linux)) for your distro (Ubuntu, Debian, Fedora, etc.) to install .NET 10.
|
||||
Follow the installation instructions from
|
||||
Microsoft ([https://learn.microsoft.com/en-us/dotnet/core/install/linux](https://learn.microsoft.com/en-us/dotnet/core/install/linux))
|
||||
for your distro (Ubuntu, Debian, Fedora, etc.) to install .NET 10.
|
||||
|
||||
- Clone the repo
|
||||
|
||||
@ -28,15 +32,20 @@ cd 'OF-DL'
|
||||
|
||||
- Build the project. Replace `%VERSION%` with the current version number of OF-DL (e.g. `1.9.20`).
|
||||
|
||||
- Modern graphical version (recommended)
|
||||
```bash
|
||||
dotnet publish "OF DL/OF DL.csproj" -p:Version=%VERSION% -p:PackageVersion=%VERSION% -c Release
|
||||
cd 'OF DL/bin/Release/net10.0'
|
||||
dotnet publish "OF DL.Cli/OF DL.Cli.csproj" -p:Version=%VERSION% -p:PackageVersion=%VERSION% -c Release
|
||||
cd 'OF DL.Cli/bin/Release/net10.0'
|
||||
```
|
||||
|
||||
- Classic text-based version
|
||||
```bash
|
||||
dotnet publish "OF DL.Cli/OF DL.Cli.csproj" -p:Version=%VERSION% -p:PackageVersion=%VERSION% -c Release
|
||||
cd 'OF DL.Cli/bin/Release/net10.0'
|
||||
```
|
||||
|
||||
- Download the windows release as described on [here](/installation/windows#installation).
|
||||
|
||||
- Add the `config.conf` and `rules.json` files as well as the `cdm` folder to the `OF DL/bin/Release/net10.0` folder.
|
||||
|
||||
- Run the application
|
||||
|
||||
```bash
|
||||
|
||||
@ -2,22 +2,24 @@
|
||||
|
||||
## Requirements
|
||||
|
||||
### FFmpeg
|
||||
|
||||
You will need to download FFmpeg. You can download it from [here](https://www.gyan.dev/ffmpeg/builds/).
|
||||
Make sure you download `ffmpeg-release-essentials.zip`. Unzip it anywhere on your computer. You need both `ffmpeg.exe` and `ffprobe.exe`.
|
||||
Move `ffmpeg.exe` and `ffprobe.exe` to the same folder as `OF DL.exe` (downloaded in the installation steps below). If you choose to move them to a different folder,
|
||||
you will need to specify the paths in the config file (see the `FFmpegPath` and `FFprobePath` [config options](/config/configuration)).
|
||||
|
||||
## Installation
|
||||
|
||||
1. Navigate to the OF-DL [releases page](https://git.ofdl.tools/sim0n00ps/OF-DL/releases), and download the latest release zip file. The zip file will be named `OFDLVx.x.x.zip` where `x.x.x` is the version number.
|
||||
2. Unzip the downloaded file. The destination folder can be anywhere on your computer, preferably somewhere where you want to download content to/already have content downloaded.
|
||||
3. Your folder should contain a folder named `cdm` as well as the following files:
|
||||
1. Navigate to the OF-DL [releases page](https://git.ofdl.tools/sim0n00ps/OF-DL/releases), and download the latest
|
||||
release zip file. The zip file will be named `OFDLVx.x.x.zip` where `x.x.x` is the version number.
|
||||
2. Unzip the downloaded file. The destination folder can be anywhere on your computer, preferably somewhere where you
|
||||
want to download content to/already have content downloaded.
|
||||
3. Your folder should contain 3 folders named `.playwright`, `cdm`, and `chromium-scripts` as well as the following
|
||||
files:
|
||||
- OF DL.exe
|
||||
- OF DL - Legacy.exe
|
||||
- config.json
|
||||
- rules.json
|
||||
- e_sqlite3.dll
|
||||
- ffmpeg.exe
|
||||
- ffprobe.exe
|
||||
4. Once you have done this, run OF DL.exe
|
||||
- LICENSE.ffmpeg
|
||||
- playwright.ps1
|
||||
- av_libglesv2.dll
|
||||
- e_sqlite3.dll
|
||||
- libHarfBuzzSharp.dll
|
||||
- libSkiaSharp.dll
|
||||
4. Once you have done this, run OF DL.exe (or OF DL - Classic.exe for the legacy text-based version)
|
||||
|
||||
69
docs/operation/classic-version.md
Normal file
69
docs/operation/classic-version.md
Normal file
@ -0,0 +1,69 @@
|
||||
!!! info
|
||||
|
||||
These instructions are for the Classic version of OF DL. For instructions relating to the modern graphical version,
|
||||
see [here](/operation/modern-version).
|
||||
|
||||
# Running the Program
|
||||
|
||||
Once you are happy you have filled everything in [auth.json](/config/auth) correctly, you can double-click "OF DL -
|
||||
Classic.exe", and you should see a command prompt window appear, it should look something like this:
|
||||
|
||||

|
||||
|
||||
It should locate `config.conf`, `rules.json`, FFmpeg, and FFprobe successfully. If anything doesn't get located
|
||||
successfully, then make sure the files exist or the path is correct.
|
||||
|
||||
OF-DL will open a new window, if needed, to allow you to log into your OnlyFans account. The window will automatically
|
||||
close once
|
||||
the authorization process has finished. If the auth info is correct then you should see a message in green text
|
||||
`Logged In successfully as {Your Username} {Your User Id}`. However, if the authorization has failed,
|
||||
then a message in red text will appear
|
||||
`Auth failed, please check the values in auth.json are correct, press any key to exit.`
|
||||
This means you need to go back and fill in the `auth.json` file again, this will usually indicate that your `user-agent`
|
||||
has changed or you need to re-copy your `sess` value.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
The `Select All` option will go through every account you are currently subscribed to and grab all of the media from the
|
||||
users.
|
||||
|
||||
The `List` option will show you all the lists you have created on OnlyFans and you can then select 1 or more lists to
|
||||
download the content of the users within those lists.
|
||||
|
||||
The `Custom` option allows you to select 1 or more accounts you want to scrape media from so if you only want to get
|
||||
media from a select number of accounts then you can do that.
|
||||
To navigate the menu the can use the ↑ & ↓ arrows. You can also press keys A-Z on the keyboard whilst in the menu to
|
||||
easily navigate the menu and for example
|
||||
pressing the letter 'c' on the keyboard will highlight the first user in the list whose username starts with the
|
||||
letter 'c'. To select/deselect an account,
|
||||
press the space key, and after you are happy with your selection(s), press the enter key to start downloading.
|
||||
|
||||
The `Download Single Post` allows you to download a post from a URL, to get this URL go to any post and press the 3
|
||||
dots, Copy link to post.
|
||||
|
||||
The `Download Single Message` allows you to download a message from a URL, to get this URL go to any message in the *
|
||||
*purchased tab** and press the 3 dots, Copy link to message.
|
||||
|
||||
The `Download Purchased Tab` option will download all the media from the purchased tab in OnlyFans.
|
||||
|
||||
The `Edit config.json` option allows you to change the config from within the program.
|
||||
|
||||
The `Change logging level` option allows you to change the logging level that the program uses when writing logs to
|
||||
files in the `logs` folder.
|
||||
|
||||
The `Logout and Exit` option allows you to remove your authentication from OF-DL. This is useful if you use multiple
|
||||
OnlyFans accounts.
|
||||
|
||||
After you have made your selection, the content should start downloading. Content is downloaded in this order:
|
||||
|
||||
1. Paid Posts
|
||||
2. Posts
|
||||
3. Archived
|
||||
4. Streams
|
||||
5. Stories
|
||||
6. Highlights
|
||||
7. Messages
|
||||
8. Paid Messages
|
||||
55
docs/operation/modern-version.md
Normal file
55
docs/operation/modern-version.md
Normal file
@ -0,0 +1,55 @@
|
||||
!!! info
|
||||
|
||||
These instructions are for the modern graphical version of OF DL. For the classic command-line version, see
|
||||
[here](/operation/classic-version).
|
||||
|
||||
# Using OF DL (GUI)
|
||||
|
||||
## Open and sign in
|
||||
|
||||
Double-click on `OF DL.exe` to open the app.
|
||||
|
||||
If you are not signed in yet, you will see **Authentication Required**.
|
||||
|
||||
1. Click **Login with Browser** (recommended).
|
||||
2. Sign in to your OnlyFans account in the browser window that opens.
|
||||
3. Wait for OF DL to return to the main screen.
|
||||
|
||||
If browser login does not work for or you prefer to use another method, use **Manual Authentication** and read about
|
||||
[Legacy Auth Methods](/config/auth/#legacy-methods).
|
||||
|
||||
## Choose what to download
|
||||
|
||||
From the menubar at the top of the OF DL window, open **Edit -> Configuration**.
|
||||
|
||||
In the **Download Media Types and Sources** section, choose what you want to download:
|
||||
|
||||
- Media types: videos, images, audio
|
||||
- Sources: posts, messages, archived, stories, highlights, and more
|
||||
|
||||
In the **Download Behavior** section, you can choose where files are saved. There are many other options available to
|
||||
configure on this page. Hover your mouse over the question mark icons for more information about each option or check
|
||||
out the [configuration documentation](/config/all-configuration-options/) for additional details.
|
||||
|
||||
Click **Save Configuration** at the bottom of the page to save your changes and return to the main screen.
|
||||
|
||||
## Download content
|
||||
|
||||
On the main screen:
|
||||
|
||||
- Click on creators' names one by one to select them, or use the top checkbox to select all
|
||||
- Use the list dropdown to quickly select creators from an OnlyFans list
|
||||
- From the menubar at the top of the window, click **File -> Refresh** if your subscriptions or lists changed
|
||||
|
||||
Use the download buttons at the top:
|
||||
|
||||
- **Download Selected**: downloads media for the creators you selected
|
||||
- **Download Purchased Tab**: downloads media from your Purchased tab
|
||||
- **Download Single Post/Message**: downloads a single post or paid message URL
|
||||
|
||||
While any download is running:
|
||||
|
||||
- The **Activity Log** on the right half of the window shows progress and messages
|
||||
- The progress bar at the bottom of the window shows current download status
|
||||
- The **Stop** button in the top right corner of the window cancels the current download
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
# Running the Program
|
||||
|
||||
Once you are happy you have filled everything in [auth.json](/config/auth) correctly, you can double click OF-DL.exe and you should see a command prompt window appear, it should look something like this:
|
||||
|
||||

|
||||
|
||||
It should locate `config.conf`, `rules.json`, FFmpeg, and FFprobe successfully. If anything doesn't get located
|
||||
successfully, then make sure the files exist or the path is correct.
|
||||
|
||||
OF-DL will open a new window, if needed, to allow you to log into your OnlyFans account. The window will automatically close once
|
||||
the authorization process has finished. If the auth info is correct then you should see a message in green text
|
||||
`Logged In successfully as {Your Username} {Your User Id}`. However, if the authorization has failed,
|
||||
then a message in red text will appear `Auth failed, please check the values in auth.json are correct, press any key to exit.`
|
||||
This means you need to go back and fill in the `auth.json` file again, this will usually indicate that your `user-agent` has changed or you need to re-copy your `sess` value.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
The `Select All` option will go through every account you are currently subscribed to and grab all of the media from the users.
|
||||
|
||||
The `List` option will show you all the lists you have created on OnlyFans and you can then select 1 or more lists to download the content of the users within those lists.
|
||||
|
||||
The `Custom` option allows you to select 1 or more accounts you want to scrape media from so if you only want to get media from a select number of accounts then you can do that.
|
||||
To navigate the menu the can use the ↑ & ↓ arrows. You can also press keys A-Z on the keyboard whilst in the menu to easily navigate the menu and for example
|
||||
pressing the letter 'c' on the keyboard will highlight the first user in the list whose username starts with the letter 'c'. To select/deselect an account,
|
||||
press the space key, and after you are happy with your selection(s), press the enter key to start downloading.
|
||||
|
||||
The `Download Single Post` allows you to download a post from a URL, to get this URL go to any post and press the 3 dots, Copy link to post.
|
||||
|
||||
The `Download Single Message` allows you to download a message from a URL, to get this URL go to any message in the **purchased tab** and press the 3 dots, Copy link to message.
|
||||
|
||||
The `Download Purchased Tab` option will download all the media from the purchased tab in OnlyFans.
|
||||
|
||||
The `Edit config.json` option allows you to change the config from within the program.
|
||||
|
||||
The `Change logging level` option allows you to change the logging level that the program uses when writing logs to files in the `logs` folder.
|
||||
|
||||
The `Logout and Exit` option allows you to remove your authentication from OF-DL. This is useful if you use multiple OnlyFans accounts.
|
||||
|
||||
After you have made your selection the content should start downloading. Content is downloaded in this order:
|
||||
|
||||
1. Paid Posts
|
||||
2. Posts
|
||||
3. Archived
|
||||
4. Streams
|
||||
5. Stories
|
||||
6. Highlights
|
||||
7. Messages
|
||||
8. Paid Messages
|
||||
@ -7,7 +7,9 @@ nav:
|
||||
- macOS: installation/macos.md
|
||||
- Linux: installation/linux.md
|
||||
- Docker: installation/docker.md
|
||||
- Running the Program: running-the-program.md
|
||||
- Running the Program:
|
||||
- Modern Version: operation/modern-version.md
|
||||
- Classic Version: operation/classic-version.md
|
||||
- Config:
|
||||
- Authentication: config/auth.md
|
||||
- CDM (optional, but recommended): config/cdm.md
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user