diff --git a/.editorconfig b/.editorconfig index b3672fd..6cb91ba 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,9 +1,186 @@ -# Editor configuration, see https://editorconfig.org +# editorconfig.org + +# top-most EditorConfig file root = true +# Default settings: +# A newline ending every file +# Use 4 spaces as indentation [*] charset = utf-8 indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true + +[project.json] +indent_size = 2 + +# Generated code +[*{_AssemblyInfo.cs,.notsupported.cs}] +generated_code = true + +# C# files +[*.cs] +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Modifier preferences +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:suggestion + +# avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# Types: use keywords instead of BCL types, and permit var only when the type is clear +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = false:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# static fields should have s_ prefix +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Code style defaults +csharp_using_directive_placement = outside_namespace:suggestion +dotnet_sort_system_directives_first = true +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true:none +csharp_preserve_single_line_statements = false:none +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = false:none +csharp_style_prefer_switch_expression = true:suggestion +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +csharp_prefer_simple_default_expression = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_constructors = true:silent +csharp_style_expression_bodied_operators = true:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = true:silent + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Other features +csharp_style_prefer_index_operator = false:none +csharp_style_prefer_range_operator = false:none +csharp_style_pattern_local_over_anonymous_function = false:none + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Xml project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] +indent_size = 2 + +[*.{csproj,vbproj,proj,nativeproj,locproj}] +charset = utf-8 + +# Xml build files +[*.builds] +indent_size = 2 + +# Xml files +[*.{xml,stylecop,resx,ruleset}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +# YAML config files +[*.{yml,yaml}] +indent_size = 2 + +# Shell scripts +[*.sh] +end_of_line = lf + +[*.{cmd,bat}] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes index 1ff0c42..06ec5e7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,11 @@ ############################################################################### * text=auto +############################################################################### +# Shell scripts should use LF line endings (avoid /bin/sh^M issues in containers) +############################################################################### +*.sh text eol=lf + ############################################################################### # Set default behavior for command prompt diff. # diff --git a/.gitea/workflows/publish-release.yml b/.gitea/workflows/publish-release.yml index 432a534..9dc43f6 100644 --- a/.gitea/workflows/publish-release.yml +++ b/.gitea/workflows/publish-release.yml @@ -29,12 +29,12 @@ jobs: - name: Build for Windows and Linux run: | - dotnet publish -p:Version=${{ steps.version.outputs.version }} \ + dotnet publish "OF DL/OF DL.csproj" -p:Version=${{ steps.version.outputs.version }} \ -p:PackageVersion=${{ steps.version.outputs.version }} \ -p:WarningLevel=0 -c Release -r win-x86 \ --self-contained true -p:PublishSingleFile=true -o outwin - dotnet publish -p:Version=${{ steps.version.outputs.version }} \ + dotnet publish "OF DL/OF DL.csproj" -p:Version=${{ steps.version.outputs.version }} \ -p:PackageVersion=${{ steps.version.outputs.version }} \ -p:WarningLevel=0 -c Release -r linux-x64 \ --self-contained true -p:PublishSingleFile=true -o outlin diff --git a/.gitignore b/.gitignore index 822cbd2..4d0320d 100644 --- a/.gitignore +++ b/.gitignore @@ -370,4 +370,7 @@ FodyWeavers.xsd !.gitea-actions/**/node_modules/ # venv -venv/ \ No newline at end of file +venv/ + +# Generated docs +/site diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..368b337 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,238 @@ +# AGENTS.md + +Note: Keep AGENTS.md updated as project structure, key services, or workflows change. + +This repo is **OF DL** (also known as OF-DL), a C# console app that downloads media from a user's OnlyFans account(s). +This document is for AI agents helping developers modify the project. It focuses on architecture, data flow, and the +most important change points. + +## Quick Flow + +1. `Program.Main` builds DI, loads `config.conf`, and runs the interactive flow. +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. + +## Project Layout + +- `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 Services + +- `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. + +## Models + +- 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 + +## Configuration + +- 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. + +## Runtime Files (relative to the working directory) + +- `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. + +## Execution and Testing + +- .NET SDK: 8.x (`net8.0` for all projects). +- Build from the repo root: + +```bash +dotnet build OF DL.sln +``` + +- Run from source (runtime files are read from the current working directory): + +```bash +dotnet run --project "OF DL/OF DL.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: + +```bash +dotnet test "OF DL.Tests/OF DL.Tests.csproj" +``` + +- Optional coverage (coverlet collector): + +```bash +dotnet test "OF DL.Tests/OF DL.Tests.csproj" --collect:"XPlat Code Coverage" +``` + +## Authentication Flow + +- 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`. + +Environment variables used by auth: + +- `OFDL_DOCKER=true` toggles Docker-specific instructions and browser flags. +- `OFDL_PUPPETEER_EXECUTABLE_PATH` overrides the Chromium path for PuppeteerSharp. + +## Dynamic Rules and Signature Headers + +- 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`. + +Signature algorithm in `GetDynamicHeaders`: + +- `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}"` + +Headers included in signed requests: + +- `app-token`, `sign`, `time`, `user-id`, `user-agent`, `x-bc`, `cookie`. + +## Widevine CDM and DRM Decryption + +- 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`. + +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`. diff --git a/Dockerfile b/Dockerfile index 973bc4f..f94f831 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,16 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG VERSION # Copy source code COPY ["OF DL.sln", "/src/OF DL.sln"] COPY ["OF DL", "/src/OF DL"] +COPY ["OF DL.Core", "/src/OF DL.Core"] WORKDIR "/src" # Build release -RUN dotnet publish -p:WarningLevel=0 -p:Version=$VERSION -c Release --self-contained true -p:PublishSingleFile=true -o out +RUN dotnet publish "OF DL/OF DL.csproj" -p:WarningLevel=0 -p:Version=$VERSION -c Release -o out # Generate default config.conf files RUN /src/out/OF\ DL --non-interactive || true && \ @@ -18,7 +19,7 @@ RUN /src/out/OF\ DL --non-interactive || true && \ mv /src/updated_config.conf /src/config.conf -FROM mcr.microsoft.com/dotnet/runtime:8.0 AS final +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final # Install dependencies RUN apt-get update \ diff --git a/OF DL.Core/Crypto/CryptoUtils.cs b/OF DL.Core/Crypto/CryptoUtils.cs new file mode 100644 index 0000000..3c519c8 --- /dev/null +++ b/OF DL.Core/Crypto/CryptoUtils.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Macs; +using Org.BouncyCastle.Crypto.Parameters; + +namespace OF_DL.Crypto; + +public class CryptoUtils +{ + public static byte[] GetHMACSHA256Digest(byte[] data, byte[] key) => new HMACSHA256(key).ComputeHash(data); + + public static byte[] GetCMACDigest(byte[] data, byte[] key) + { + IBlockCipher cipher = new AesEngine(); + IMac mac = new CMac(cipher, 128); + + KeyParameter keyParam = new(key); + + mac.Init(keyParam); + + mac.BlockUpdate(data, 0, data.Length); + + byte[] outBytes = new byte[16]; + + mac.DoFinal(outBytes, 0); + return outBytes; + } +} diff --git a/OF DL.Core/Crypto/Padding.cs b/OF DL.Core/Crypto/Padding.cs new file mode 100644 index 0000000..2927482 --- /dev/null +++ b/OF DL.Core/Crypto/Padding.cs @@ -0,0 +1,127 @@ +using System.Security.Cryptography; + +namespace OF_DL.Crypto; + +public class Padding +{ + public static byte[] AddPKCS7Padding(byte[] data, int k) + { + int m = k - data.Length % k; + + byte[] padding = new byte[m]; + Array.Fill(padding, (byte)m); + + byte[] paddedBytes = new byte[data.Length + padding.Length]; + Buffer.BlockCopy(data, 0, paddedBytes, 0, data.Length); + Buffer.BlockCopy(padding, 0, paddedBytes, data.Length, padding.Length); + + return paddedBytes; + } + + public static byte[] RemovePKCS7Padding(byte[] paddedByteArray) + { + byte last = paddedByteArray[^1]; + if (paddedByteArray.Length <= last) + { + return paddedByteArray; + } + + return SubArray(paddedByteArray, 0, paddedByteArray.Length - last); + } + + public static T[] SubArray(T[] arr, int start, int length) + { + T[] result = new T[length]; + Buffer.BlockCopy(arr, start, result, 0, length); + + return result; + } + + public static byte[] AddPssPadding(byte[] hash) + { + int modBits = 2048; + int hLen = 20; + int emLen = 256; + + int lmask = 0; + for (int i = 0; i < 8 * emLen - (modBits - 1); i++) + { + lmask = (lmask >> 1) | 0x80; + } + + // Commented out since the condition will always be false while emLen = 256 and hLen = 20 + // if (emLen < hLen + hLen + 2) + // { + // return null; + // } + + byte[] salt = new byte[hLen]; + new Random().NextBytes(salt); + + byte[] m_prime = Enumerable.Repeat((byte)0, 8).ToArray().Concat(hash).Concat(salt).ToArray(); + byte[] h = SHA1.Create().ComputeHash(m_prime); + + byte[] ps = Enumerable.Repeat((byte)0, emLen - hLen - hLen - 2).ToArray(); + byte[] db = ps.Concat(new byte[] { 0x01 }).Concat(salt).ToArray(); + + byte[] dbMask = MGF1(h, emLen - hLen - 1); + + byte[] maskedDb = new byte[dbMask.Length]; + for (int i = 0; i < dbMask.Length; i++) + { + maskedDb[i] = (byte)(db[i] ^ dbMask[i]); + } + + maskedDb[0] = (byte)(maskedDb[0] & ~lmask); + + byte[] padded = maskedDb.Concat(h).Concat(new byte[] { 0xBC }).ToArray(); + + return padded; + } + + public static byte[] RemoveOAEPPadding(byte[] data) + { + int k = 256; + int hLen = 20; + + byte[] maskedSeed = data[1..(hLen + 1)]; + byte[] maskedDB = data[(hLen + 1)..]; + + byte[] seedMask = MGF1(maskedDB, hLen); + + byte[] seed = new byte[maskedSeed.Length]; + for (int i = 0; i < maskedSeed.Length; i++) + { + seed[i] = (byte)(maskedSeed[i] ^ seedMask[i]); + } + + byte[] dbMask = MGF1(seed, k - hLen - 1); + + byte[] db = new byte[maskedDB.Length]; + for (int i = 0; i < maskedDB.Length; i++) + { + db[i] = (byte)(maskedDB[i] ^ dbMask[i]); + } + + int onePos = BitConverter.ToString(db[hLen..]).Replace("-", "").IndexOf("01", StringComparison.Ordinal) / 2; + byte[] unpadded = db[(hLen + onePos + 1)..]; + + return unpadded; + } + + private static byte[] MGF1(byte[] seed, int maskLen) + { + SHA1 hobj = SHA1.Create(); + int hLen = hobj.HashSize / 8; + List T = new(); + for (int i = 0; i < (int)Math.Ceiling(maskLen / (double)hLen); i++) + { + byte[] c = BitConverter.GetBytes(i); + Array.Reverse(c); + byte[] digest = hobj.ComputeHash(seed.Concat(c).ToArray()); + T.AddRange(digest); + } + + return T.GetRange(0, maskLen).ToArray(); + } +} diff --git a/OF DL.Core/Enumerations/CustomFileNameOption.cs b/OF DL.Core/Enumerations/CustomFileNameOption.cs new file mode 100644 index 0000000..6656873 --- /dev/null +++ b/OF DL.Core/Enumerations/CustomFileNameOption.cs @@ -0,0 +1,7 @@ +namespace OF_DL.Enumerations; + +public enum CustomFileNameOption +{ + ReturnOriginal, + ReturnEmpty +} diff --git a/OF DL.Core/Enumerations/DownloadDateSelection.cs b/OF DL.Core/Enumerations/DownloadDateSelection.cs new file mode 100644 index 0000000..2f1d949 --- /dev/null +++ b/OF DL.Core/Enumerations/DownloadDateSelection.cs @@ -0,0 +1,7 @@ +namespace OF_DL.Enumerations; + +public enum DownloadDateSelection +{ + before, + after +} diff --git a/OF DL.Core/Enumerations/LoggingLevel.cs b/OF DL.Core/Enumerations/LoggingLevel.cs new file mode 100644 index 0000000..f9c1d35 --- /dev/null +++ b/OF DL.Core/Enumerations/LoggingLevel.cs @@ -0,0 +1,34 @@ +namespace OF_DL.Enumerations; + +public enum LoggingLevel +{ + // + // Summary: + // Anything and everything you might want to know about a running block of code. + Verbose, + + // + // Summary: + // Internal system events that aren't necessarily observable from the outside. + Debug, + + // + // Summary: + // The lifeblood of operational intelligence - things happen. + Information, + + // + // Summary: + // Service is degraded or endangered. + Warning, + + // + // Summary: + // Functionality is unavailable, invariants are broken or data is lost. + Error, + + // + // Summary: + // If you have a pager, it goes off when one of these occurs. + Fatal +} diff --git a/OF DL.Core/Enumerations/MediaType.cs b/OF DL.Core/Enumerations/MediaType.cs new file mode 100644 index 0000000..ccdd25d --- /dev/null +++ b/OF DL.Core/Enumerations/MediaType.cs @@ -0,0 +1,12 @@ +namespace OF_DL.Enumerations; + +public enum MediaType +{ + PaidPosts = 10, + Posts = 20, + Archived = 30, + Stories = 40, + Highlights = 50, + Messages = 60, + PaidMessages = 70 +} diff --git a/OF DL.Core/Enumerations/VideoResolution.cs b/OF DL.Core/Enumerations/VideoResolution.cs new file mode 100644 index 0000000..d07a1fd --- /dev/null +++ b/OF DL.Core/Enumerations/VideoResolution.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Enumerations; + +public enum VideoResolution +{ + _240, + _720, + source +} diff --git a/OF DL.Core/Helpers/Constants.cs b/OF DL.Core/Helpers/Constants.cs new file mode 100644 index 0000000..2843ee7 --- /dev/null +++ b/OF DL.Core/Helpers/Constants.cs @@ -0,0 +1,12 @@ +namespace OF_DL.Helpers; + +public static class Constants +{ + public const string ApiUrl = "https://onlyfans.com/api2/v2"; + + public const int ApiPageSize = 50; + + public const int WidevineRetryDelay = 10; + + public const int WidevineMaxRetries = 3; +} diff --git a/OF DL.Core/Helpers/ExceptionLoggerHelper.cs b/OF DL.Core/Helpers/ExceptionLoggerHelper.cs new file mode 100644 index 0000000..0162e6c --- /dev/null +++ b/OF DL.Core/Helpers/ExceptionLoggerHelper.cs @@ -0,0 +1,26 @@ +using Serilog; + +namespace OF_DL.Helpers; + +internal static class ExceptionLoggerHelper +{ + /// + /// Logs an exception to the console and Serilog with inner exception details. + /// + /// The exception to log. + public static void LogException(Exception ex) + { + Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); + Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); + if (ex.InnerException == null) + { + return; + } + + Console.WriteLine("\nInner Exception:"); + Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, + ex.InnerException.StackTrace); + Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, + ex.InnerException.StackTrace); + } +} diff --git a/OF DL/Helpers/VersionHelper.cs b/OF DL.Core/Helpers/VersionHelper.cs similarity index 54% rename from OF DL/Helpers/VersionHelper.cs rename to OF DL.Core/Helpers/VersionHelper.cs index dc2b163..593e74c 100644 --- a/OF DL/Helpers/VersionHelper.cs +++ b/OF DL.Core/Helpers/VersionHelper.cs @@ -1,20 +1,20 @@ -using Newtonsoft.Json; -using OF_DL.Entities; +using Newtonsoft.Json; +using OF_DL.Models.OfdlApi; using Serilog; namespace OF_DL.Helpers; public static class VersionHelper { - private static readonly HttpClient httpClient = new HttpClient(); - private const string url = "https://git.ofdl.tools/api/v1/repos/sim0n00ps/OF-DL/releases/latest"; + private const string Url = "https://git.ofdl.tools/api/v1/repos/sim0n00ps/OF-DL/releases/latest"; + private static readonly HttpClient s_httpClient = new(); public static async Task GetLatestReleaseTag(CancellationToken cancellationToken = default) { Log.Debug("Calling GetLatestReleaseTag"); try { - var response = await httpClient.GetAsync(url, cancellationToken); + HttpResponseMessage response = await s_httpClient.GetAsync(Url, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -22,20 +22,20 @@ public static class VersionHelper return null; } - var body = await response.Content.ReadAsStringAsync(); + string body = await response.Content.ReadAsStringAsync(cancellationToken); - Log.Debug("GetLatestReleaseTag API Response: "); - Log.Debug(body); + Log.Debug("GetLatestReleaseTag API Response: {Body}", body); - var versionCheckResponse = JsonConvert.DeserializeObject(body); + LatestReleaseApiResponse? versionCheckResponse = + JsonConvert.DeserializeObject(body); - if (versionCheckResponse == null || versionCheckResponse.TagName == "") + if (versionCheckResponse != null && versionCheckResponse.TagName != "") { - Log.Debug("GetLatestReleaseTag did not return a valid tag name"); - return null; + return versionCheckResponse.TagName; } - return versionCheckResponse.TagName; + Log.Debug("GetLatestReleaseTag did not return a valid tag name"); + return null; } catch (OperationCanceledException) { @@ -48,10 +48,13 @@ public static class VersionHelper if (ex.InnerException != null) { Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); + Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, + ex.InnerException.StackTrace); + Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, + ex.InnerException.StackTrace); } } + return null; } } diff --git a/OF DL.Core/Models/Auth.cs b/OF DL.Core/Models/Auth.cs new file mode 100644 index 0000000..0788fbd --- /dev/null +++ b/OF DL.Core/Models/Auth.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models; + +public class Auth +{ + [JsonProperty(PropertyName = "USER_ID")] + public string? UserId { get; set; } = ""; + + [JsonProperty(PropertyName = "USER_AGENT")] + public string? UserAgent { get; set; } = ""; + + [JsonProperty(PropertyName = "X_BC")] public string? XBc { get; set; } = ""; + + [JsonProperty(PropertyName = "COOKIE")] + public string? Cookie { get; set; } = ""; + + [JsonIgnore] + [JsonProperty(PropertyName = "FFMPEG_PATH")] + public string? FfmpegPath { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Config/Config.cs b/OF DL.Core/Models/Config/Config.cs new file mode 100644 index 0000000..c6d53fc --- /dev/null +++ b/OF DL.Core/Models/Config/Config.cs @@ -0,0 +1,155 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using OF_DL.Enumerations; +using Serilog; + +namespace OF_DL.Models.Config; + +public class Config : IFileNameFormatConfig +{ + [ToggleableConfig] public bool DownloadAvatarHeaderPhoto { get; set; } = true; + + [ToggleableConfig] public bool DownloadPaidPosts { get; set; } = true; + + [ToggleableConfig] public bool DownloadPosts { get; set; } = true; + + [ToggleableConfig] public bool DownloadArchived { get; set; } = true; + + [ToggleableConfig] public bool DownloadStreams { get; set; } = true; + + [ToggleableConfig] public bool DownloadStories { get; set; } = true; + + [ToggleableConfig] public bool DownloadHighlights { get; set; } = true; + + [ToggleableConfig] public bool DownloadMessages { get; set; } = true; + + [ToggleableConfig] public bool DownloadPaidMessages { get; set; } = true; + + [ToggleableConfig] public bool DownloadImages { get; set; } = true; + + [ToggleableConfig] public bool DownloadVideos { get; set; } = true; + + [ToggleableConfig] public bool DownloadAudios { get; set; } = true; + + [ToggleableConfig] public bool IncludeExpiredSubscriptions { get; set; } + + [ToggleableConfig] public bool IncludeRestrictedSubscriptions { get; set; } + + [ToggleableConfig] public bool SkipAds { get; set; } + + public string? DownloadPath { get; set; } = ""; + + [ToggleableConfig] public bool RenameExistingFilesWhenCustomFormatIsSelected { get; set; } + + public int? Timeout { get; set; } = -1; + + [ToggleableConfig] public bool FolderPerPaidPost { get; set; } + + [ToggleableConfig] public bool FolderPerPost { get; set; } + + [ToggleableConfig] public bool FolderPerPaidMessage { get; set; } + + [ToggleableConfig] public bool FolderPerMessage { get; set; } + + [ToggleableConfig] public bool LimitDownloadRate { get; set; } + + public int DownloadLimitInMbPerSec { get; set; } = 4; + + // Indicates if you want to download only on specific dates. + [ToggleableConfig] public bool DownloadOnlySpecificDates { get; set; } + + // This enum will define if we want data from before or after the CustomDate. + [JsonConverter(typeof(StringEnumConverter))] + public DownloadDateSelection DownloadDateSelection { get; set; } = DownloadDateSelection.before; + // This is the specific date used in combination with the above enum. + + [JsonConverter(typeof(ShortDateConverter))] + public DateTime? CustomDate { get; set; } = null; + + [ToggleableConfig] public bool ShowScrapeSize { get; set; } + + [ToggleableConfig] public bool DownloadPostsIncrementally { get; set; } + + public bool NonInteractiveMode { get; set; } + public string NonInteractiveModeListName { get; set; } = ""; + + [ToggleableConfig] public bool NonInteractiveModePurchasedTab { get; set; } + + public string? FFmpegPath { get; set; } = ""; + + [ToggleableConfig] public bool BypassContentForCreatorsWhoNoLongerExist { get; set; } + + public Dictionary CreatorConfigs { get; set; } = new(); + + [ToggleableConfig] public bool DownloadDuplicatedMedia { get; set; } + + public string IgnoredUsersListName { get; set; } = ""; + + [JsonConverter(typeof(StringEnumConverter))] + public LoggingLevel LoggingLevel { get; set; } = LoggingLevel.Error; + + [ToggleableConfig] public bool IgnoreOwnMessages { get; set; } + + [ToggleableConfig] public bool DisableBrowserAuth { get; set; } + + [JsonConverter(typeof(StringEnumConverter))] + public VideoResolution DownloadVideoResolution { get; set; } = VideoResolution.source; + + // When enabled, post/message text is stored as-is without XML stripping. + [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) + { + FileNameFormatConfig combinedFilenameFormatConfig = new() + { + PaidPostFileNameFormat = PaidPostFileNameFormat, + PostFileNameFormat = PostFileNameFormat, + PaidMessageFileNameFormat = PaidMessageFileNameFormat, + MessageFileNameFormat = MessageFileNameFormat + }; + + if (CreatorConfigs.TryGetValue(username, out CreatorConfig? creatorConfig)) + { + if (creatorConfig.PaidPostFileNameFormat != null) + { + combinedFilenameFormatConfig.PaidPostFileNameFormat = creatorConfig.PaidPostFileNameFormat; + } + + if (creatorConfig.PostFileNameFormat != null) + { + combinedFilenameFormatConfig.PostFileNameFormat = creatorConfig.PostFileNameFormat; + } + + if (creatorConfig.PaidMessageFileNameFormat != null) + { + combinedFilenameFormatConfig.PaidMessageFileNameFormat = creatorConfig.PaidMessageFileNameFormat; + } + + if (creatorConfig.MessageFileNameFormat != null) + { + combinedFilenameFormatConfig.MessageFileNameFormat = creatorConfig.MessageFileNameFormat; + } + } + + Log.Debug("PaidMessageFilenameFormat: {CombinedConfigPaidMessageFileNameFormat}", + combinedFilenameFormatConfig.PaidMessageFileNameFormat); + Log.Debug("PostFileNameFormat: {CombinedConfigPostFileNameFormat}", + combinedFilenameFormatConfig.PostFileNameFormat); + Log.Debug("MessageFileNameFormat: {CombinedConfigMessageFileNameFormat}", + combinedFilenameFormatConfig.MessageFileNameFormat); + Log.Debug("PaidPostFileNameFormat: {CombinedConfigPaidPostFileNameFormat}", + combinedFilenameFormatConfig.PaidPostFileNameFormat); + + return combinedFilenameFormatConfig; + } + + private class ShortDateConverter : IsoDateTimeConverter + { + public ShortDateConverter() => DateTimeFormat = "yyyy-MM-dd"; + } +} diff --git a/OF DL.Core/Models/Config/CreatorConfig.cs b/OF DL.Core/Models/Config/CreatorConfig.cs new file mode 100644 index 0000000..89ba399 --- /dev/null +++ b/OF DL.Core/Models/Config/CreatorConfig.cs @@ -0,0 +1,12 @@ +namespace OF_DL.Models.Config; + +public class CreatorConfig : IFileNameFormatConfig +{ + public string? PaidPostFileNameFormat { get; set; } + + public string? PostFileNameFormat { get; set; } + + public string? PaidMessageFileNameFormat { get; set; } + + public string? MessageFileNameFormat { get; set; } +} diff --git a/OF DL.Core/Models/Config/FileNameFormatConfig.cs b/OF DL.Core/Models/Config/FileNameFormatConfig.cs new file mode 100644 index 0000000..2a14123 --- /dev/null +++ b/OF DL.Core/Models/Config/FileNameFormatConfig.cs @@ -0,0 +1,12 @@ +namespace OF_DL.Models.Config; + +public class FileNameFormatConfig : IFileNameFormatConfig +{ + public string? PaidPostFileNameFormat { get; set; } + + public string? PostFileNameFormat { get; set; } + + public string? PaidMessageFileNameFormat { get; set; } + + public string? MessageFileNameFormat { get; set; } +} diff --git a/OF DL.Core/Models/Config/IFileNameFormatConfig.cs b/OF DL.Core/Models/Config/IFileNameFormatConfig.cs new file mode 100644 index 0000000..5800f88 --- /dev/null +++ b/OF DL.Core/Models/Config/IFileNameFormatConfig.cs @@ -0,0 +1,12 @@ +namespace OF_DL.Models.Config; + +public interface IFileNameFormatConfig +{ + string? PaidPostFileNameFormat { get; set; } + + string? PostFileNameFormat { get; set; } + + string? PaidMessageFileNameFormat { get; set; } + + string? MessageFileNameFormat { get; set; } +} diff --git a/OF DL.Core/Models/Config/ToggleableConfigAttribute.cs b/OF DL.Core/Models/Config/ToggleableConfigAttribute.cs new file mode 100644 index 0000000..4adeba0 --- /dev/null +++ b/OF DL.Core/Models/Config/ToggleableConfigAttribute.cs @@ -0,0 +1,4 @@ +namespace OF_DL.Models.Config; + +[AttributeUsage(AttributeTargets.Property)] +internal class ToggleableConfigAttribute : Attribute; diff --git a/OF DL.Core/Models/Downloads/CreatorDownloadResult.cs b/OF DL.Core/Models/Downloads/CreatorDownloadResult.cs new file mode 100644 index 0000000..d686e1b --- /dev/null +++ b/OF DL.Core/Models/Downloads/CreatorDownloadResult.cs @@ -0,0 +1,29 @@ +namespace OF_DL.Models.Downloads; + +public class CreatorDownloadResult +{ + public int PaidPostCount { get; set; } + + public int PostCount { get; set; } + + public int ArchivedCount { get; set; } + + public int StreamsCount { get; set; } + + public int StoriesCount { get; set; } + + public int HighlightsCount { get; set; } + + public int MessagesCount { get; set; } + + public int PaidMessagesCount { get; set; } +} + +public class UserListResult +{ + public Dictionary Users { get; set; } = new(); + + public Dictionary Lists { get; set; } = new(); + + public string? IgnoredListError { get; set; } +} diff --git a/OF DL.Core/Models/Downloads/DownloadResult.cs b/OF DL.Core/Models/Downloads/DownloadResult.cs new file mode 100644 index 0000000..a38b314 --- /dev/null +++ b/OF DL.Core/Models/Downloads/DownloadResult.cs @@ -0,0 +1,37 @@ +namespace OF_DL.Models.Downloads; + +/// +/// Represents the result of a download operation. +/// +public class DownloadResult +{ + /// + /// Total number of media items processed. + /// + public int TotalCount { get; set; } + + /// + /// Number of newly downloaded media items. + /// + public int NewDownloads { get; set; } + + /// + /// Number of media items that were already downloaded. + /// + public int ExistingDownloads { get; set; } + + /// + /// The type of media downloaded (e.g., "Posts", "Messages", "Stories", etc.). + /// + public string MediaType { get; set; } = string.Empty; + + /// + /// Indicates whether the download operation was successful. + /// + public bool Success { get; set; } = true; + + /// + /// Optional error message if the download failed. + /// + public string? ErrorMessage { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Archived/ArchivedDto.cs b/OF DL.Core/Models/Dtos/Archived/ArchivedDto.cs new file mode 100644 index 0000000..b9968d1 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Archived/ArchivedDto.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Archived; + +public class ArchivedDto +{ + [JsonProperty("list")] public List List { get; set; } = []; + + [JsonProperty("hasMore")] public bool HasMore { get; set; } + + [JsonProperty("headMarker")] public string HeadMarker { get; set; } = ""; + + [JsonProperty("tailMarker")] public string TailMarker { get; set; } = ""; + + [JsonProperty("counters")] public CountersDto Counters { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Archived/InfoDto.cs b/OF DL.Core/Models/Dtos/Archived/InfoDto.cs new file mode 100644 index 0000000..36c077a --- /dev/null +++ b/OF DL.Core/Models/Dtos/Archived/InfoDto.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Archived; + +public class InfoDto +{ + [JsonProperty("source")] public SourceDto Source { get; set; } = new(); + + [JsonProperty("preview")] public PreviewDto Preview { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Archived/LinkedPostDto.cs b/OF DL.Core/Models/Dtos/Archived/LinkedPostDto.cs new file mode 100644 index 0000000..ac610fa --- /dev/null +++ b/OF DL.Core/Models/Dtos/Archived/LinkedPostDto.cs @@ -0,0 +1,96 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; +using OF_DL.Utils; + +namespace OF_DL.Models.Dtos.Archived; + +public class LinkedPostDto +{ + private string _rawText = ""; + [JsonProperty("responseType")] public string ResponseType { get; set; } = ""; + + [JsonProperty("id")] public long? Id { get; set; } + + [JsonProperty("postedAt")] public DateTime? PostedAt { get; set; } + + [JsonProperty("postedAtPrecise")] public string PostedAtPrecise { get; set; } = ""; + + [JsonProperty("expiredAt")] public object ExpiredAt { get; set; } = new(); + + [JsonProperty("author")] public AuthorDto Author { get; set; } = new(); + + [JsonProperty("text")] public string Text { get; set; } = ""; + + [JsonProperty("rawText")] + public string RawText + { + get + { + if (string.IsNullOrEmpty(_rawText)) + { + _rawText = XmlUtils.EvaluateInnerText(Text); + } + + return _rawText; + } + set => _rawText = value; + } + + [JsonProperty("lockedText")] public bool? LockedText { get; set; } + + [JsonProperty("isFavorite")] public bool? IsFavorite { get; set; } + + [JsonProperty("canReport")] public bool? CanReport { get; set; } + + [JsonProperty("canDelete")] public bool? CanDelete { get; set; } + + [JsonProperty("canComment")] public bool? CanComment { get; set; } + + [JsonProperty("canEdit")] public bool? CanEdit { get; set; } + + [JsonProperty("isPinned")] public bool? IsPinned { get; set; } + + [JsonProperty("favoritesCount")] public int? FavoritesCount { get; set; } + + [JsonProperty("mediaCount")] public int? MediaCount { get; set; } + + [JsonProperty("isMediaReady")] public bool? IsMediaReady { get; set; } + + [JsonProperty("voting")] public object Voting { get; set; } = new(); + + [JsonProperty("isOpened")] public bool? IsOpened { get; set; } + + [JsonProperty("canToggleFavorite")] public bool? CanToggleFavorite { get; set; } + + [JsonProperty("streamId")] public object StreamId { get; set; } = new(); + + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("hasVoting")] public bool? HasVoting { get; set; } + + [JsonProperty("isAddedToBookmarks")] public bool? IsAddedToBookmarks { get; set; } + + [JsonProperty("isArchived")] public bool? IsArchived { get; set; } + + [JsonProperty("isPrivateArchived")] public bool? IsPrivateArchived { get; set; } + + [JsonProperty("isDeleted")] public bool? IsDeleted { get; set; } + + [JsonProperty("hasUrl")] public bool? HasUrl { get; set; } + + [JsonProperty("isCouplePeopleMedia")] public bool? IsCouplePeopleMedia { get; set; } + + [JsonProperty("cantCommentReason")] public string CantCommentReason { get; set; } = ""; + + [JsonProperty("commentsCount")] public int? CommentsCount { get; set; } + + [JsonProperty("mentionedUsers")] public List MentionedUsers { get; set; } = []; + + [JsonProperty("linkedUsers")] public List LinkedUsers { get; set; } = []; + + [JsonProperty("media")] public List Media { get; set; } = []; + + [JsonProperty("canViewMedia")] public bool? CanViewMedia { get; set; } + + [JsonProperty("preview")] public List Preview { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Archived/ListItemDto.cs b/OF DL.Core/Models/Dtos/Archived/ListItemDto.cs new file mode 100644 index 0000000..808202f --- /dev/null +++ b/OF DL.Core/Models/Dtos/Archived/ListItemDto.cs @@ -0,0 +1,97 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; +using OF_DL.Utils; + +namespace OF_DL.Models.Dtos.Archived; + +public class ListItemDto +{ + private string _rawText = ""; + + [JsonProperty("responseType")] public string ResponseType { get; set; } = ""; + + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("postedAt")] public DateTime PostedAt { get; set; } + + [JsonProperty("postedAtPrecise")] public string PostedAtPrecise { get; set; } = ""; + + [JsonProperty("expiredAt")] public object ExpiredAt { get; set; } = new(); + + [JsonProperty("author")] public AuthorDto Author { get; set; } = new(); + + [JsonProperty("text")] public string Text { get; set; } = ""; + + [JsonProperty("rawText")] + public string RawText + { + get + { + if (string.IsNullOrEmpty(_rawText)) + { + _rawText = XmlUtils.EvaluateInnerText(Text); + } + + return _rawText; + } + set => _rawText = value; + } + + [JsonProperty("lockedText")] public bool? LockedText { get; set; } + + [JsonProperty("isFavorite")] public bool? IsFavorite { get; set; } + + [JsonProperty("canReport")] public bool? CanReport { get; set; } + + [JsonProperty("canDelete")] public bool? CanDelete { get; set; } + + [JsonProperty("canComment")] public bool? CanComment { get; set; } + + [JsonProperty("canEdit")] public bool? CanEdit { get; set; } + + [JsonProperty("isPinned")] public bool? IsPinned { get; set; } + + [JsonProperty("favoritesCount")] public int? FavoritesCount { get; set; } + + [JsonProperty("mediaCount")] public int? MediaCount { get; set; } + + [JsonProperty("isMediaReady")] public bool? IsMediaReady { get; set; } + + [JsonProperty("voting")] public object Voting { get; set; } = new(); + + [JsonProperty("isOpened")] public bool IsOpened { get; set; } + + [JsonProperty("canToggleFavorite")] public bool? CanToggleFavorite { get; set; } + + [JsonProperty("streamId")] public object StreamId { get; set; } = new(); + + [JsonProperty("price")] public string Price { get; set; } = ""; + + [JsonProperty("hasVoting")] public bool? HasVoting { get; set; } + + [JsonProperty("isAddedToBookmarks")] public bool? IsAddedToBookmarks { get; set; } + + [JsonProperty("isArchived")] public bool IsArchived { get; set; } + + [JsonProperty("isPrivateArchived")] public bool? IsPrivateArchived { get; set; } + + [JsonProperty("isDeleted")] public bool? IsDeleted { get; set; } + + [JsonProperty("hasUrl")] public bool? HasUrl { get; set; } + + [JsonProperty("isCouplePeopleMedia")] public bool? IsCouplePeopleMedia { get; set; } + + [JsonProperty("commentsCount")] public int? CommentsCount { get; set; } + + [JsonProperty("mentionedUsers")] public List MentionedUsers { get; set; } = []; + + [JsonProperty("linkedUsers")] public List LinkedUsers { get; set; } = []; + + [JsonProperty("media")] public List Media { get; set; } = []; + + [JsonProperty("canViewMedia")] public bool? CanViewMedia { get; set; } + + [JsonProperty("preview")] public List Preview { get; set; } = []; + + [JsonProperty("cantCommentReason")] public string CantCommentReason { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Archived/MediumDto.cs b/OF DL.Core/Models/Dtos/Archived/MediumDto.cs new file mode 100644 index 0000000..bcbd99a --- /dev/null +++ b/OF DL.Core/Models/Dtos/Archived/MediumDto.cs @@ -0,0 +1,35 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Archived; + +public class MediumDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("convertedToVideo")] public bool? ConvertedToVideo { get; set; } + + [JsonProperty("canView")] public bool CanView { get; set; } + + [JsonProperty("hasError")] public bool? HasError { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } = new(); + + [JsonProperty("info")] public InfoDto Info { get; set; } = new(); + + [JsonProperty("source")] public SourceDto Source { get; set; } = new(); + + [JsonProperty("squarePreview")] public string SquarePreview { get; set; } = ""; + + [JsonProperty("full")] public string Full { get; set; } = ""; + + [JsonProperty("preview")] public string Preview { get; set; } = ""; + + [JsonProperty("thumb")] public string Thumb { get; set; } = ""; + + [JsonProperty("files")] public FilesDto Files { get; set; } = new(); + + [JsonProperty("videoSources")] public VideoSourcesDto VideoSources { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Common/AuthorDto.cs b/OF DL.Core/Models/Dtos/Common/AuthorDto.cs new file mode 100644 index 0000000..2857aca --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/AuthorDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class AuthorDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("_view")] public string View { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Common/AvatarThumbsDto.cs b/OF DL.Core/Models/Dtos/Common/AvatarThumbsDto.cs new file mode 100644 index 0000000..ab37032 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/AvatarThumbsDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class AvatarThumbsDto +{ + [JsonProperty("c50")] public string C50 { get; set; } = ""; + + [JsonProperty("c144")] public string C144 { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Common/CountersDto.cs b/OF DL.Core/Models/Dtos/Common/CountersDto.cs new file mode 100644 index 0000000..fe3c3b4 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/CountersDto.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class CountersDto +{ + [JsonProperty("audiosCount")] public int? AudiosCount { get; set; } + + [JsonProperty("photosCount")] public int? PhotosCount { get; set; } + + [JsonProperty("videosCount")] public int? VideosCount { get; set; } + + [JsonProperty("mediasCount")] public int? MediasCount { get; set; } + + [JsonProperty("postsCount")] public int? PostsCount { get; set; } + + [JsonProperty("streamsCount")] public int? StreamsCount { get; set; } + + [JsonProperty("archivedPostsCount")] public int? ArchivedPostsCount { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Common/DashDto.cs b/OF DL.Core/Models/Dtos/Common/DashDto.cs new file mode 100644 index 0000000..a3f8a1a --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/DashDto.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class DashDto +{ + [JsonProperty("CloudFront-Policy")] public string CloudFrontPolicy { get; set; } = ""; + + [JsonProperty("CloudFront-Signature")] public string CloudFrontSignature { get; set; } = ""; + + [JsonProperty("CloudFront-Key-Pair-Id")] + public string CloudFrontKeyPairId { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Common/DrmDto.cs b/OF DL.Core/Models/Dtos/Common/DrmDto.cs new file mode 100644 index 0000000..1290908 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/DrmDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class DrmDto +{ + [JsonProperty("manifest")] public ManifestDto Manifest { get; set; } = new(); + + [JsonProperty("signature")] public SignatureDto Signature { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Common/FilesDto.cs b/OF DL.Core/Models/Dtos/Common/FilesDto.cs new file mode 100644 index 0000000..c7b9056 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/FilesDto.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + + +namespace OF_DL.Models.Dtos.Common; + +public class FilesDto +{ + [JsonProperty("full")] public FullDto Full { get; set; } = new(); + + [JsonProperty("thumb")] public ThumbDto Thumb { get; set; } = new(); + + [JsonProperty("preview")] public PreviewDto Preview { get; set; } = new(); + + [JsonProperty("squarePreview")] public SquarePreviewDto SquarePreview { get; set; } = new(); + + [JsonProperty("drm")] public DrmDto? Drm { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Common/FullDto.cs b/OF DL.Core/Models/Dtos/Common/FullDto.cs new file mode 100644 index 0000000..fd21dff --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/FullDto.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class FullDto +{ + [JsonProperty("url")] public string Url { get; set; } = ""; + + [JsonProperty("width")] public int Width { get; set; } + + [JsonProperty("height")] public int Height { get; set; } + + [JsonProperty("size")] public long Size { get; set; } + + [JsonProperty("sources")] public List Sources { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Common/HeaderSizeDto.cs b/OF DL.Core/Models/Dtos/Common/HeaderSizeDto.cs new file mode 100644 index 0000000..3e09784 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/HeaderSizeDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class HeaderSizeDto +{ + [JsonProperty("width")] public int Width { get; set; } + + [JsonProperty("height")] public int Height { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Common/HeaderThumbsDto.cs b/OF DL.Core/Models/Dtos/Common/HeaderThumbsDto.cs new file mode 100644 index 0000000..9e4b763 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/HeaderThumbsDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class HeaderThumbsDto +{ + [JsonProperty("w480")] public string W480 { get; set; } = ""; + + [JsonProperty("w760")] public string W760 { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Common/HlsDto.cs b/OF DL.Core/Models/Dtos/Common/HlsDto.cs new file mode 100644 index 0000000..36e65b1 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/HlsDto.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class HlsDto +{ + [JsonProperty("CloudFront-Policy")] public string CloudFrontPolicy { get; set; } = ""; + + [JsonProperty("CloudFront-Signature")] public string CloudFrontSignature { get; set; } = ""; + + [JsonProperty("CloudFront-Key-Pair-Id")] + public string CloudFrontKeyPairId { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Common/ManifestDto.cs b/OF DL.Core/Models/Dtos/Common/ManifestDto.cs new file mode 100644 index 0000000..9e19ad4 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/ManifestDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class ManifestDto +{ + [JsonProperty("hls")] public string Hls { get; set; } = ""; + + [JsonProperty("dash")] public string Dash { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Common/PreviewDto.cs b/OF DL.Core/Models/Dtos/Common/PreviewDto.cs new file mode 100644 index 0000000..c33df6b --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/PreviewDto.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class PreviewDto +{ + [JsonProperty("width")] public int? Width { get; set; } + + [JsonProperty("height")] public int? Height { get; set; } + + [JsonProperty("size")] public int? Size { get; set; } + + [JsonProperty("url")] public string Url { get; set; } = ""; + + [JsonProperty("sources")] public SourcesDto Sources { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Common/SignatureDto.cs b/OF DL.Core/Models/Dtos/Common/SignatureDto.cs new file mode 100644 index 0000000..bfc0374 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/SignatureDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class SignatureDto +{ + [JsonProperty("hls")] public HlsDto Hls { get; set; } = new(); + + [JsonProperty("dash")] public DashDto Dash { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Common/SourceDto.cs b/OF DL.Core/Models/Dtos/Common/SourceDto.cs new file mode 100644 index 0000000..a61fd1d --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/SourceDto.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class SourceDto +{ + [JsonProperty("url")] public string Url { get; set; } = ""; + + [JsonProperty("width")] public int Width { get; set; } + + [JsonProperty("height")] public int Height { get; set; } + + [JsonProperty("duration")] public int Duration { get; set; } + + [JsonProperty("size")] public long Size { get; set; } + + [JsonProperty("sources")] public SourcesDto Sources { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Common/SourcesDto.cs b/OF DL.Core/Models/Dtos/Common/SourcesDto.cs new file mode 100644 index 0000000..f2d5d17 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/SourcesDto.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class SourcesDto +{ + [JsonProperty("720")] public string _720 { get; set; } = ""; + + [JsonProperty("240")] public string _240 { get; set; } = ""; + + [JsonProperty("w150")] public string W150 { get; set; } = ""; + + [JsonProperty("w480")] public string W480 { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Common/SquarePreviewDto.cs b/OF DL.Core/Models/Dtos/Common/SquarePreviewDto.cs new file mode 100644 index 0000000..d5f8f82 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/SquarePreviewDto.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class SquarePreviewDto +{ + [JsonProperty("url")] public string Url { get; set; } = ""; + + [JsonProperty("width")] public int Width { get; set; } + + [JsonProperty("height")] public int Height { get; set; } + + [JsonProperty("size")] public long Size { get; set; } + + [JsonProperty("sources")] public SourcesDto Sources { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Common/SubscribedByDataDto.cs b/OF DL.Core/Models/Dtos/Common/SubscribedByDataDto.cs new file mode 100644 index 0000000..f1bc4d9 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/SubscribedByDataDto.cs @@ -0,0 +1,44 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Subscriptions; + +namespace OF_DL.Models.Dtos.Common; + +public class SubscribedByDataDto +{ + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("newPrice")] public string? NewPrice { get; set; } + + [JsonProperty("regularPrice")] public string? RegularPrice { get; set; } + + [JsonProperty("subscribePrice")] public string? SubscribePrice { get; set; } + + [JsonProperty("discountPercent")] public int? DiscountPercent { get; set; } + + [JsonProperty("discountPeriod")] public int? DiscountPeriod { get; set; } + + [JsonProperty("subscribeAt")] public DateTime? SubscribeAt { get; set; } + + [JsonProperty("expiredAt")] public DateTime? ExpiredAt { get; set; } + + [JsonProperty("renewedAt")] public DateTime? RenewedAt { get; set; } + + [JsonProperty("discountFinishedAt")] public object? DiscountFinishedAt { get; set; } = new(); + + [JsonProperty("discountStartedAt")] public object? DiscountStartedAt { get; set; } = new(); + + [JsonProperty("status")] public string Status { get; set; } = ""; + + [JsonProperty("isMuted")] public bool? IsMuted { get; set; } + + [JsonProperty("unsubscribeReason")] public string UnsubscribeReason { get; set; } = ""; + + [JsonProperty("duration")] public string Duration { get; set; } = ""; + + [JsonProperty("showPostsInFeed")] public bool? ShowPostsInFeed { get; set; } + + [JsonProperty("subscribes")] public List Subscribes { get; set; } = []; + + [JsonProperty("hasActivePaidSubscriptions")] + public bool? HasActivePaidSubscriptions { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Common/SubscribedOnDataDto.cs b/OF DL.Core/Models/Dtos/Common/SubscribedOnDataDto.cs new file mode 100644 index 0000000..b0c0cee --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/SubscribedOnDataDto.cs @@ -0,0 +1,54 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Subscriptions; + +namespace OF_DL.Models.Dtos.Common; + +public class SubscribedOnDataDto +{ + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("newPrice")] public string? NewPrice { get; set; } + + [JsonProperty("regularPrice")] public string? RegularPrice { get; set; } + + [JsonProperty("subscribePrice")] public string? SubscribePrice { get; set; } + + [JsonProperty("discountPercent")] public int? DiscountPercent { get; set; } + + [JsonProperty("discountPeriod")] public int? DiscountPeriod { get; set; } + + [JsonProperty("subscribeAt")] public DateTime? SubscribeAt { get; set; } + + [JsonProperty("expiredAt")] public DateTime? ExpiredAt { get; set; } + + [JsonProperty("renewedAt")] public DateTime? RenewedAt { get; set; } + + [JsonProperty("discountFinishedAt")] public object? DiscountFinishedAt { get; set; } = new(); + + [JsonProperty("discountStartedAt")] public object? DiscountStartedAt { get; set; } = new(); + + [JsonProperty("status")] public object? Status { get; set; } + + [JsonProperty("isMuted")] public bool? IsMuted { get; set; } + + [JsonProperty("unsubscribeReason")] public string? UnsubscribeReason { get; set; } = ""; + + [JsonProperty("duration")] public string Duration { get; set; } = ""; + + [JsonProperty("tipsSumm")] public string? TipsSumm { get; set; } + + [JsonProperty("subscribesSumm")] public string? SubscribesSumm { get; set; } + + [JsonProperty("messagesSumm")] public string? MessagesSumm { get; set; } + + [JsonProperty("postsSumm")] public string? PostsSumm { get; set; } + + [JsonProperty("streamsSumm")] public string? StreamsSumm { get; set; } + + [JsonProperty("totalSumm")] public string? TotalSumm { get; set; } + + [JsonProperty("subscribes")] public List Subscribes { get; set; } = []; + + [JsonProperty("hasActivePaidSubscriptions")] + public bool? HasActivePaidSubscriptions { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Common/ThumbDto.cs b/OF DL.Core/Models/Dtos/Common/ThumbDto.cs new file mode 100644 index 0000000..2257499 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/ThumbDto.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class ThumbDto +{ + [JsonProperty("url")] public string Url { get; set; } = ""; + + [JsonProperty("width")] public int Width { get; set; } + + [JsonProperty("height")] public int Height { get; set; } + + [JsonProperty("size")] public long Size { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Common/VideoDto.cs b/OF DL.Core/Models/Dtos/Common/VideoDto.cs new file mode 100644 index 0000000..003ea73 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/VideoDto.cs @@ -0,0 +1,8 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class VideoDto +{ + [JsonProperty("mp4")] public string Mp4 { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Common/VideoSourcesDto.cs b/OF DL.Core/Models/Dtos/Common/VideoSourcesDto.cs new file mode 100644 index 0000000..0596204 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Common/VideoSourcesDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Common; + +public class VideoSourcesDto +{ + [JsonProperty("720")] public string _720 { get; set; } = ""; + + [JsonProperty("240")] public string _240 { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Highlights/HighlightMediaDto.cs b/OF DL.Core/Models/Dtos/Highlights/HighlightMediaDto.cs new file mode 100644 index 0000000..1ceba1d --- /dev/null +++ b/OF DL.Core/Models/Dtos/Highlights/HighlightMediaDto.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Highlights; + +public class HighlightMediaDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("userId")] public long UserId { get; set; } + + [JsonProperty("title")] public string Title { get; set; } = ""; + + [JsonProperty("coverStoryId")] public long CoverStoryId { get; set; } + + [JsonProperty("cover")] public string Cover { get; set; } = ""; + + [JsonProperty("storiesCount")] public int StoriesCount { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("stories")] public List Stories { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Highlights/HighlightsDto.cs b/OF DL.Core/Models/Dtos/Highlights/HighlightsDto.cs new file mode 100644 index 0000000..1178828 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Highlights/HighlightsDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Highlights; + +public class HighlightsDto +{ + [JsonProperty("list")] public List List { get; set; } = []; + + [JsonProperty("hasMore")] public bool HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Highlights/ListItemDto.cs b/OF DL.Core/Models/Dtos/Highlights/ListItemDto.cs new file mode 100644 index 0000000..ffeecd7 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Highlights/ListItemDto.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Highlights; + +public class ListItemDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("userId")] public long UserId { get; set; } + + [JsonProperty("title")] public string Title { get; set; } = ""; + + [JsonProperty("coverStoryId")] public long CoverStoryId { get; set; } + + [JsonProperty("cover")] public string Cover { get; set; } = ""; + + [JsonProperty("storiesCount")] public int StoriesCount { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Highlights/MediumDto.cs b/OF DL.Core/Models/Dtos/Highlights/MediumDto.cs new file mode 100644 index 0000000..cf6bc4c --- /dev/null +++ b/OF DL.Core/Models/Dtos/Highlights/MediumDto.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Highlights; + +public class MediumDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("convertedToVideo")] public bool ConvertedToVideo { get; set; } + + [JsonProperty("canView")] public bool CanView { get; set; } + + [JsonProperty("hasError")] public bool HasError { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("files")] public FilesDto Files { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Highlights/StoryDto.cs b/OF DL.Core/Models/Dtos/Highlights/StoryDto.cs new file mode 100644 index 0000000..053215a --- /dev/null +++ b/OF DL.Core/Models/Dtos/Highlights/StoryDto.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Highlights; + +public class StoryDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("userId")] public long UserId { get; set; } + + [JsonProperty("isWatched")] public bool IsWatched { get; set; } + + [JsonProperty("isReady")] public bool IsReady { get; set; } + + [JsonProperty("media")] public List Media { get; set; } = []; + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("question")] public object Question { get; set; } = new(); + + [JsonProperty("canLike")] public bool CanLike { get; set; } + + [JsonProperty("isLiked")] public bool IsLiked { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Lists/HeaderSizeDto.cs b/OF DL.Core/Models/Dtos/Lists/HeaderSizeDto.cs new file mode 100644 index 0000000..4e37f8c --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/HeaderSizeDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class HeaderSizeDto +{ + [JsonProperty("width")] public int? Width { get; set; } + + [JsonProperty("height")] public int? Height { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Lists/HeaderThumbsDto.cs b/OF DL.Core/Models/Dtos/Lists/HeaderThumbsDto.cs new file mode 100644 index 0000000..8ddbbe3 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/HeaderThumbsDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class HeaderThumbsDto +{ + [JsonProperty("w480")] public string W480 { get; set; } = ""; + + [JsonProperty("w760")] public string W760 { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Lists/ListsStateDto.cs b/OF DL.Core/Models/Dtos/Lists/ListsStateDto.cs new file mode 100644 index 0000000..375e9a2 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/ListsStateDto.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class ListsStateDto +{ + [JsonProperty("id")] public string Id { get; set; } = ""; + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("name")] public string Name { get; set; } = ""; + + [JsonProperty("hasUser")] public bool HasUser { get; set; } + + [JsonProperty("canAddUser")] public bool CanAddUser { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Lists/SubscribeDto.cs b/OF DL.Core/Models/Dtos/Lists/SubscribeDto.cs new file mode 100644 index 0000000..bb7c0b7 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/SubscribeDto.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class SubscribeDto +{ + [JsonProperty("id")] public object Id { get; set; } = new(); + + [JsonProperty("userId")] public long? UserId { get; set; } + + [JsonProperty("subscriberId")] public int? SubscriberId { get; set; } + + [JsonProperty("date")] public DateTime? Date { get; set; } + + [JsonProperty("duration")] public int? Duration { get; set; } + + [JsonProperty("startDate")] public DateTime? StartDate { get; set; } + + [JsonProperty("expireDate")] public DateTime? ExpireDate { get; set; } + + [JsonProperty("cancelDate")] public object CancelDate { get; set; } = new(); + + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("regularPrice")] public string? RegularPrice { get; set; } + + [JsonProperty("discount")] public string? Discount { get; set; } + + [JsonProperty("action")] public string Action { get; set; } = ""; + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("offerStart")] public object OfferStart { get; set; } = new(); + + [JsonProperty("offerEnd")] public object OfferEnd { get; set; } = new(); + + [JsonProperty("isCurrent")] public bool? IsCurrent { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Lists/SubscribedByDataDto.cs b/OF DL.Core/Models/Dtos/Lists/SubscribedByDataDto.cs new file mode 100644 index 0000000..8e73fad --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/SubscribedByDataDto.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class SubscribedByDataDto +{ + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("newPrice")] public string? NewPrice { get; set; } + + [JsonProperty("regularPrice")] public string? RegularPrice { get; set; } + + [JsonProperty("subscribePrice")] public string? SubscribePrice { get; set; } + + [JsonProperty("discountPercent")] public string? DiscountPercent { get; set; } + + [JsonProperty("discountPeriod")] public string? DiscountPeriod { get; set; } + + [JsonProperty("subscribeAt")] public DateTime? SubscribeAt { get; set; } + + [JsonProperty("expiredAt")] public DateTime? ExpiredAt { get; set; } + + [JsonProperty("renewedAt")] public object RenewedAt { get; set; } = new(); + + [JsonProperty("discountFinishedAt")] public object DiscountFinishedAt { get; set; } = new(); + + [JsonProperty("discountStartedAt")] public object DiscountStartedAt { get; set; } = new(); + + [JsonProperty("status")] public string Status { get; set; } = ""; + + [JsonProperty("isMuted")] public bool? IsMuted { get; set; } + + [JsonProperty("unsubscribeReason")] public string UnsubscribeReason { get; set; } = ""; + + [JsonProperty("duration")] public string Duration { get; set; } = ""; + + [JsonProperty("showPostsInFeed")] public bool? ShowPostsInFeed { get; set; } + + [JsonProperty("subscribes")] public List Subscribes { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Lists/SubscribedOnDataDto.cs b/OF DL.Core/Models/Dtos/Lists/SubscribedOnDataDto.cs new file mode 100644 index 0000000..ab1ed2f --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/SubscribedOnDataDto.cs @@ -0,0 +1,54 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class SubscribedOnDataDto +{ + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("newPrice")] public string? NewPrice { get; set; } + + [JsonProperty("regularPrice")] public string? RegularPrice { get; set; } + + [JsonProperty("subscribePrice")] public string? SubscribePrice { get; set; } + + [JsonProperty("discountPercent")] public string? DiscountPercent { get; set; } + + [JsonProperty("discountPeriod")] public string? DiscountPeriod { get; set; } + + [JsonProperty("subscribeAt")] public DateTime? SubscribeAt { get; set; } + + [JsonProperty("expiredAt")] public DateTime? ExpiredAt { get; set; } + + [JsonProperty("renewedAt")] public object RenewedAt { get; set; } = new(); + + [JsonProperty("discountFinishedAt")] public object DiscountFinishedAt { get; set; } = new(); + + [JsonProperty("discountStartedAt")] public object DiscountStartedAt { get; set; } = new(); + + [JsonProperty("status")] public object Status { get; set; } = new(); + + [JsonProperty("isMuted")] public bool? IsMuted { get; set; } + + [JsonProperty("unsubscribeReason")] public string UnsubscribeReason { get; set; } = ""; + + [JsonProperty("duration")] public string Duration { get; set; } = ""; + + [JsonProperty("tipsSumm")] public string? TipsSumm { get; set; } + + [JsonProperty("subscribesSumm")] public string? SubscribesSumm { get; set; } + + [JsonProperty("messagesSumm")] public string? MessagesSumm { get; set; } + + [JsonProperty("postsSumm")] public string? PostsSumm { get; set; } + + [JsonProperty("streamsSumm")] public string? StreamsSumm { get; set; } + + [JsonProperty("totalSumm")] public string? TotalSumm { get; set; } + + [JsonProperty("lastActivity")] public DateTime? LastActivity { get; set; } + + [JsonProperty("recommendations")] public int? Recommendations { get; set; } + + [JsonProperty("subscribes")] public List Subscribes { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Lists/SubscriptionBundleDto.cs b/OF DL.Core/Models/Dtos/Lists/SubscriptionBundleDto.cs new file mode 100644 index 0000000..38e9c62 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/SubscriptionBundleDto.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class SubscriptionBundleDto +{ + [JsonProperty("id")] public long? Id { get; set; } + + [JsonProperty("discount")] public string? Discount { get; set; } + + [JsonProperty("duration")] public string? Duration { get; set; } + + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("canBuy")] public bool? CanBuy { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Lists/UserListDto.cs b/OF DL.Core/Models/Dtos/Lists/UserListDto.cs new file mode 100644 index 0000000..ded01cb --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/UserListDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class UserListDto +{ + [JsonProperty("list")] public List List { get; set; } = []; + + [JsonProperty("hasMore")] public bool? HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Lists/UserListItemDto.cs b/OF DL.Core/Models/Dtos/Lists/UserListItemDto.cs new file mode 100644 index 0000000..9b5f7a8 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/UserListItemDto.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class UserListItemDto +{ + [JsonProperty("id")] public string Id { get; set; } = ""; + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("name")] public string Name { get; set; } = ""; + + [JsonProperty("usersCount")] public int? UsersCount { get; set; } + + [JsonProperty("postsCount")] public int? PostsCount { get; set; } + + [JsonProperty("canUpdate")] public bool? CanUpdate { get; set; } + + [JsonProperty("canDelete")] public bool? CanDelete { get; set; } + + [JsonProperty("canManageUsers")] public bool? CanManageUsers { get; set; } + + [JsonProperty("canAddUsers")] public bool? CanAddUsers { get; set; } + + [JsonProperty("canPinnedToFeed")] public bool? CanPinnedToFeed { get; set; } + + [JsonProperty("isPinnedToFeed")] public bool? IsPinnedToFeed { get; set; } + + [JsonProperty("canPinnedToChat")] public bool? CanPinnedToChat { get; set; } + + [JsonProperty("isPinnedToChat")] public bool? IsPinnedToChat { get; set; } + + [JsonProperty("order")] public string Order { get; set; } = ""; + + [JsonProperty("direction")] public string Direction { get; set; } = ""; + + [JsonProperty("users")] public List Users { get; set; } = []; + + [JsonProperty("customOrderUsersIds")] public List CustomOrderUsersIds { get; set; } = []; + + [JsonProperty("posts")] public List Posts { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Lists/UserListUserDto.cs b/OF DL.Core/Models/Dtos/Lists/UserListUserDto.cs new file mode 100644 index 0000000..aaca253 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/UserListUserDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Lists; + +public class UserListUserDto +{ + [JsonProperty("id")] public long? Id { get; set; } + + [JsonProperty("_view")] public string View { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Lists/UsersListDto.cs b/OF DL.Core/Models/Dtos/Lists/UsersListDto.cs new file mode 100644 index 0000000..3885214 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Lists/UsersListDto.cs @@ -0,0 +1,121 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Lists; + +public class UsersListDto +{ + [JsonProperty("view")] public string View { get; set; } = ""; + + [JsonProperty("avatar")] public string Avatar { get; set; } = ""; + + [JsonProperty("avatarThumbs")] public AvatarThumbsDto AvatarThumbs { get; set; } = new(); + + [JsonProperty("header")] public string Header { get; set; } = ""; + + [JsonProperty("headerSize")] public HeaderSizeDto HeaderSize { get; set; } = new(); + + [JsonProperty("headerThumbs")] public HeaderThumbsDto HeaderThumbs { get; set; } = new(); + + [JsonProperty("id")] public long? Id { get; set; } + + [JsonProperty("name")] public string Name { get; set; } = ""; + + [JsonProperty("username")] public string Username { get; set; } = ""; + + [JsonProperty("canLookStory")] public bool? CanLookStory { get; set; } + + [JsonProperty("canCommentStory")] public bool? CanCommentStory { get; set; } + + [JsonProperty("hasNotViewedStory")] public bool? HasNotViewedStory { get; set; } + + [JsonProperty("isVerified")] public bool? IsVerified { get; set; } + + [JsonProperty("canPayInternal")] public bool? CanPayInternal { get; set; } + + [JsonProperty("hasScheduledStream")] public bool? HasScheduledStream { get; set; } + + [JsonProperty("hasStream")] public bool? HasStream { get; set; } + + [JsonProperty("hasStories")] public bool? HasStories { get; set; } + + [JsonProperty("tipsEnabled")] public bool? TipsEnabled { get; set; } + + [JsonProperty("tipsTextEnabled")] public bool? TipsTextEnabled { get; set; } + + [JsonProperty("tipsMin")] public int? TipsMin { get; set; } + + [JsonProperty("tipsMinInternal")] public int? TipsMinInternal { get; set; } + + [JsonProperty("tipsMax")] public int? TipsMax { get; set; } + + [JsonProperty("canEarn")] public bool? CanEarn { get; set; } + + [JsonProperty("canAddSubscriber")] public bool? CanAddSubscriber { get; set; } + + [JsonProperty("subscribePrice")] public string? SubscribePrice { get; set; } + + [JsonProperty("subscriptionBundles")] public List SubscriptionBundles { get; set; } = []; + + [JsonProperty("displayName")] public string DisplayName { get; set; } = ""; + + [JsonProperty("notice")] public string Notice { get; set; } = ""; + + [JsonProperty("isPaywallRequired")] public bool? IsPaywallRequired { get; set; } + + [JsonProperty("unprofitable")] public bool? Unprofitable { get; set; } + + [JsonProperty("listsStates")] public List ListsStates { get; set; } = []; + + [JsonProperty("isMuted")] public bool? IsMuted { get; set; } + + [JsonProperty("isRestricted")] public bool? IsRestricted { get; set; } + + [JsonProperty("canRestrict")] public bool? CanRestrict { get; set; } + + [JsonProperty("subscribedBy")] public bool? SubscribedBy { get; set; } + + [JsonProperty("subscribedByExpire")] public bool? SubscribedByExpire { get; set; } + + [JsonProperty("subscribedByExpireDate")] + public DateTime? SubscribedByExpireDate { get; set; } + + [JsonProperty("subscribedByAutoprolong")] + public bool? SubscribedByAutoprolong { get; set; } + + [JsonProperty("subscribedIsExpiredNow")] + public bool? SubscribedIsExpiredNow { get; set; } + + [JsonProperty("currentSubscribePrice")] + public string? CurrentSubscribePrice { get; set; } + + [JsonProperty("subscribedOn")] public bool? SubscribedOn { get; set; } + + [JsonProperty("subscribedOnExpiredNow")] + public bool? SubscribedOnExpiredNow { get; set; } + + [JsonProperty("subscribedOnDuration")] public string SubscribedOnDuration { get; set; } = ""; + + [JsonProperty("canReport")] public bool? CanReport { get; set; } + + [JsonProperty("canReceiveChatMessage")] + public bool? CanReceiveChatMessage { get; set; } + + [JsonProperty("hideChat")] public bool? HideChat { get; set; } + + [JsonProperty("lastSeen")] public DateTime? LastSeen { get; set; } + + [JsonProperty("isPerformer")] public bool? IsPerformer { get; set; } + + [JsonProperty("isRealPerformer")] public bool? IsRealPerformer { get; set; } + + [JsonProperty("subscribedByData")] public SubscribedByDataDto SubscribedByData { get; set; } = new(); + + [JsonProperty("subscribedOnData")] public SubscribedOnDataDto SubscribedOnData { get; set; } = new(); + + [JsonProperty("canTrialSend")] public bool? CanTrialSend { get; set; } + + [JsonProperty("isBlocked")] public bool? IsBlocked { get; set; } + + [JsonProperty("promoOffers")] public List PromoOffers { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Messages/FromUserDto.cs b/OF DL.Core/Models/Dtos/Messages/FromUserDto.cs new file mode 100644 index 0000000..999b7f6 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Messages/FromUserDto.cs @@ -0,0 +1,98 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Messages; + +public class FromUserDto +{ + [JsonProperty("_view")] public string ViewRaw { get; set; } = ""; + + [JsonProperty("view")] public string View { get; set; } = ""; + + [JsonProperty("avatar")] public string Avatar { get; set; } = ""; + + [JsonProperty("avatarThumbs")] public AvatarThumbsDto AvatarThumbs { get; set; } = new(); + + [JsonProperty("header")] public string Header { get; set; } = ""; + + [JsonProperty("headerSize")] public HeaderSizeDto HeaderSize { get; set; } = new(); + + [JsonProperty("headerThumbs")] public HeaderThumbsDto HeaderThumbs { get; set; } = new(); + + [JsonProperty("id")] public long? Id { get; set; } + + [JsonProperty("name")] public string Name { get; set; } = ""; + + [JsonProperty("username")] public string Username { get; set; } = ""; + + [JsonProperty("canLookStory")] public bool CanLookStory { get; set; } + + [JsonProperty("canCommentStory")] public bool CanCommentStory { get; set; } + + [JsonProperty("hasNotViewedStory")] public bool HasNotViewedStory { get; set; } + + [JsonProperty("isVerified")] public bool IsVerified { get; set; } + + [JsonProperty("canPayInternal")] public bool CanPayInternal { get; set; } + + [JsonProperty("hasScheduledStream")] public bool HasScheduledStream { get; set; } + + [JsonProperty("hasStream")] public bool HasStream { get; set; } + + [JsonProperty("hasStories")] public bool HasStories { get; set; } + + [JsonProperty("tipsEnabled")] public bool TipsEnabled { get; set; } + + [JsonProperty("tipsTextEnabled")] public bool TipsTextEnabled { get; set; } + + [JsonProperty("tipsMin")] public int TipsMin { get; set; } + + [JsonProperty("tipsMinInternal")] public int TipsMinInternal { get; set; } + + [JsonProperty("tipsMax")] public int TipsMax { get; set; } + + [JsonProperty("canEarn")] public bool CanEarn { get; set; } + + [JsonProperty("canAddSubscriber")] public bool CanAddSubscriber { get; set; } + + [JsonProperty("subscribePrice")] public string SubscribePrice { get; set; } = ""; + + [JsonProperty("subscriptionBundles")] public List SubscriptionBundles { get; set; } = []; + + [JsonProperty("isPaywallRequired")] public bool IsPaywallRequired { get; set; } + + [JsonProperty("listsStates")] public List ListsStates { get; set; } = []; + + [JsonProperty("isRestricted")] public bool IsRestricted { get; set; } + + [JsonProperty("canRestrict")] public bool CanRestrict { get; set; } + + [JsonProperty("subscribedBy")] public object SubscribedBy { get; set; } = new(); + + [JsonProperty("subscribedByExpire")] public object SubscribedByExpire { get; set; } = new(); + + [JsonProperty("subscribedByExpireDate")] + public DateTime? SubscribedByExpireDate { get; set; } + + [JsonProperty("subscribedByAutoprolong")] + public object SubscribedByAutoprolong { get; set; } = new(); + + [JsonProperty("subscribedIsExpiredNow")] + public bool SubscribedIsExpiredNow { get; set; } + + [JsonProperty("currentSubscribePrice")] + public object CurrentSubscribePrice { get; set; } = new(); + + [JsonProperty("subscribedOn")] public object SubscribedOn { get; set; } = new(); + + [JsonProperty("subscribedOnExpiredNow")] + public object SubscribedOnExpiredNow { get; set; } = new(); + + [JsonProperty("subscribedOnDuration")] public object SubscribedOnDuration { get; set; } = new(); + + [JsonProperty("callPrice")] public int CallPrice { get; set; } + + [JsonProperty("lastSeen")] public DateTime? LastSeen { get; set; } + + [JsonProperty("canReport")] public bool CanReport { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Messages/InfoDto.cs b/OF DL.Core/Models/Dtos/Messages/InfoDto.cs new file mode 100644 index 0000000..30b3ceb --- /dev/null +++ b/OF DL.Core/Models/Dtos/Messages/InfoDto.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Messages; + +public class InfoDto +{ + [JsonProperty("source")] public SourceDto Source { get; set; } = new(); + + [JsonProperty("preview")] public PreviewDto Preview { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Messages/ListItemDto.cs b/OF DL.Core/Models/Dtos/Messages/ListItemDto.cs new file mode 100644 index 0000000..6b414d7 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Messages/ListItemDto.cs @@ -0,0 +1,66 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Messages; + +public class ListItemDto +{ + [JsonProperty("responseType")] public string ResponseType { get; set; } = ""; + + [JsonProperty("text")] public string Text { get; set; } = ""; + + [JsonProperty("giphyId")] public object GiphyId { get; set; } = new(); + + [JsonProperty("lockedText")] public bool? LockedText { get; set; } + + [JsonProperty("isFree")] public bool? IsFree { get; set; } + + [JsonProperty("price")] public string Price { get; set; } = ""; + + [JsonProperty("isMediaReady")] public bool? IsMediaReady { get; set; } + + [JsonProperty("mediaCount")] public int? MediaCount { get; set; } + + [JsonProperty("media")] public List Media { get; set; } = []; + + [JsonProperty("previews")] public List Previews { get; set; } = []; + + [JsonProperty("isTip")] public bool? IsTip { get; set; } + + [JsonProperty("isReportedByMe")] public bool? IsReportedByMe { get; set; } + + [JsonProperty("isCouplePeopleMedia")] public bool? IsCouplePeopleMedia { get; set; } + + [JsonProperty("queueId")] public object QueueId { get; set; } = new(); + + [JsonProperty("fromUser")] public FromUserDto FromUser { get; set; } = new(); + + [JsonProperty("isFromQueue")] public bool? IsFromQueue { get; set; } + + [JsonProperty("canUnsendQueue")] public bool? CanUnsendQueue { get; set; } + + [JsonProperty("unsendSecondsQueue")] public int? UnsendSecondsQueue { get; set; } + + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("isOpened")] public bool? IsOpened { get; set; } + + [JsonProperty("isNew")] public bool? IsNew { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("changedAt")] public DateTime? ChangedAt { get; set; } + + [JsonProperty("cancelSeconds")] public int? CancelSeconds { get; set; } + + [JsonProperty("isLiked")] public bool? IsLiked { get; set; } + + [JsonProperty("canPurchase")] public bool? CanPurchase { get; set; } + + [JsonProperty("canPurchaseReason")] public string CanPurchaseReason { get; set; } = ""; + + [JsonProperty("canReport")] public bool? CanReport { get; set; } + + [JsonProperty("canBePinned")] public bool? CanBePinned { get; set; } + + [JsonProperty("isPinned")] public bool? IsPinned { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Messages/ListsStateDto.cs b/OF DL.Core/Models/Dtos/Messages/ListsStateDto.cs new file mode 100644 index 0000000..dd4524a --- /dev/null +++ b/OF DL.Core/Models/Dtos/Messages/ListsStateDto.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Messages; + +public class ListsStateDto +{ + [JsonProperty("id")] public string Id { get; set; } = ""; + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("name")] public string Name { get; set; } = ""; + + [JsonProperty("hasUser")] public bool HasUser { get; set; } + + [JsonProperty("canAddUser")] public bool CanAddUser { get; set; } + + [JsonProperty("cannotAddUserReason")] public string CannotAddUserReason { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Messages/MediumDto.cs b/OF DL.Core/Models/Dtos/Messages/MediumDto.cs new file mode 100644 index 0000000..58052f1 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Messages/MediumDto.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Messages; + +public class MediumDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("canView")] public bool CanView { get; set; } + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("src")] public string Src { get; set; } = ""; + + [JsonProperty("preview")] public string Preview { get; set; } = ""; + + [JsonProperty("thumb")] public string Thumb { get; set; } = ""; + + [JsonProperty("locked")] public object Locked { get; set; } = new(); + + [JsonProperty("duration")] public int? Duration { get; set; } + + [JsonProperty("hasError")] public bool? HasError { get; set; } + + [JsonProperty("squarePreview")] public string SquarePreview { get; set; } = ""; + + [JsonProperty("video")] public VideoDto Video { get; set; } = new(); + + [JsonProperty("videoSources")] public VideoSourcesDto VideoSources { get; set; } = new(); + + [JsonProperty("source")] public SourceDto Source { get; set; } = new(); + + [JsonProperty("info")] public InfoDto Info { get; set; } = new(); + + [JsonProperty("files")] public FilesDto Files { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Messages/MessagesDto.cs b/OF DL.Core/Models/Dtos/Messages/MessagesDto.cs new file mode 100644 index 0000000..50673b5 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Messages/MessagesDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Messages; + +public class MessagesDto +{ + [JsonProperty("list")] public List List { get; set; } = []; + + [JsonProperty("hasMore")] public bool HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Messages/SingleMessageDto.cs b/OF DL.Core/Models/Dtos/Messages/SingleMessageDto.cs new file mode 100644 index 0000000..252c59b --- /dev/null +++ b/OF DL.Core/Models/Dtos/Messages/SingleMessageDto.cs @@ -0,0 +1,60 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Messages; + +public class SingleMessageDto +{ + [JsonProperty("responseType")] public string ResponseType { get; set; } = ""; + + [JsonProperty("text")] public string Text { get; set; } = ""; + + [JsonProperty("giphyId")] public object GiphyId { get; set; } = new(); + + [JsonProperty("lockedText")] public bool LockedText { get; set; } + + [JsonProperty("isFree")] public bool IsFree { get; set; } + + [JsonProperty("price")] public double Price { get; set; } + + [JsonProperty("isMediaReady")] public bool IsMediaReady { get; set; } + + [JsonProperty("mediaCount")] public int MediaCount { get; set; } + + [JsonProperty("media")] public List Media { get; set; } = []; + + [JsonProperty("previews")] public List Previews { get; set; } = []; + + [JsonProperty("isTip")] public bool IsTip { get; set; } + + [JsonProperty("isReportedByMe")] public bool IsReportedByMe { get; set; } + + [JsonProperty("isCouplePeopleMedia")] public bool IsCouplePeopleMedia { get; set; } + + [JsonProperty("queueId")] public long QueueId { get; set; } + + [JsonProperty("fromUser")] public FromUserDto FromUser { get; set; } = new(); + + [JsonProperty("isFromQueue")] public bool IsFromQueue { get; set; } + + [JsonProperty("canUnsendQueue")] public bool CanUnsendQueue { get; set; } + + [JsonProperty("unsendSecondsQueue")] public int UnsendSecondsQueue { get; set; } + + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("isOpened")] public bool IsOpened { get; set; } + + [JsonProperty("isNew")] public bool IsNew { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("changedAt")] public DateTime? ChangedAt { get; set; } + + [JsonProperty("cancelSeconds")] public int CancelSeconds { get; set; } + + [JsonProperty("isLiked")] public bool IsLiked { get; set; } + + [JsonProperty("canPurchase")] public bool CanPurchase { get; set; } + + [JsonProperty("canReport")] public bool CanReport { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Posts/InfoDto.cs b/OF DL.Core/Models/Dtos/Posts/InfoDto.cs new file mode 100644 index 0000000..6a7bcf3 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Posts/InfoDto.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Posts; + +public class InfoDto +{ + [JsonProperty("source")] public SourceDto Source { get; set; } = new(); + + [JsonProperty("preview")] public PreviewDto Preview { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Posts/ListItemDto.cs b/OF DL.Core/Models/Dtos/Posts/ListItemDto.cs new file mode 100644 index 0000000..d4196e8 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Posts/ListItemDto.cs @@ -0,0 +1,101 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; +using OF_DL.Utils; + +namespace OF_DL.Models.Dtos.Posts; + +public class ListItemDto +{ + private string _rawText = ""; + + [JsonProperty("responseType")] public string ResponseType { get; set; } = ""; + + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("postedAt")] public DateTime PostedAt { get; set; } + + [JsonProperty("postedAtPrecise")] public string PostedAtPrecise { get; set; } = ""; + + [JsonProperty("expiredAt")] public object ExpiredAt { get; set; } = new(); + + [JsonProperty("author")] public AuthorDto Author { get; set; } = new(); + + [JsonProperty("text")] public string Text { get; set; } = ""; + + [JsonProperty("rawText")] + public string RawText + { + get + { + if (string.IsNullOrEmpty(_rawText)) + { + _rawText = XmlUtils.EvaluateInnerText(Text); + } + + return _rawText; + } + set => _rawText = value; + } + + [JsonProperty("lockedText")] public bool? LockedText { get; set; } + + [JsonProperty("isFavorite")] public bool? IsFavorite { get; set; } + + [JsonProperty("canReport")] public bool? CanReport { get; set; } + + [JsonProperty("canDelete")] public bool? CanDelete { get; set; } + + [JsonProperty("canComment")] public bool? CanComment { get; set; } + + [JsonProperty("canEdit")] public bool? CanEdit { get; set; } + + [JsonProperty("isPinned")] public bool? IsPinned { get; set; } + + [JsonProperty("favoritesCount")] public int? FavoritesCount { get; set; } + + [JsonProperty("mediaCount")] public int? MediaCount { get; set; } + + [JsonProperty("isMediaReady")] public bool? IsMediaReady { get; set; } + + [JsonProperty("voting")] public object Voting { get; set; } = new(); + + [JsonProperty("isOpened")] public bool IsOpened { get; set; } + + [JsonProperty("canToggleFavorite")] public bool? CanToggleFavorite { get; set; } + + [JsonProperty("streamId")] public object StreamId { get; set; } = new(); + + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("hasVoting")] public bool? HasVoting { get; set; } + + [JsonProperty("isAddedToBookmarks")] public bool? IsAddedToBookmarks { get; set; } + + [JsonProperty("isArchived")] public bool IsArchived { get; set; } + + [JsonProperty("isPrivateArchived")] public bool? IsPrivateArchived { get; set; } + + [JsonProperty("isDeleted")] public bool? IsDeleted { get; set; } + + [JsonProperty("hasUrl")] public bool? HasUrl { get; set; } + + [JsonProperty("isCouplePeopleMedia")] public bool? IsCouplePeopleMedia { get; set; } + + [JsonProperty("cantCommentReason")] public string CantCommentReason { get; set; } = ""; + + [JsonProperty("votingType")] public int? VotingType { get; set; } + + [JsonProperty("commentsCount")] public int? CommentsCount { get; set; } + + [JsonProperty("mentionedUsers")] public List MentionedUsers { get; set; } = []; + + [JsonProperty("linkedUsers")] public List LinkedUsers { get; set; } = []; + + [JsonProperty("canVote")] public bool? CanVote { get; set; } + + [JsonProperty("media")] public List Media { get; set; } = []; + + [JsonProperty("canViewMedia")] public bool? CanViewMedia { get; set; } + + [JsonProperty("preview")] public List Preview { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Posts/MediumDto.cs b/OF DL.Core/Models/Dtos/Posts/MediumDto.cs new file mode 100644 index 0000000..39d785c --- /dev/null +++ b/OF DL.Core/Models/Dtos/Posts/MediumDto.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Posts; + +public class MediumDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("convertedToVideo")] public bool? ConvertedToVideo { get; set; } + + [JsonProperty("canView")] public bool CanView { get; set; } + + [JsonProperty("hasError")] public bool? HasError { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("info")] public InfoDto? Info { get; set; } + + [JsonProperty("source")] public SourceDto? Source { get; set; } + + [JsonProperty("squarePreview")] public string? SquarePreview { get; set; } + + [JsonProperty("full")] public string? Full { get; set; } + + [JsonProperty("preview")] public string? Preview { get; set; } + + [JsonProperty("thumb")] public string? Thumb { get; set; } + + [JsonProperty("hasCustomPreview")] public bool? HasCustomPreview { get; set; } + + [JsonProperty("files")] public FilesDto Files { get; set; } = new(); + + [JsonProperty("videoSources")] public VideoSourcesDto VideoSources { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Posts/PostDto.cs b/OF DL.Core/Models/Dtos/Posts/PostDto.cs new file mode 100644 index 0000000..8b7fbeb --- /dev/null +++ b/OF DL.Core/Models/Dtos/Posts/PostDto.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Posts; + +public class PostDto +{ + [JsonProperty("list")] public List List { get; set; } = []; + + [JsonProperty("hasMore")] public bool HasMore { get; set; } + + [JsonProperty("headMarker")] public string HeadMarker { get; set; } = ""; + + [JsonProperty("tailMarker")] public string TailMarker { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Posts/SinglePostDto.cs b/OF DL.Core/Models/Dtos/Posts/SinglePostDto.cs new file mode 100644 index 0000000..d37073e --- /dev/null +++ b/OF DL.Core/Models/Dtos/Posts/SinglePostDto.cs @@ -0,0 +1,99 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; +using OF_DL.Utils; + +namespace OF_DL.Models.Dtos.Posts; + +public class SinglePostDto +{ + private string _rawText = ""; + + [JsonProperty("responseType")] public string ResponseType { get; set; } = ""; + + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("postedAt")] public DateTime PostedAt { get; set; } + + [JsonProperty("postedAtPrecise")] public string PostedAtPrecise { get; set; } = ""; + + [JsonProperty("expiredAt")] public object ExpiredAt { get; set; } = new(); + + [JsonProperty("author")] public AuthorDto Author { get; set; } = new(); + + [JsonProperty("text")] public string Text { get; set; } = ""; + + [JsonProperty("rawText")] + public string RawText + { + get + { + if (string.IsNullOrEmpty(_rawText)) + { + _rawText = XmlUtils.EvaluateInnerText(Text); + } + + return _rawText; + } + set => _rawText = value; + } + + [JsonProperty("lockedText")] public bool LockedText { get; set; } + + [JsonProperty("isFavorite")] public bool IsFavorite { get; set; } + + [JsonProperty("canReport")] public bool CanReport { get; set; } + + [JsonProperty("canDelete")] public bool CanDelete { get; set; } + + [JsonProperty("canComment")] public bool CanComment { get; set; } + + [JsonProperty("canEdit")] public bool CanEdit { get; set; } + + [JsonProperty("isPinned")] public bool IsPinned { get; set; } + + [JsonProperty("favoritesCount")] public int FavoritesCount { get; set; } + + [JsonProperty("mediaCount")] public int MediaCount { get; set; } + + [JsonProperty("isMediaReady")] public bool IsMediaReady { get; set; } + + [JsonProperty("voting")] public object Voting { get; set; } = new(); + + [JsonProperty("isOpened")] public bool IsOpened { get; set; } + + [JsonProperty("canToggleFavorite")] public bool CanToggleFavorite { get; set; } + + [JsonProperty("streamId")] public string StreamId { get; set; } = ""; + + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("hasVoting")] public bool HasVoting { get; set; } + + [JsonProperty("isAddedToBookmarks")] public bool IsAddedToBookmarks { get; set; } + + [JsonProperty("isArchived")] public bool IsArchived { get; set; } + + [JsonProperty("isPrivateArchived")] public bool IsPrivateArchived { get; set; } + + [JsonProperty("isDeleted")] public bool IsDeleted { get; set; } + + [JsonProperty("hasUrl")] public bool HasUrl { get; set; } + + [JsonProperty("isCouplePeopleMedia")] public bool IsCouplePeopleMedia { get; set; } + + [JsonProperty("commentsCount")] public int CommentsCount { get; set; } + + [JsonProperty("mentionedUsers")] public List MentionedUsers { get; set; } = []; + + [JsonProperty("linkedUsers")] public List LinkedUsers { get; set; } = []; + + [JsonProperty("tipsAmount")] public string TipsAmount { get; set; } = ""; + + [JsonProperty("tipsAmountRaw")] public string TipsAmountRaw { get; set; } = ""; + + [JsonProperty("media")] public List Media { get; set; } = []; + + [JsonProperty("canViewMedia")] public bool CanViewMedia { get; set; } + + [JsonProperty("preview")] public List Preview { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Purchased/FromUserDto.cs b/OF DL.Core/Models/Dtos/Purchased/FromUserDto.cs new file mode 100644 index 0000000..fffd69f --- /dev/null +++ b/OF DL.Core/Models/Dtos/Purchased/FromUserDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Purchased; + +public class FromUserDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("_view")] public string View { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Purchased/ListItemDto.cs b/OF DL.Core/Models/Dtos/Purchased/ListItemDto.cs new file mode 100644 index 0000000..2104490 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Purchased/ListItemDto.cs @@ -0,0 +1,72 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; +using MessageDtos = OF_DL.Models.Dtos.Messages; + +namespace OF_DL.Models.Dtos.Purchased; + +public class ListItemDto +{ + [JsonProperty("responseType")] public string ResponseType { get; set; } = ""; + + [JsonProperty("text")] public string Text { get; set; } = ""; + + [JsonProperty("giphyId")] public object GiphyId { get; set; } = new(); + + [JsonProperty("lockedText")] public bool? LockedText { get; set; } + + [JsonProperty("isFree")] public bool? IsFree { get; set; } + + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("isMediaReady")] public bool? IsMediaReady { get; set; } + + [JsonProperty("mediaCount")] public int? MediaCount { get; set; } + + [JsonProperty("media")] public List? Media { get; set; } + + [JsonProperty("previews")] public List? Previews { get; set; } + + [JsonProperty("preview")] public List? Preview { get; set; } + + [JsonProperty("isTip")] public bool? IsTip { get; set; } + + [JsonProperty("isReportedByMe")] public bool? IsReportedByMe { get; set; } + + [JsonProperty("isCouplePeopleMedia")] public bool? IsCouplePeopleMedia { get; set; } + + [JsonProperty("queueId")] public object QueueId { get; set; } = new(); + + [JsonProperty("fromUser")] public FromUserDto? FromUser { get; set; } + + [JsonProperty("author")] public AuthorDto? Author { get; set; } + + [JsonProperty("isFromQueue")] public bool? IsFromQueue { get; set; } + + [JsonProperty("canUnsendQueue")] public bool? CanUnsendQueue { get; set; } + + [JsonProperty("unsendSecondsQueue")] public int? UnsendSecondsQueue { get; set; } + + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("isOpened")] public bool IsOpened { get; set; } + + [JsonProperty("isNew")] public bool? IsNew { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("postedAt")] public DateTime? PostedAt { get; set; } + + [JsonProperty("changedAt")] public DateTime? ChangedAt { get; set; } + + [JsonProperty("cancelSeconds")] public int? CancelSeconds { get; set; } + + [JsonProperty("isLiked")] public bool? IsLiked { get; set; } + + [JsonProperty("canPurchase")] public bool? CanPurchase { get; set; } + + [JsonProperty("canReport")] public bool? CanReport { get; set; } + + [JsonProperty("isCanceled")] public bool? IsCanceled { get; set; } + + [JsonProperty("isArchived")] public bool? IsArchived { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Purchased/PurchasedDto.cs b/OF DL.Core/Models/Dtos/Purchased/PurchasedDto.cs new file mode 100644 index 0000000..7be06c7 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Purchased/PurchasedDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Purchased; + +public class PurchasedDto +{ + [JsonProperty("list")] public List List { get; set; } = []; + + [JsonProperty("hasMore")] public bool HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Stories/MediumDto.cs b/OF DL.Core/Models/Dtos/Stories/MediumDto.cs new file mode 100644 index 0000000..6971f50 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Stories/MediumDto.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Stories; + +public class MediumDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("convertedToVideo")] public bool ConvertedToVideo { get; set; } + + [JsonProperty("canView")] public bool CanView { get; set; } + + [JsonProperty("hasError")] public bool HasError { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("files")] public FilesDto Files { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Stories/StoryDto.cs b/OF DL.Core/Models/Dtos/Stories/StoryDto.cs new file mode 100644 index 0000000..1a11fa5 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Stories/StoryDto.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Stories; + +public class StoryDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("userId")] public long UserId { get; set; } + + [JsonProperty("isWatched")] public bool IsWatched { get; set; } + + [JsonProperty("isReady")] public bool IsReady { get; set; } + + [JsonProperty("media")] public List Media { get; set; } = []; + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("question")] public object Question { get; set; } = new(); + + [JsonProperty("canLike")] public bool CanLike { get; set; } + + [JsonProperty("isLiked")] public bool IsLiked { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Streams/InfoDto.cs b/OF DL.Core/Models/Dtos/Streams/InfoDto.cs new file mode 100644 index 0000000..bd1e9b1 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Streams/InfoDto.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Streams; + +public class InfoDto +{ + [JsonProperty("source")] public SourceDto Source { get; set; } = new(); + + [JsonProperty("preview")] public PreviewDto Preview { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Streams/ListItemDto.cs b/OF DL.Core/Models/Dtos/Streams/ListItemDto.cs new file mode 100644 index 0000000..151ece4 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Streams/ListItemDto.cs @@ -0,0 +1,101 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; +using OF_DL.Utils; + +namespace OF_DL.Models.Dtos.Streams; + +public class ListItemDto +{ + private string _rawText = ""; + + [JsonProperty("responseType")] public string ResponseType { get; set; } = ""; + + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("postedAt")] public DateTime PostedAt { get; set; } + + [JsonProperty("postedAtPrecise")] public string PostedAtPrecise { get; set; } = ""; + + [JsonProperty("expiredAt")] public object ExpiredAt { get; set; } = new(); + + [JsonProperty("author")] public AuthorDto Author { get; set; } = new(); + + [JsonProperty("text")] public string Text { get; set; } = ""; + + [JsonProperty("rawText")] + public string RawText + { + get + { + if (string.IsNullOrEmpty(_rawText)) + { + _rawText = XmlUtils.EvaluateInnerText(Text); + } + + return _rawText; + } + set => _rawText = value; + } + + [JsonProperty("lockedText")] public bool? LockedText { get; set; } + + [JsonProperty("isFavorite")] public bool? IsFavorite { get; set; } + + [JsonProperty("canReport")] public bool? CanReport { get; set; } + + [JsonProperty("canDelete")] public bool? CanDelete { get; set; } + + [JsonProperty("canComment")] public bool? CanComment { get; set; } + + [JsonProperty("canEdit")] public bool? CanEdit { get; set; } + + [JsonProperty("isPinned")] public bool? IsPinned { get; set; } + + [JsonProperty("favoritesCount")] public int? FavoritesCount { get; set; } + + [JsonProperty("mediaCount")] public int? MediaCount { get; set; } + + [JsonProperty("isMediaReady")] public bool? IsMediaReady { get; set; } + + [JsonProperty("voting")] public object Voting { get; set; } = new(); + + [JsonProperty("isOpened")] public bool? IsOpened { get; set; } + + [JsonProperty("canToggleFavorite")] public bool? CanToggleFavorite { get; set; } + + [JsonProperty("streamId")] public int? StreamId { get; set; } + + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("hasVoting")] public bool? HasVoting { get; set; } + + [JsonProperty("isAddedToBookmarks")] public bool? IsAddedToBookmarks { get; set; } + + [JsonProperty("isArchived")] public bool? IsArchived { get; set; } + + [JsonProperty("isPrivateArchived")] public bool? IsPrivateArchived { get; set; } + + [JsonProperty("isDeleted")] public bool? IsDeleted { get; set; } + + [JsonProperty("hasUrl")] public bool? HasUrl { get; set; } + + [JsonProperty("isCouplePeopleMedia")] public bool? IsCouplePeopleMedia { get; set; } + + [JsonProperty("cantCommentReason")] public string CantCommentReason { get; set; } = ""; + + [JsonProperty("commentsCount")] public int? CommentsCount { get; set; } + + [JsonProperty("mentionedUsers")] public List MentionedUsers { get; set; } = []; + + [JsonProperty("linkedUsers")] public List LinkedUsers { get; set; } = []; + + [JsonProperty("tipsAmount")] public string TipsAmount { get; set; } = ""; + + [JsonProperty("tipsAmountRaw")] public string TipsAmountRaw { get; set; } = ""; + + [JsonProperty("media")] public List Media { get; set; } = []; + + [JsonProperty("canViewMedia")] public bool? CanViewMedia { get; set; } + + [JsonProperty("preview")] public List Preview { get; set; } = []; +} diff --git a/OF DL.Core/Models/Dtos/Streams/MediumDto.cs b/OF DL.Core/Models/Dtos/Streams/MediumDto.cs new file mode 100644 index 0000000..61040f1 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Streams/MediumDto.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Streams; + +public class MediumDto +{ + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("convertedToVideo")] public bool ConvertedToVideo { get; set; } + + [JsonProperty("canView")] public bool CanView { get; set; } + + [JsonProperty("hasError")] public bool HasError { get; set; } + + [JsonProperty("createdAt")] public DateTime? CreatedAt { get; set; } + + [JsonProperty("info")] public InfoDto Info { get; set; } = new(); + + [JsonProperty("source")] public SourceDto Source { get; set; } = new(); + + [JsonProperty("squarePreview")] public string SquarePreview { get; set; } = ""; + + [JsonProperty("full")] public string Full { get; set; } = ""; + + [JsonProperty("preview")] public string Preview { get; set; } = ""; + + [JsonProperty("thumb")] public string Thumb { get; set; } = ""; + + [JsonProperty("hasCustomPreview")] public bool HasCustomPreview { get; set; } + + [JsonProperty("files")] public FilesDto Files { get; set; } = new(); + + [JsonProperty("videoSources")] public VideoSourcesDto VideoSources { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Streams/StreamsDto.cs b/OF DL.Core/Models/Dtos/Streams/StreamsDto.cs new file mode 100644 index 0000000..480a494 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Streams/StreamsDto.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Streams; + +public class StreamsDto +{ + [JsonProperty("list")] public List List { get; set; } = []; + + [JsonProperty("hasMore")] public bool HasMore { get; set; } + + [JsonProperty("headMarker")] public string HeadMarker { get; set; } = ""; + + [JsonProperty("tailMarker")] public string TailMarker { get; set; } = ""; + + [JsonProperty("counters")] public CountersDto Counters { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Dtos/Subscriptions/ListItemDto.cs b/OF DL.Core/Models/Dtos/Subscriptions/ListItemDto.cs new file mode 100644 index 0000000..8b60c38 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Subscriptions/ListItemDto.cs @@ -0,0 +1,111 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Subscriptions; + +public class ListItemDto +{ + [JsonProperty("view")] public string View { get; set; } = ""; + + [JsonProperty("avatar")] public string? Avatar { get; set; } + + [JsonProperty("avatarThumbs")] public AvatarThumbsDto AvatarThumbs { get; set; } = new(); + + [JsonProperty("header")] public string? Header { get; set; } + + [JsonProperty("headerSize")] public HeaderSizeDto HeaderSize { get; set; } = new(); + + [JsonProperty("headerThumbs")] public HeaderThumbsDto HeaderThumbs { get; set; } = new(); + + [JsonProperty("id")] public long Id { get; set; } + + [JsonProperty("name")] public string Name { get; set; } = ""; + + [JsonProperty("username")] public string? Username { get; set; } + + [JsonProperty("canLookStory")] public bool? CanLookStory { get; set; } + + [JsonProperty("canCommentStory")] public bool? CanCommentStory { get; set; } + + [JsonProperty("hasNotViewedStory")] public bool? HasNotViewedStory { get; set; } + + [JsonProperty("isVerified")] public bool? IsVerified { get; set; } + + [JsonProperty("canPayInternal")] public bool? CanPayInternal { get; set; } + + [JsonProperty("hasScheduledStream")] public bool? HasScheduledStream { get; set; } + + [JsonProperty("hasStream")] public bool? HasStream { get; set; } + + [JsonProperty("hasStories")] public bool? HasStories { get; set; } + + [JsonProperty("tipsEnabled")] public bool? TipsEnabled { get; set; } + + [JsonProperty("tipsTextEnabled")] public bool? TipsTextEnabled { get; set; } + + [JsonProperty("tipsMin")] public int? TipsMin { get; set; } + + [JsonProperty("tipsMinInternal")] public int? TipsMinInternal { get; set; } + + [JsonProperty("tipsMax")] public int? TipsMax { get; set; } + + [JsonProperty("canEarn")] public bool? CanEarn { get; set; } + + [JsonProperty("canAddSubscriber")] public bool? CanAddSubscriber { get; set; } + + [JsonProperty("subscribePrice")] public string? SubscribePrice { get; set; } + + [JsonProperty("isPaywallRequired")] public bool? IsPaywallRequired { get; set; } + + [JsonProperty("unprofitable")] public bool? Unprofitable { get; set; } + + [JsonProperty("listsStates")] public List ListsStates { get; set; } = []; + + [JsonProperty("isMuted")] public bool? IsMuted { get; set; } + + [JsonProperty("isRestricted")] public bool? IsRestricted { get; set; } + + [JsonProperty("canRestrict")] public bool? CanRestrict { get; set; } + + [JsonProperty("subscribedBy")] public bool? SubscribedBy { get; set; } + + [JsonProperty("subscribedByExpire")] public bool? SubscribedByExpire { get; set; } + + [JsonProperty("subscribedByExpireDate")] public DateTime? SubscribedByExpireDate { get; set; } + + [JsonProperty("subscribedByAutoprolong")] public bool? SubscribedByAutoprolong { get; set; } + + [JsonProperty("subscribedIsExpiredNow")] public bool? SubscribedIsExpiredNow { get; set; } + + [JsonProperty("currentSubscribePrice")] public string? CurrentSubscribePrice { get; set; } + + [JsonProperty("subscribedOn")] public bool? SubscribedOn { get; set; } + + [JsonProperty("subscribedOnExpiredNow")] public bool? SubscribedOnExpiredNow { get; set; } + + [JsonProperty("subscribedOnDuration")] public string SubscribedOnDuration { get; set; } = ""; + + [JsonProperty("canReport")] public bool? CanReport { get; set; } + + [JsonProperty("canReceiveChatMessage")] public bool? CanReceiveChatMessage { get; set; } + + [JsonProperty("hideChat")] public bool? HideChat { get; set; } + + [JsonProperty("lastSeen")] public DateTime? LastSeen { get; set; } + + [JsonProperty("isPerformer")] public bool? IsPerformer { get; set; } + + [JsonProperty("isRealPerformer")] public bool? IsRealPerformer { get; set; } + + [JsonProperty("subscribedByData")] public SubscribedByDataDto SubscribedByData { get; set; } = new(); + + [JsonProperty("subscribedOnData")] public SubscribedOnDataDto SubscribedOnData { get; set; } = new(); + + [JsonProperty("canTrialSend")] public bool? CanTrialSend { get; set; } + + [JsonProperty("isBlocked")] public bool? IsBlocked { get; set; } + + [JsonProperty("displayName")] public string DisplayName { get; set; } = ""; + + [JsonProperty("notice")] public string Notice { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Dtos/Subscriptions/ListsStateDto.cs b/OF DL.Core/Models/Dtos/Subscriptions/ListsStateDto.cs new file mode 100644 index 0000000..fc4929f --- /dev/null +++ b/OF DL.Core/Models/Dtos/Subscriptions/ListsStateDto.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Subscriptions; + +public class ListsStateDto +{ + [JsonProperty("id")] public object Id { get; set; } = new(); + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("name")] public string Name { get; set; } = ""; + + [JsonProperty("hasUser")] public bool? HasUser { get; set; } + + [JsonProperty("canAddUser")] public bool? CanAddUser { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Subscriptions/SubscribeDto.cs b/OF DL.Core/Models/Dtos/Subscriptions/SubscribeDto.cs new file mode 100644 index 0000000..2dfeef8 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Subscriptions/SubscribeDto.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Subscriptions; + +public class SubscribeDto +{ + [JsonProperty("id")] public object Id { get; set; } = new(); + + [JsonProperty("userId")] public long? UserId { get; set; } + + [JsonProperty("subscriberId")] public int? SubscriberId { get; set; } + + [JsonProperty("date")] public DateTime? Date { get; set; } + + [JsonProperty("duration")] public int? Duration { get; set; } + + [JsonProperty("startDate")] public DateTime? StartDate { get; set; } + + [JsonProperty("expireDate")] public DateTime? ExpireDate { get; set; } + + [JsonProperty("cancelDate")] public object CancelDate { get; set; } = new(); + + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("regularPrice")] public string? RegularPrice { get; set; } + + [JsonProperty("discount")] public string? Discount { get; set; } + + [JsonProperty("action")] public string Action { get; set; } = ""; + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("offerStart")] public object OfferStart { get; set; } = new(); + + [JsonProperty("offerEnd")] public object OfferEnd { get; set; } = new(); + + [JsonProperty("isCurrent")] public bool? IsCurrent { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Subscriptions/SubscriptionsDto.cs b/OF DL.Core/Models/Dtos/Subscriptions/SubscriptionsDto.cs new file mode 100644 index 0000000..3743c8a --- /dev/null +++ b/OF DL.Core/Models/Dtos/Subscriptions/SubscriptionsDto.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Subscriptions; + +public class SubscriptionsDto +{ + [JsonProperty("list")] public List List { get; set; } = []; + + [JsonProperty("hasMore")] public bool HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Users/ListsStateDto.cs b/OF DL.Core/Models/Dtos/Users/ListsStateDto.cs new file mode 100644 index 0000000..ce6952f --- /dev/null +++ b/OF DL.Core/Models/Dtos/Users/ListsStateDto.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Users; + +public class ListsStateDto +{ + [JsonProperty("id")] public string Id { get; set; } = ""; + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("name")] public string Name { get; set; } = ""; + + [JsonProperty("hasUser")] public bool HasUser { get; set; } + + [JsonProperty("canAddUser")] public bool CanAddUser { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Users/SubscribeDto.cs b/OF DL.Core/Models/Dtos/Users/SubscribeDto.cs new file mode 100644 index 0000000..d6ff5a9 --- /dev/null +++ b/OF DL.Core/Models/Dtos/Users/SubscribeDto.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.Dtos.Users; + +public class SubscribeDto +{ + [JsonProperty("id")] public long? Id { get; set; } + + [JsonProperty("userId")] public long? UserId { get; set; } + + [JsonProperty("subscriberId")] public int? SubscriberId { get; set; } + + [JsonProperty("date")] public DateTime? Date { get; set; } + + [JsonProperty("duration")] public int? Duration { get; set; } + + [JsonProperty("startDate")] public DateTime? StartDate { get; set; } + + [JsonProperty("expireDate")] public DateTime? ExpireDate { get; set; } + + [JsonProperty("cancelDate")] public object CancelDate { get; set; } = new(); + + [JsonProperty("price")] public string? Price { get; set; } + + [JsonProperty("regularPrice")] public string? RegularPrice { get; set; } + + [JsonProperty("discount")] public int? Discount { get; set; } + + [JsonProperty("action")] public string Action { get; set; } = ""; + + [JsonProperty("type")] public string Type { get; set; } = ""; + + [JsonProperty("offerStart")] public object OfferStart { get; set; } = new(); + + [JsonProperty("offerEnd")] public object OfferEnd { get; set; } = new(); + + [JsonProperty("isCurrent")] public bool? IsCurrent { get; set; } +} diff --git a/OF DL.Core/Models/Dtos/Users/UserDto.cs b/OF DL.Core/Models/Dtos/Users/UserDto.cs new file mode 100644 index 0000000..cc1ca3b --- /dev/null +++ b/OF DL.Core/Models/Dtos/Users/UserDto.cs @@ -0,0 +1,179 @@ +using Newtonsoft.Json; +using OF_DL.Models.Dtos.Common; + +namespace OF_DL.Models.Dtos.Users; + +public class UserDto +{ + [JsonProperty("view")] public string View { get; set; } = ""; + + [JsonProperty("avatar")] public string? Avatar { get; set; } + + [JsonProperty("avatarThumbs")] public AvatarThumbsDto AvatarThumbs { get; set; } = new(); + + [JsonProperty("header")] public string? Header { get; set; } + + [JsonProperty("headerSize")] public HeaderSizeDto HeaderSize { get; set; } = new(); + + [JsonProperty("headerThumbs")] public HeaderThumbsDto HeaderThumbs { get; set; } = new(); + + [JsonProperty("id")] public long? Id { get; set; } + + [JsonProperty("name")] public string? Name { get; set; } + + [JsonProperty("username")] public string? Username { get; set; } + + [JsonProperty("canLookStory")] public bool? CanLookStory { get; set; } + + [JsonProperty("canCommentStory")] public bool? CanCommentStory { get; set; } + + [JsonProperty("hasNotViewedStory")] public bool? HasNotViewedStory { get; set; } + + [JsonProperty("isVerified")] public bool? IsVerified { get; set; } + + [JsonProperty("canPayInternal")] public bool? CanPayInternal { get; set; } + + [JsonProperty("hasScheduledStream")] public bool? HasScheduledStream { get; set; } + + [JsonProperty("hasStream")] public bool? HasStream { get; set; } + + [JsonProperty("hasStories")] public bool? HasStories { get; set; } + + [JsonProperty("tipsEnabled")] public bool? TipsEnabled { get; set; } + + [JsonProperty("tipsTextEnabled")] public bool? TipsTextEnabled { get; set; } + + [JsonProperty("tipsMin")] public int? TipsMin { get; set; } + + [JsonProperty("tipsMinInternal")] public int? TipsMinInternal { get; set; } + + [JsonProperty("tipsMax")] public int? TipsMax { get; set; } + + [JsonProperty("canEarn")] public bool? CanEarn { get; set; } + + [JsonProperty("canAddSubscriber")] public bool? CanAddSubscriber { get; set; } + + [JsonProperty("subscribePrice")] public string? SubscribePrice { get; set; } + + [JsonProperty("displayName")] public string DisplayName { get; set; } = ""; + + [JsonProperty("notice")] public string Notice { get; set; } = ""; + + [JsonProperty("isPaywallRequired")] public bool? IsPaywallRequired { get; set; } + + [JsonProperty("unprofitable")] public bool? Unprofitable { get; set; } + + [JsonProperty("listsStates")] public List ListsStates { get; set; } = []; + + [JsonProperty("isMuted")] public bool? IsMuted { get; set; } + + [JsonProperty("isRestricted")] public bool? IsRestricted { get; set; } + + [JsonProperty("canRestrict")] public bool? CanRestrict { get; set; } + + [JsonProperty("subscribedBy")] public bool? SubscribedBy { get; set; } + + [JsonProperty("subscribedByExpire")] public bool? SubscribedByExpire { get; set; } + + [JsonProperty("subscribedByExpireDate")] public DateTime? SubscribedByExpireDate { get; set; } + + [JsonProperty("subscribedByAutoprolong")] public bool? SubscribedByAutoprolong { get; set; } + + [JsonProperty("subscribedIsExpiredNow")] public bool? SubscribedIsExpiredNow { get; set; } + + [JsonProperty("currentSubscribePrice")] public string? CurrentSubscribePrice { get; set; } + + [JsonProperty("subscribedOn")] public bool? SubscribedOn { get; set; } + + [JsonProperty("subscribedOnExpiredNow")] public bool? SubscribedOnExpiredNow { get; set; } + + [JsonProperty("subscribedOnDuration")] public string SubscribedOnDuration { get; set; } = ""; + + [JsonProperty("joinDate")] public DateTime? JoinDate { get; set; } + + [JsonProperty("isReferrerAllowed")] public bool? IsReferrerAllowed { get; set; } + + [JsonProperty("about")] public string About { get; set; } = ""; + + [JsonProperty("rawAbout")] public string RawAbout { get; set; } = ""; + + [JsonProperty("website")] public object Website { get; set; } = new(); + + [JsonProperty("wishlist")] public object Wishlist { get; set; } = new(); + + [JsonProperty("location")] public object Location { get; set; } = new(); + + [JsonProperty("postsCount")] public int? PostsCount { get; set; } + + [JsonProperty("archivedPostsCount")] public int? ArchivedPostsCount { get; set; } + + [JsonProperty("privateArchivedPostsCount")] public int? PrivateArchivedPostsCount { get; set; } + + [JsonProperty("photosCount")] public int? PhotosCount { get; set; } + + [JsonProperty("videosCount")] public int? VideosCount { get; set; } + + [JsonProperty("audiosCount")] public int? AudiosCount { get; set; } + + [JsonProperty("mediasCount")] public int? MediasCount { get; set; } + + [JsonProperty("lastSeen")] public DateTime? LastSeen { get; set; } + + [JsonProperty("favoritesCount")] public int? FavoritesCount { get; set; } + + [JsonProperty("favoritedCount")] public int? FavoritedCount { get; set; } + + [JsonProperty("showPostsInFeed")] public bool? ShowPostsInFeed { get; set; } + + [JsonProperty("canReceiveChatMessage")] public bool? CanReceiveChatMessage { get; set; } + + [JsonProperty("isPerformer")] public bool? IsPerformer { get; set; } + + [JsonProperty("isRealPerformer")] public bool? IsRealPerformer { get; set; } + + [JsonProperty("isSpotifyConnected")] public bool? IsSpotifyConnected { get; set; } + + [JsonProperty("subscribersCount")] public int? SubscribersCount { get; set; } + + [JsonProperty("hasPinnedPosts")] public bool? HasPinnedPosts { get; set; } + + [JsonProperty("hasLabels")] public bool? HasLabels { get; set; } + + [JsonProperty("canChat")] public bool? CanChat { get; set; } + + [JsonProperty("callPrice")] public string? CallPrice { get; set; } + + [JsonProperty("isPrivateRestriction")] public bool? IsPrivateRestriction { get; set; } + + [JsonProperty("showSubscribersCount")] public bool? ShowSubscribersCount { get; set; } + + [JsonProperty("showMediaCount")] public bool? ShowMediaCount { get; set; } + + [JsonProperty("subscribedByData")] public SubscribedByDataDto SubscribedByData { get; set; } = new(); + + [JsonProperty("subscribedOnData")] public SubscribedOnDataDto SubscribedOnData { get; set; } = new(); + + [JsonProperty("canPromotion")] public bool? CanPromotion { get; set; } + + [JsonProperty("canCreatePromotion")] public bool? CanCreatePromotion { get; set; } + + [JsonProperty("canCreateTrial")] public bool? CanCreateTrial { get; set; } + + [JsonProperty("isAdultContent")] public bool? IsAdultContent { get; set; } + + [JsonProperty("canTrialSend")] public bool? CanTrialSend { get; set; } + + [JsonProperty("hadEnoughLastPhotos")] public bool? HadEnoughLastPhotos { get; set; } + + [JsonProperty("hasLinks")] public bool? HasLinks { get; set; } + + [JsonProperty("firstPublishedPostDate")] public DateTime? FirstPublishedPostDate { get; set; } + + [JsonProperty("isSpringConnected")] public bool? IsSpringConnected { get; set; } + + [JsonProperty("isFriend")] public bool? IsFriend { get; set; } + + [JsonProperty("isBlocked")] public bool? IsBlocked { get; set; } + + [JsonProperty("canReport")] public bool? CanReport { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Archived/Archived.cs b/OF DL.Core/Models/Entities/Archived/Archived.cs new file mode 100644 index 0000000..80bd9f6 --- /dev/null +++ b/OF DL.Core/Models/Entities/Archived/Archived.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Archived; + +public class Archived +{ + public List List { get; set; } = []; + + public bool HasMore { get; set; } + + public string? TailMarker { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Archived/ArchivedCollection.cs b/OF DL.Core/Models/Entities/Archived/ArchivedCollection.cs new file mode 100644 index 0000000..d4662ed --- /dev/null +++ b/OF DL.Core/Models/Entities/Archived/ArchivedCollection.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Archived; + +public class ArchivedCollection +{ + public List ArchivedPostMedia { get; set; } = []; + + public List ArchivedPostObjects { get; set; } = []; + + public Dictionary ArchivedPosts { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Archived/ListItem.cs b/OF DL.Core/Models/Entities/Archived/ListItem.cs new file mode 100644 index 0000000..15333db --- /dev/null +++ b/OF DL.Core/Models/Entities/Archived/ListItem.cs @@ -0,0 +1,24 @@ +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Entities.Archived; + +public class ListItem +{ + public long Id { get; set; } + + public DateTime PostedAt { get; set; } + + public Author? Author { get; set; } + + public string? Text { get; set; } + + public string? Price { get; set; } + + public bool IsOpened { get; set; } + + public bool IsArchived { get; set; } + + public List? Media { get; set; } + + public List? Preview { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Archived/Medium.cs b/OF DL.Core/Models/Entities/Archived/Medium.cs new file mode 100644 index 0000000..9b8b537 --- /dev/null +++ b/OF DL.Core/Models/Entities/Archived/Medium.cs @@ -0,0 +1,16 @@ +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Entities.Archived; + +public class Medium +{ + public long Id { get; set; } + + public string? Type { get; set; } + + public bool CanView { get; set; } + + public Files? Files { get; set; } + + public string? Preview { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/Author.cs b/OF DL.Core/Models/Entities/Common/Author.cs new file mode 100644 index 0000000..5f6b9d9 --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/Author.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Models.Entities.Common; + +public class Author +{ + public long Id { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/Dash.cs b/OF DL.Core/Models/Entities/Common/Dash.cs new file mode 100644 index 0000000..5d746d6 --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/Dash.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Common; + +public class Dash +{ + public string? CloudFrontPolicy { get; set; } + + public string? CloudFrontSignature { get; set; } + + public string? CloudFrontKeyPairId { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/Drm.cs b/OF DL.Core/Models/Entities/Common/Drm.cs new file mode 100644 index 0000000..fe97d3c --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/Drm.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Models.Entities.Common; + +public class Drm +{ + public Manifest? Manifest { get; set; } + + public Signature? Signature { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/Files.cs b/OF DL.Core/Models/Entities/Common/Files.cs new file mode 100644 index 0000000..4f50965 --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/Files.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Common; + +public class Files +{ + public Full? Full { get; set; } + + public Preview? Preview { get; set; } + + public Drm? Drm { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/FromUser.cs b/OF DL.Core/Models/Entities/Common/FromUser.cs new file mode 100644 index 0000000..fe435b2 --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/FromUser.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Models.Entities.Common; + +public class FromUser +{ + public long Id { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/Full.cs b/OF DL.Core/Models/Entities/Common/Full.cs new file mode 100644 index 0000000..517e09d --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/Full.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Models.Entities.Common; + +public class Full +{ + public string? Url { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/Manifest.cs b/OF DL.Core/Models/Entities/Common/Manifest.cs new file mode 100644 index 0000000..b3502b1 --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/Manifest.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Models.Entities.Common; + +public class Manifest +{ + public string? Dash { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/Preview.cs b/OF DL.Core/Models/Entities/Common/Preview.cs new file mode 100644 index 0000000..a3d6edb --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/Preview.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Models.Entities.Common; + +public class Preview +{ + public string? Url { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/Signature.cs b/OF DL.Core/Models/Entities/Common/Signature.cs new file mode 100644 index 0000000..bf10deb --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/Signature.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Models.Entities.Common; + +public class Signature +{ + public Dash? Dash { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Common/VideoSources.cs b/OF DL.Core/Models/Entities/Common/VideoSources.cs new file mode 100644 index 0000000..92422c0 --- /dev/null +++ b/OF DL.Core/Models/Entities/Common/VideoSources.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Models.Entities.Common; + +public class VideoSources +{ + public string? _720 { get; set; } + + public string? _240 { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Highlights/HighlightMedia.cs b/OF DL.Core/Models/Entities/Highlights/HighlightMedia.cs new file mode 100644 index 0000000..e082a0a --- /dev/null +++ b/OF DL.Core/Models/Entities/Highlights/HighlightMedia.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Models.Entities.Highlights; + +public class HighlightMedia +{ + public List Stories { get; set; } = []; +} diff --git a/OF DL.Core/Models/Entities/Highlights/Highlights.cs b/OF DL.Core/Models/Entities/Highlights/Highlights.cs new file mode 100644 index 0000000..a0a916c --- /dev/null +++ b/OF DL.Core/Models/Entities/Highlights/Highlights.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Models.Entities.Highlights; + +public class Highlights +{ + public List List { get; set; } = []; + + public bool HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Highlights/ListItem.cs b/OF DL.Core/Models/Entities/Highlights/ListItem.cs new file mode 100644 index 0000000..6d6a407 --- /dev/null +++ b/OF DL.Core/Models/Entities/Highlights/ListItem.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Models.Entities.Highlights; + +public class ListItem +{ + public long Id { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Highlights/Medium.cs b/OF DL.Core/Models/Entities/Highlights/Medium.cs new file mode 100644 index 0000000..e3855eb --- /dev/null +++ b/OF DL.Core/Models/Entities/Highlights/Medium.cs @@ -0,0 +1,16 @@ +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Entities.Highlights; + +public class Medium +{ + public long Id { get; set; } + + public string? Type { get; set; } + + public bool CanView { get; set; } + + public DateTime? CreatedAt { get; set; } + + public Files? Files { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Highlights/Story.cs b/OF DL.Core/Models/Entities/Highlights/Story.cs new file mode 100644 index 0000000..ef2fb81 --- /dev/null +++ b/OF DL.Core/Models/Entities/Highlights/Story.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Highlights; + +public class Story +{ + public long Id { get; set; } + + public DateTime? CreatedAt { get; set; } + + public List? Media { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Lists/UserList.cs b/OF DL.Core/Models/Entities/Lists/UserList.cs new file mode 100644 index 0000000..69babb3 --- /dev/null +++ b/OF DL.Core/Models/Entities/Lists/UserList.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Models.Entities.Lists; + +public class UserList +{ + public List List { get; set; } = []; + + public bool HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Lists/UserListItem.cs b/OF DL.Core/Models/Entities/Lists/UserListItem.cs new file mode 100644 index 0000000..ad57069 --- /dev/null +++ b/OF DL.Core/Models/Entities/Lists/UserListItem.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Models.Entities.Lists; + +public class UserListItem +{ + public string Id { get; set; } = ""; + + public string Name { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Entities/Lists/UsersList.cs b/OF DL.Core/Models/Entities/Lists/UsersList.cs new file mode 100644 index 0000000..877b982 --- /dev/null +++ b/OF DL.Core/Models/Entities/Lists/UsersList.cs @@ -0,0 +1,6 @@ +namespace OF_DL.Models.Entities.Lists; + +public class UsersList +{ + public string Username { get; set; } = ""; +} diff --git a/OF DL.Core/Models/Entities/Messages/ListItem.cs b/OF DL.Core/Models/Entities/Messages/ListItem.cs new file mode 100644 index 0000000..e48f1f5 --- /dev/null +++ b/OF DL.Core/Models/Entities/Messages/ListItem.cs @@ -0,0 +1,22 @@ +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Entities.Messages; + +public class ListItem +{ + public long Id { get; set; } + + public string? Text { get; set; } + + public string? Price { get; set; } + + public string? CanPurchaseReason { get; set; } + + public DateTime? CreatedAt { get; set; } + + public List? Previews { get; set; } + + public List? Media { get; set; } + + public FromUser? FromUser { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Messages/Medium.cs b/OF DL.Core/Models/Entities/Messages/Medium.cs new file mode 100644 index 0000000..7f6fa1a --- /dev/null +++ b/OF DL.Core/Models/Entities/Messages/Medium.cs @@ -0,0 +1,14 @@ +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Entities.Messages; + +public class Medium +{ + public long Id { get; set; } + + public bool CanView { get; set; } + + public string? Type { get; set; } + + public Files? Files { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Messages/MessageCollection.cs b/OF DL.Core/Models/Entities/Messages/MessageCollection.cs new file mode 100644 index 0000000..c5ac0c2 --- /dev/null +++ b/OF DL.Core/Models/Entities/Messages/MessageCollection.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Messages; + +public class MessageCollection +{ + public List MessageMedia { get; set; } = []; + + public List MessageObjects { get; set; } = []; + + public Dictionary Messages { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Messages/Messages.cs b/OF DL.Core/Models/Entities/Messages/Messages.cs new file mode 100644 index 0000000..9a965e2 --- /dev/null +++ b/OF DL.Core/Models/Entities/Messages/Messages.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Models.Entities.Messages; + +public class Messages +{ + public List List { get; set; } = []; + + public bool HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Messages/SingleMessage.cs b/OF DL.Core/Models/Entities/Messages/SingleMessage.cs new file mode 100644 index 0000000..b41b555 --- /dev/null +++ b/OF DL.Core/Models/Entities/Messages/SingleMessage.cs @@ -0,0 +1,20 @@ +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Entities.Messages; + +public class SingleMessage +{ + public long Id { get; set; } + + public string? Text { get; set; } + + public double? Price { get; set; } + + public DateTime? CreatedAt { get; set; } + + public List? Media { get; set; } + + public List? Previews { get; set; } + + public FromUser? FromUser { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Posts/ListItem.cs b/OF DL.Core/Models/Entities/Posts/ListItem.cs new file mode 100644 index 0000000..e5a2f5b --- /dev/null +++ b/OF DL.Core/Models/Entities/Posts/ListItem.cs @@ -0,0 +1,41 @@ +using OF_DL.Models.Entities.Common; +using OF_DL.Utils; + +namespace OF_DL.Models.Entities.Posts; + +public class ListItem +{ + private string _rawText = ""; + + public long Id { get; set; } + + public DateTime PostedAt { get; set; } + + public Author? Author { get; set; } + + public string Text { get; set; } = ""; + + public string RawText + { + get + { + if (string.IsNullOrEmpty(_rawText)) + { + _rawText = XmlUtils.EvaluateInnerText(Text); + } + + return _rawText; + } + set => _rawText = value; + } + + public bool IsOpened { get; set; } + + public string? Price { get; set; } + + public bool IsArchived { get; set; } + + public List? Media { get; set; } + + public List? Preview { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Posts/Medium.cs b/OF DL.Core/Models/Entities/Posts/Medium.cs new file mode 100644 index 0000000..88eab10 --- /dev/null +++ b/OF DL.Core/Models/Entities/Posts/Medium.cs @@ -0,0 +1,18 @@ +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Entities.Posts; + +public class Medium +{ + public long Id { get; set; } + + public string Type { get; set; } = ""; + + public bool CanView { get; set; } + + public string? Preview { get; set; } + + public Files? Files { get; set; } + + public VideoSources? VideoSources { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Posts/Post.cs b/OF DL.Core/Models/Entities/Posts/Post.cs new file mode 100644 index 0000000..d914824 --- /dev/null +++ b/OF DL.Core/Models/Entities/Posts/Post.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Posts; + +public class Post +{ + public List List { get; set; } = []; + + public bool HasMore { get; set; } + + public string? TailMarker { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Posts/PostCollection.cs b/OF DL.Core/Models/Entities/Posts/PostCollection.cs new file mode 100644 index 0000000..c5cecba --- /dev/null +++ b/OF DL.Core/Models/Entities/Posts/PostCollection.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Posts; + +public class PostCollection +{ + public List PostMedia { get; set; } = []; + + public List PostObjects { get; set; } = []; + + public Dictionary Posts { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Posts/SinglePost.cs b/OF DL.Core/Models/Entities/Posts/SinglePost.cs new file mode 100644 index 0000000..9875864 --- /dev/null +++ b/OF DL.Core/Models/Entities/Posts/SinglePost.cs @@ -0,0 +1,41 @@ +using OF_DL.Models.Entities.Common; +using OF_DL.Utils; + +namespace OF_DL.Models.Entities.Posts; + +public class SinglePost +{ + private string _rawText = ""; + + public long Id { get; set; } + + public DateTime PostedAt { get; set; } + + public Author? Author { get; set; } + + public string Text { get; set; } = ""; + + public string RawText + { + get + { + if (string.IsNullOrEmpty(_rawText)) + { + _rawText = XmlUtils.EvaluateInnerText(Text); + } + + return _rawText; + } + set => _rawText = value; + } + + public bool IsOpened { get; set; } + + public string? Price { get; set; } + + public bool IsArchived { get; set; } + + public List? Media { get; set; } + + public List? Preview { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Posts/SinglePostCollection.cs b/OF DL.Core/Models/Entities/Posts/SinglePostCollection.cs new file mode 100644 index 0000000..9afc43c --- /dev/null +++ b/OF DL.Core/Models/Entities/Posts/SinglePostCollection.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Posts; + +public class SinglePostCollection +{ + public List SinglePostMedia { get; set; } = []; + + public List SinglePostObjects { get; set; } = []; + + public Dictionary SinglePosts { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Purchased/ListItem.cs b/OF DL.Core/Models/Entities/Purchased/ListItem.cs new file mode 100644 index 0000000..9e21aca --- /dev/null +++ b/OF DL.Core/Models/Entities/Purchased/ListItem.cs @@ -0,0 +1,33 @@ +using OF_DL.Models.Entities.Common; +using MessageEntities = OF_DL.Models.Entities.Messages; + +namespace OF_DL.Models.Entities.Purchased; + +public class ListItem +{ + public string ResponseType { get; set; } = ""; + + public string? Text { get; set; } + + public string? Price { get; set; } + + public List? Media { get; set; } + + public List? Previews { get; set; } + + public List? Preview { get; set; } + + public FromUser? FromUser { get; set; } + + public Author? Author { get; set; } + + public long Id { get; set; } + + public bool IsOpened { get; set; } + + public DateTime? CreatedAt { get; set; } + + public DateTime? PostedAt { get; set; } + + public bool? IsArchived { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Purchased/PaidMessageCollection.cs b/OF DL.Core/Models/Entities/Purchased/PaidMessageCollection.cs new file mode 100644 index 0000000..dd1435f --- /dev/null +++ b/OF DL.Core/Models/Entities/Purchased/PaidMessageCollection.cs @@ -0,0 +1,12 @@ +using MessageEntities = OF_DL.Models.Entities.Messages; + +namespace OF_DL.Models.Entities.Purchased; + +public class PaidMessageCollection +{ + public List PaidMessageMedia { get; set; } = []; + + public List PaidMessageObjects { get; set; } = []; + + public Dictionary PaidMessages { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Purchased/PaidPostCollection.cs b/OF DL.Core/Models/Entities/Purchased/PaidPostCollection.cs new file mode 100644 index 0000000..55b8e7b --- /dev/null +++ b/OF DL.Core/Models/Entities/Purchased/PaidPostCollection.cs @@ -0,0 +1,12 @@ +using MessageEntities = OF_DL.Models.Entities.Messages; + +namespace OF_DL.Models.Entities.Purchased; + +public class PaidPostCollection +{ + public List PaidPostMedia { get; set; } = []; + + public List PaidPostObjects { get; set; } = []; + + public Dictionary PaidPosts { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Purchased/Purchased.cs b/OF DL.Core/Models/Entities/Purchased/Purchased.cs new file mode 100644 index 0000000..fc80ec9 --- /dev/null +++ b/OF DL.Core/Models/Entities/Purchased/Purchased.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Models.Entities.Purchased; + +public class Purchased +{ + public List List { get; set; } = []; + + public bool HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Purchased/PurchasedTabCollection.cs b/OF DL.Core/Models/Entities/Purchased/PurchasedTabCollection.cs new file mode 100644 index 0000000..e64eb86 --- /dev/null +++ b/OF DL.Core/Models/Entities/Purchased/PurchasedTabCollection.cs @@ -0,0 +1,12 @@ +namespace OF_DL.Models.Entities.Purchased; + +public class PurchasedTabCollection +{ + public long UserId { get; set; } + + public string Username { get; set; } = ""; + + public PaidPostCollection PaidPosts { get; set; } = new(); + + public PaidMessageCollection PaidMessages { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Purchased/SinglePaidMessageCollection.cs b/OF DL.Core/Models/Entities/Purchased/SinglePaidMessageCollection.cs new file mode 100644 index 0000000..66e0f5a --- /dev/null +++ b/OF DL.Core/Models/Entities/Purchased/SinglePaidMessageCollection.cs @@ -0,0 +1,17 @@ +using MessageEntities = OF_DL.Models.Entities.Messages; + +namespace OF_DL.Models.Entities.Purchased; + +public class SinglePaidMessageCollection +{ + public List PreviewSingleMessageMedia { get; set; } = []; + + + public Dictionary PreviewSingleMessages { get; set; } = new(); + + public List SingleMessageMedia { get; set; } = []; + + public List SingleMessageObjects { get; set; } = []; + + public Dictionary SingleMessages { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Stories/Medium.cs b/OF DL.Core/Models/Entities/Stories/Medium.cs new file mode 100644 index 0000000..20e3f83 --- /dev/null +++ b/OF DL.Core/Models/Entities/Stories/Medium.cs @@ -0,0 +1,16 @@ +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Entities.Stories; + +public class Medium +{ + public long Id { get; set; } + + public string? Type { get; set; } + + public bool CanView { get; set; } + + public DateTime? CreatedAt { get; set; } + + public Files Files { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Stories/Stories.cs b/OF DL.Core/Models/Entities/Stories/Stories.cs new file mode 100644 index 0000000..65d06d5 --- /dev/null +++ b/OF DL.Core/Models/Entities/Stories/Stories.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Stories; + +public class Stories +{ + public long Id { get; set; } + + public DateTime? CreatedAt { get; set; } + + public List Media { get; set; } = []; +} diff --git a/OF DL.Core/Models/Entities/Streams/ListItem.cs b/OF DL.Core/Models/Entities/Streams/ListItem.cs new file mode 100644 index 0000000..05bd254 --- /dev/null +++ b/OF DL.Core/Models/Entities/Streams/ListItem.cs @@ -0,0 +1,41 @@ +using OF_DL.Models.Entities.Common; +using OF_DL.Utils; + +namespace OF_DL.Models.Entities.Streams; + +public class ListItem +{ + private string _rawText = ""; + + public long Id { get; set; } + + public DateTime PostedAt { get; set; } + + public Author? Author { get; set; } + + public string Text { get; set; } = ""; + + public string RawText + { + get + { + if (string.IsNullOrEmpty(_rawText)) + { + _rawText = XmlUtils.EvaluateInnerText(Text); + } + + return _rawText; + } + set => _rawText = value; + } + + public bool IsOpened { get; set; } + + public string? Price { get; set; } + + public bool IsArchived { get; set; } + + public List? Media { get; set; } + + public List? Preview { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Streams/Medium.cs b/OF DL.Core/Models/Entities/Streams/Medium.cs new file mode 100644 index 0000000..0dbb768 --- /dev/null +++ b/OF DL.Core/Models/Entities/Streams/Medium.cs @@ -0,0 +1,14 @@ +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Entities.Streams; + +public class Medium +{ + public long Id { get; set; } + + public string? Type { get; set; } + + public bool CanView { get; set; } + + public Files? Files { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Streams/Streams.cs b/OF DL.Core/Models/Entities/Streams/Streams.cs new file mode 100644 index 0000000..55646fb --- /dev/null +++ b/OF DL.Core/Models/Entities/Streams/Streams.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Streams; + +public class Streams +{ + public List List { get; set; } = []; + + public bool HasMore { get; set; } + + public string? TailMarker { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Streams/StreamsCollection.cs b/OF DL.Core/Models/Entities/Streams/StreamsCollection.cs new file mode 100644 index 0000000..3355d01 --- /dev/null +++ b/OF DL.Core/Models/Entities/Streams/StreamsCollection.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Models.Entities.Streams; + +public class StreamsCollection +{ + public List StreamMedia { get; set; } = []; + public List StreamObjects { get; set; } = []; + public Dictionary Streams { get; set; } = new(); +} diff --git a/OF DL.Core/Models/Entities/Subscriptions/ListItem.cs b/OF DL.Core/Models/Entities/Subscriptions/ListItem.cs new file mode 100644 index 0000000..a9fe450 --- /dev/null +++ b/OF DL.Core/Models/Entities/Subscriptions/ListItem.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Models.Entities.Subscriptions; + +public class ListItem +{ + public string Username { get; set; } = ""; + + public bool? IsRestricted { get; set; } + + public long Id { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Subscriptions/Subscriptions.cs b/OF DL.Core/Models/Entities/Subscriptions/Subscriptions.cs new file mode 100644 index 0000000..4b8dfb2 --- /dev/null +++ b/OF DL.Core/Models/Entities/Subscriptions/Subscriptions.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Models.Entities.Subscriptions; + +public class Subscriptions +{ + public List List { get; set; } = []; + + public bool HasMore { get; set; } +} diff --git a/OF DL.Core/Models/Entities/Users/User.cs b/OF DL.Core/Models/Entities/Users/User.cs new file mode 100644 index 0000000..270cf25 --- /dev/null +++ b/OF DL.Core/Models/Entities/Users/User.cs @@ -0,0 +1,12 @@ +namespace OF_DL.Models.Entities.Users; + +public class User +{ + public string? Avatar { get; set; } + + public string? Header { get; set; } + + public string? Name { get; set; } + + public string? Username { get; set; } +} diff --git a/OF DL.Core/Models/Mappers/ArchivedMapper.cs b/OF DL.Core/Models/Mappers/ArchivedMapper.cs new file mode 100644 index 0000000..ab722c6 --- /dev/null +++ b/OF DL.Core/Models/Mappers/ArchivedMapper.cs @@ -0,0 +1,56 @@ +using OF_DL.Models.Dtos.Archived; +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Entities.Archived; + +namespace OF_DL.Models.Mappers; + +public static class ArchivedMapper +{ + public static Archived FromDto(ArchivedDto? dto) + { + Archived mapped = new() { HasMore = dto?.HasMore ?? false, TailMarker = dto?.TailMarker }; + + if (dto?.List == null) + { + return mapped; + } + + foreach (ListItemDto entry in dto.List) + { + mapped.List.Add(MapList(entry)); + } + + return mapped; + } + + private static ListItem MapList(ListItemDto dto) => + new() + { + Id = dto.Id, + PostedAt = dto.PostedAt, + Author = CommonMapper.MapAuthor(dto.Author), + Text = dto.Text, + Price = dto.Price, + IsOpened = dto.IsOpened, + IsArchived = dto.IsArchived, + Media = MapMedia(dto.Media), + Preview = dto.Preview + }; + + private static List? MapMedia(List? media) => + media == null ? null : media.Select(MapMedium).ToList(); + + private static Medium MapMedium(MediumDto dto) => + new() + { + Id = dto.Id, + Type = dto.Type, + CanView = dto.CanView, + Files = MapFiles(dto.Files), + Preview = dto.Preview + }; + + private static Entities.Common.Files? MapFiles(FilesDto? dto) => dto == null + ? null + : new Entities.Common.Files { Full = CommonMapper.MapFull(dto.Full), Drm = CommonMapper.MapDrm(dto.Drm) }; +} diff --git a/OF DL.Core/Models/Mappers/CommonMapper.cs b/OF DL.Core/Models/Mappers/CommonMapper.cs new file mode 100644 index 0000000..3312a2f --- /dev/null +++ b/OF DL.Core/Models/Mappers/CommonMapper.cs @@ -0,0 +1,115 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Entities.Common; + +namespace OF_DL.Models.Mappers; + +public static class CommonMapper +{ + public static Author? MapAuthor(AuthorDto? dto) + { + if (dto == null || dto.Id == 0) + { + return null; + } + + return new Author { Id = dto.Id }; + } + + private static Dash? MapDash(DashDto? dto) + { + if (dto == null || ( + string.IsNullOrEmpty(dto.CloudFrontPolicy) && + string.IsNullOrEmpty(dto.CloudFrontSignature) && + string.IsNullOrEmpty(dto.CloudFrontKeyPairId))) + { + return null; + } + + return new Dash + { + CloudFrontPolicy = dto.CloudFrontPolicy, + CloudFrontSignature = dto.CloudFrontSignature, + CloudFrontKeyPairId = dto.CloudFrontKeyPairId + }; + } + + public static Drm? MapDrm(DrmDto? dto) + { + if (dto == null || (dto.Manifest == new ManifestDto() && dto.Signature == new SignatureDto())) + { + return null; + } + + return new Drm { Manifest = MapManifest(dto.Manifest), Signature = MapSignature(dto.Signature) }; + } + + public static Files? MapFiles(FilesDto? dto) + { + if (dto == null) + { + return null; + } + + Full? full = MapFull(dto.Full); + Preview? preview = MapPreview(dto.Preview); + Drm? drm = MapDrm(dto.Drm); + + if (full == null && preview == null && drm == null) + { + return null; + } + + return new Files { Full = MapFull(dto.Full), Preview = MapPreview(dto.Preview), Drm = MapDrm(dto.Drm) }; + } + + public static Full? MapFull(FullDto? dto) + { + if (dto == null || string.IsNullOrEmpty(dto.Url)) + { + return null; + } + + return new Full { Url = dto.Url }; + } + + public static Manifest? MapManifest(ManifestDto? dto) + { + if (dto == null || string.IsNullOrEmpty(dto.Dash)) + { + return null; + } + + return new Manifest { Dash = dto.Dash }; + } + + public static Preview? MapPreview(PreviewDto? dto) + { + if (dto == null || string.IsNullOrEmpty(dto.Url)) + { + return null; + } + + return new Preview { Url = dto.Url }; + } + + public static Signature? MapSignature(SignatureDto? dto) + { + if (dto == null) + { + return null; + } + + Dash? dash = MapDash(dto.Dash); + return dash == null ? null : new Signature { Dash = dash }; + } + + public static VideoSources? MapVideoSources(VideoSourcesDto? dto) + { + if (dto == null || (string.IsNullOrEmpty(dto._240) && string.IsNullOrEmpty(dto._720))) + { + return null; + } + + return new VideoSources { _240 = dto._240, _720 = dto._720 }; + } +} diff --git a/OF DL.Core/Models/Mappers/HighlightsMapper.cs b/OF DL.Core/Models/Mappers/HighlightsMapper.cs new file mode 100644 index 0000000..60efa17 --- /dev/null +++ b/OF DL.Core/Models/Mappers/HighlightsMapper.cs @@ -0,0 +1,64 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Highlights; +using OF_DL.Models.Entities.Common; +using OF_DL.Models.Entities.Highlights; + +namespace OF_DL.Models.Mappers; + +public static class HighlightsMapper +{ + public static Highlights FromDto(HighlightsDto? dto) + { + Highlights mapped = new() { HasMore = dto?.HasMore ?? false }; + + if (dto?.List == null) + { + return mapped; + } + + foreach (ListItemDto entry in dto.List) + { + mapped.List.Add(MapListItem(entry)); + } + + return mapped; + } + + public static HighlightMedia FromDto(HighlightMediaDto? dto) + { + HighlightMedia mapped = new(); + + if (dto?.Stories == null) + { + return mapped; + } + + foreach (StoryDto story in dto.Stories) + { + mapped.Stories.Add(MapStory(story)); + } + + return mapped; + } + + private static ListItem MapListItem(ListItemDto dto) => new() { Id = dto.Id }; + + private static Story MapStory(StoryDto dto) => + new() { Id = dto.Id, CreatedAt = dto.CreatedAt, Media = MapMedia(dto.Media) }; + + private static List? MapMedia(List? media) => + media?.Select(MapMedium).ToList(); + + private static Medium MapMedium(MediumDto dto) => + new() + { + Id = dto.Id, + Type = dto.Type, + CanView = dto.CanView, + CreatedAt = dto.CreatedAt, + Files = MapFiles(dto.Files) + }; + + private static Files? MapFiles(FilesDto? dto) => + dto == null ? null : new Files { Full = CommonMapper.MapFull(dto.Full) }; +} diff --git a/OF DL.Core/Models/Mappers/MessagesMapper.cs b/OF DL.Core/Models/Mappers/MessagesMapper.cs new file mode 100644 index 0000000..c416ab1 --- /dev/null +++ b/OF DL.Core/Models/Mappers/MessagesMapper.cs @@ -0,0 +1,79 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Messages; +using OF_DL.Models.Entities.Common; +using MessageEntities = OF_DL.Models.Entities.Messages; + +namespace OF_DL.Models.Mappers; + +public static class MessagesMapper +{ + public static MessageEntities.Messages FromDto(MessagesDto? dto) + { + MessageEntities.Messages mapped = new() { HasMore = dto?.HasMore ?? false }; + + if (dto?.List == null) + { + return mapped; + } + + foreach (ListItemDto entry in dto.List) + { + mapped.List.Add(MapListItem(entry)); + } + + return mapped; + } + + public static MessageEntities.SingleMessage FromDto(SingleMessageDto? dto) + { + MessageEntities.SingleMessage mapped = new(); + + if (dto == null) + { + return mapped; + } + + mapped.Id = dto.Id; + mapped.Text = dto.Text; + mapped.Price = dto.Price; + mapped.CreatedAt = dto.CreatedAt; + mapped.Media = MapMedia(dto.Media); + mapped.Previews = dto.Previews; + mapped.FromUser = MapFromUser(dto.FromUser); + + return mapped; + } + + public static MessageEntities.Medium MapMedium(MediumDto dto) => + new() { Id = dto.Id, Type = dto.Type, CanView = dto.CanView, Files = MapFiles(dto.Files) }; + + private static MessageEntities.ListItem MapListItem(ListItemDto dto) => + new() + { + Id = dto.Id, + Text = dto.Text, + Price = dto.Price, + CanPurchaseReason = dto.CanPurchaseReason, + CreatedAt = dto.CreatedAt, + Media = MapMedia(dto.Media), + Previews = dto.Previews, + FromUser = MapFromUser(dto.FromUser) + }; + + private static List? MapMedia(List? media) => + media?.Select(MapMedium).ToList(); + + private static FromUser? MapFromUser(FromUserDto? dto) + { + if (dto?.Id == null || dto.Id == 0) + { + return null; + } + + return new FromUser { Id = dto.Id.Value }; + } + + private static Files? MapFiles(FilesDto? dto) => dto == null + ? null + : new Files { Full = CommonMapper.MapFull(dto.Full), Drm = CommonMapper.MapDrm(dto.Drm) }; +} diff --git a/OF DL.Core/Models/Mappers/PostMapper.cs b/OF DL.Core/Models/Mappers/PostMapper.cs new file mode 100644 index 0000000..1634ce8 --- /dev/null +++ b/OF DL.Core/Models/Mappers/PostMapper.cs @@ -0,0 +1,116 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Posts; +using OF_DL.Models.Entities.Common; +using OF_DL.Models.Entities.Posts; + + +namespace OF_DL.Models.Mappers; + +public static class PostMapper +{ + public static Post FromDto(PostDto? dto) + { + Post mapped = new() { HasMore = dto?.HasMore ?? false, TailMarker = dto?.TailMarker }; + + if (dto?.List == null) + { + return mapped; + } + + foreach (ListItemDto entry in dto.List) + { + mapped.List.Add(MapList(entry)); + } + + return mapped; + } + + public static SinglePost FromDto(SinglePostDto? dto) + { + SinglePost mapped = new(); + + if (dto == null) + { + return mapped; + } + + mapped.Id = dto.Id; + mapped.PostedAt = dto.PostedAt; + mapped.Author = CommonMapper.MapAuthor(dto.Author); + mapped.Text = dto.Text; + mapped.RawText = dto.RawText; + mapped.IsOpened = dto.IsOpened; + mapped.Price = dto.Price; + mapped.IsArchived = dto.IsArchived; + mapped.Media = MapSingleMedia(dto.Media); + mapped.Preview = dto.Preview; + + return mapped; + } + + private static ListItem MapList(ListItemDto dto) => + new() + { + Id = dto.Id, + PostedAt = dto.PostedAt, + Author = CommonMapper.MapAuthor(dto.Author), + Text = dto.Text, + RawText = dto.RawText, + IsOpened = dto.IsOpened, + Price = dto.Price, + IsArchived = dto.IsArchived, + Media = MapMedia(dto.Media), + Preview = dto.Preview + }; + + private static List? MapMedia(List? media) => + media?.Select(MapMedium).ToList(); + + private static Medium MapMedium(MediumDto dto) => + new() + { + Id = dto.Id, + Type = dto.Type, + CanView = dto.CanView, + Preview = dto.Preview, + Files = MapFiles(dto.Files) + }; + + private static Files? MapFiles(FilesDto? dto) => dto == null + ? null + : new Files + { + Full = CommonMapper.MapFull(dto.Full), + Preview = CommonMapper.MapPreview(dto.Preview), + Drm = CommonMapper.MapDrm(dto.Drm) + }; + + private static List? MapSingleMedia(List? media) => + media?.Select(MapSingleMedium).ToList(); + + private static Medium MapSingleMedium(MediumDto dto) => + new() + { + Id = dto.Id, + Type = dto.Type, + CanView = dto.CanView, + Preview = dto.Preview, + Files = MapSingleFiles(dto.Files), + VideoSources = CommonMapper.MapVideoSources(dto.VideoSources) + }; + + private static Files? MapSingleFiles(FilesDto? dto) + { + if (dto == null) + { + return null; + } + + return new Files + { + Full = CommonMapper.MapFull(dto.Full), + Preview = CommonMapper.MapPreview(dto.Preview), + Drm = CommonMapper.MapDrm(dto.Drm) + }; + } +} diff --git a/OF DL.Core/Models/Mappers/PurchasedMapper.cs b/OF DL.Core/Models/Mappers/PurchasedMapper.cs new file mode 100644 index 0000000..9ace9e5 --- /dev/null +++ b/OF DL.Core/Models/Mappers/PurchasedMapper.cs @@ -0,0 +1,59 @@ +using OF_DL.Models.Dtos.Purchased; +using MessageDtos = OF_DL.Models.Dtos.Messages; +using CommonEntities = OF_DL.Models.Entities.Common; +using MessageEntities = OF_DL.Models.Entities.Messages; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; + + +namespace OF_DL.Models.Mappers; + +public static class PurchasedMapper +{ + public static PurchasedEntities.Purchased FromDto(PurchasedDto? dto) + { + PurchasedEntities.Purchased mapped = new() { HasMore = dto?.HasMore ?? false }; + + if (dto?.List == null) + { + return mapped; + } + + foreach (ListItemDto entry in dto.List) + { + mapped.List.Add(MapList(entry)); + } + + return mapped; + } + + private static PurchasedEntities.ListItem MapList(ListItemDto dto) => + new() + { + ResponseType = dto.ResponseType, + Text = dto.Text, + Price = dto.Price, + IsOpened = dto.IsOpened, + IsArchived = dto.IsArchived, + CreatedAt = dto.CreatedAt, + PostedAt = dto.PostedAt, + Id = dto.Id, + Media = MapMedia(dto.Media), + Previews = dto.Previews, + Preview = dto.Preview, + FromUser = MapFromUser(dto.FromUser), + Author = CommonMapper.MapAuthor(dto.Author) + }; + + private static CommonEntities.FromUser? MapFromUser(FromUserDto? dto) + { + if (dto == null || dto.Id == 0) + { + return null; + } + + return new CommonEntities.FromUser { Id = dto.Id }; + } + + private static List? MapMedia(List? media) => + media?.Select(MessagesMapper.MapMedium).ToList(); +} diff --git a/OF DL.Core/Models/Mappers/StoriesMapper.cs b/OF DL.Core/Models/Mappers/StoriesMapper.cs new file mode 100644 index 0000000..70ef4aa --- /dev/null +++ b/OF DL.Core/Models/Mappers/StoriesMapper.cs @@ -0,0 +1,31 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Stories; +using OF_DL.Models.Entities.Common; +using OF_DL.Models.Entities.Stories; + +namespace OF_DL.Models.Mappers; + +public static class StoriesMapper +{ + public static List FromDto(List? dto) => + dto == null ? [] : dto.Select(MapStory).ToList(); + + private static Stories MapStory(StoryDto dto) => + new() { Id = dto.Id, CreatedAt = dto.CreatedAt, Media = MapMedia(dto.Media) }; + + private static List MapMedia(List? media) => + media == null ? [] : media.Select(MapMedium).ToList(); + + private static Medium MapMedium(MediumDto dto) => + new() + { + Id = dto.Id, + Type = dto.Type, + CanView = dto.CanView, + CreatedAt = dto.CreatedAt, + Files = MapFiles(dto.Files) + }; + + private static Files MapFiles(FilesDto? dto) => + new() { Full = CommonMapper.MapFull(dto?.Full) }; +} diff --git a/OF DL.Core/Models/Mappers/StreamsMapper.cs b/OF DL.Core/Models/Mappers/StreamsMapper.cs new file mode 100644 index 0000000..caa61ee --- /dev/null +++ b/OF DL.Core/Models/Mappers/StreamsMapper.cs @@ -0,0 +1,77 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Streams; +using OF_DL.Models.Entities.Common; +using OF_DL.Models.Entities.Streams; + +namespace OF_DL.Models.Mappers; + +public static class StreamsMapper +{ + public static Streams FromDto(StreamsDto? dto) + { + Streams mapped = new() { HasMore = dto?.HasMore ?? false, TailMarker = dto?.TailMarker }; + + if (dto?.List == null) + { + return mapped; + } + + foreach (ListItemDto entry in dto.List) + { + mapped.List.Add(MapList(entry)); + } + + return mapped; + } + + private static ListItem MapList(ListItemDto dto) => + new() + { + Id = dto.Id, + PostedAt = dto.PostedAt, + Author = CommonMapper.MapAuthor(dto.Author), + Text = dto.Text, + RawText = dto.RawText, + Price = dto.Price, + IsOpened = dto.IsOpened ?? false, + IsArchived = dto.IsArchived ?? false, + Media = MapMedia(dto.Media), + Preview = dto.Preview + }; + + private static List? MapMedia(List? media) => + media?.Select(MapMedium).ToList(); + + private static Medium MapMedium(MediumDto dto) => + new() { Id = dto.Id, Type = dto.Type, CanView = dto.CanView, Files = MapFiles(dto.Files) }; + + private static Files? MapFiles(FilesDto? dto) + { + if (dto == null) + { + return null; + } + + Full? full = CommonMapper.MapFull(dto.Full); + Drm? drm = MapDrm(dto.Drm); + + if (full == null && drm == null) + { + return null; + } + + return new Files { Full = full, Drm = drm }; + } + + private static Drm? MapDrm(DrmDto? dto) + { + Manifest? manifest = CommonMapper.MapManifest(dto?.Manifest); + if (manifest == null) + { + return null; + } + + Signature? signature = CommonMapper.MapSignature(dto?.Signature); + return signature == null ? null : new Drm { Manifest = manifest, Signature = signature }; + } +} diff --git a/OF DL.Core/Models/Mappers/SubscriptionsMapper.cs b/OF DL.Core/Models/Mappers/SubscriptionsMapper.cs new file mode 100644 index 0000000..9c9f2fd --- /dev/null +++ b/OF DL.Core/Models/Mappers/SubscriptionsMapper.cs @@ -0,0 +1,27 @@ +using OF_DL.Models.Dtos.Subscriptions; +using OF_DL.Models.Entities.Subscriptions; + +namespace OF_DL.Models.Mappers; + +public static class SubscriptionsMapper +{ + public static Subscriptions FromDto(SubscriptionsDto? dto) + { + Subscriptions mapped = new() { HasMore = dto?.HasMore ?? false }; + + if (dto?.List == null) + { + return mapped; + } + + foreach (ListItemDto entry in dto.List) + { + mapped.List.Add(MapList(entry)); + } + + return mapped; + } + + private static ListItem MapList(ListItemDto dto) => + new() { Id = dto.Id, Username = dto.Username ?? "", IsRestricted = dto.IsRestricted }; +} diff --git a/OF DL.Core/Models/Mappers/UserListsMapper.cs b/OF DL.Core/Models/Mappers/UserListsMapper.cs new file mode 100644 index 0000000..7f0557a --- /dev/null +++ b/OF DL.Core/Models/Mappers/UserListsMapper.cs @@ -0,0 +1,33 @@ +using OF_DL.Models.Dtos.Lists; +using OF_DL.Models.Entities.Lists; + +namespace OF_DL.Models.Mappers; + +public static class UserListsMapper +{ + public static UserList FromDto(UserListDto? dto) + { + UserList mapped = new() { HasMore = dto?.HasMore ?? false }; + + if (dto?.List == null) + { + return mapped; + } + + foreach (UserListItemDto entry in dto.List) + { + mapped.List.Add(MapListItem(entry)); + } + + return mapped; + } + + public static List FromDto(List? dto) => + dto == null ? [] : dto.Select(MapUsersList).ToList(); + + private static UserListItem MapListItem(UserListItemDto dto) => + new() { Id = dto.Id, Name = dto.Name }; + + private static UsersList MapUsersList(UsersListDto dto) => + new() { Username = dto.Username }; +} diff --git a/OF DL.Core/Models/Mappers/UserMapper.cs b/OF DL.Core/Models/Mappers/UserMapper.cs new file mode 100644 index 0000000..c724b20 --- /dev/null +++ b/OF DL.Core/Models/Mappers/UserMapper.cs @@ -0,0 +1,11 @@ +using OF_DL.Models.Dtos.Users; +using OF_DL.Models.Entities.Users; + +namespace OF_DL.Models.Mappers; + +public static class UserMapper +{ + public static User? FromDto(UserDto? dto) => dto == null + ? null + : new User { Avatar = dto.Avatar, Header = dto.Header, Name = dto.Name, Username = dto.Username }; +} diff --git a/OF DL.Core/Models/OfdlApi/DynamicRules.cs b/OF DL.Core/Models/OfdlApi/DynamicRules.cs new file mode 100644 index 0000000..ccdd635 --- /dev/null +++ b/OF DL.Core/Models/OfdlApi/DynamicRules.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.OfdlApi; + +public class DynamicRules +{ + [JsonProperty(PropertyName = "app-token")] + public string? AppToken { get; set; } + + [JsonProperty(PropertyName = "app_token")] + private string AppToken2 + { + set => AppToken = value; + } + + [JsonProperty(PropertyName = "static_param")] + public string? StaticParam { get; set; } + + [JsonProperty(PropertyName = "prefix")] + public string? Prefix { get; set; } + + [JsonProperty(PropertyName = "suffix")] + public string? Suffix { get; set; } + + [JsonProperty(PropertyName = "checksum_constant")] + public int? ChecksumConstant { get; set; } + + [JsonProperty(PropertyName = "checksum_indexes")] + public List ChecksumIndexes { get; set; } = []; +} diff --git a/OF DL/Entities/LatestReleaseAPIResponse.cs b/OF DL.Core/Models/OfdlApi/LatestReleaseApiResponse.cs similarity index 51% rename from OF DL/Entities/LatestReleaseAPIResponse.cs rename to OF DL.Core/Models/OfdlApi/LatestReleaseApiResponse.cs index 1081765..4197a89 100644 --- a/OF DL/Entities/LatestReleaseAPIResponse.cs +++ b/OF DL.Core/Models/OfdlApi/LatestReleaseApiResponse.cs @@ -1,8 +1,8 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; -namespace OF_DL.Entities; +namespace OF_DL.Models.OfdlApi; -public class LatestReleaseAPIResponse +public class LatestReleaseApiResponse { [JsonProperty(PropertyName = "tag_name")] public string TagName { get; set; } = ""; diff --git a/OF DL.Core/Models/OfdlApi/OFDLRequest.cs b/OF DL.Core/Models/OfdlApi/OFDLRequest.cs new file mode 100644 index 0000000..0f943e2 --- /dev/null +++ b/OF DL.Core/Models/OfdlApi/OFDLRequest.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace OF_DL.Models.OfdlApi; + +public class OfdlRequest +{ + [JsonProperty("pssh")] public string Pssh { get; set; } = ""; + + [JsonProperty("licenceURL")] public string LicenseUrl { get; set; } = ""; + + [JsonProperty("headers")] public string Headers { get; set; } = ""; +} diff --git a/OF DL.Core/Models/StartupResult.cs b/OF DL.Core/Models/StartupResult.cs new file mode 100644 index 0000000..6b67cd0 --- /dev/null +++ b/OF DL.Core/Models/StartupResult.cs @@ -0,0 +1,39 @@ +namespace OF_DL.Models; + +public class StartupResult +{ + public bool IsWindowsVersionValid { get; set; } = true; + + public string? OsVersionString { get; set; } + + public bool FfmpegFound { get; set; } + + public bool FfmpegPathAutoDetected { get; set; } + + public string? FfmpegPath { get; set; } + + public string? FfmpegVersion { get; set; } + + public bool ClientIdBlobMissing { get; set; } + + public bool DevicePrivateKeyMissing { get; set; } + + public bool RulesJsonValid { get; set; } + + public bool RulesJsonExists { get; set; } + + public string? RulesJsonError { get; set; } +} + +public class VersionCheckResult +{ + public Version? LocalVersion { get; set; } + + public Version? LatestVersion { get; set; } + + public bool IsUpToDate { get; set; } + + public bool CheckFailed { get; set; } + + public bool TimedOut { get; set; } +} diff --git a/OF DL.Core/OF DL.Core.csproj b/OF DL.Core/OF DL.Core.csproj new file mode 100644 index 0000000..2ef833a --- /dev/null +++ b/OF DL.Core/OF DL.Core.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + OF_DL + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/OF DL.Core/Services/ApiService.cs b/OF DL.Core/Services/ApiService.cs new file mode 100644 index 0000000..c6126bd --- /dev/null +++ b/OF DL.Core/Services/ApiService.cs @@ -0,0 +1,3058 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OF_DL.Models; +using OF_DL.Models.Entities.Common; +using OF_DL.Enumerations; +using OF_DL.Helpers; +using ArchivedDtos = OF_DL.Models.Dtos.Archived; +using HighlightDtos = OF_DL.Models.Dtos.Highlights; +using ListDtos = OF_DL.Models.Dtos.Lists; +using MessageDtos = OF_DL.Models.Dtos.Messages; +using PostDtos = OF_DL.Models.Dtos.Posts; +using PurchasedDtos = OF_DL.Models.Dtos.Purchased; +using StoriesDtos = OF_DL.Models.Dtos.Stories; +using StreamsDtos = OF_DL.Models.Dtos.Streams; +using UserDtos = OF_DL.Models.Dtos.Users; +using SubscriptionsDtos = OF_DL.Models.Dtos.Subscriptions; +using ArchivedEntities = OF_DL.Models.Entities.Archived; +using HighlightEntities = OF_DL.Models.Entities.Highlights; +using ListEntities = OF_DL.Models.Entities.Lists; +using MessageEntities = OF_DL.Models.Entities.Messages; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; +using StoryEntities = OF_DL.Models.Entities.Stories; +using StreamEntities = OF_DL.Models.Entities.Streams; +using SubscriptionEntities = OF_DL.Models.Entities.Subscriptions; +using UserEntities = OF_DL.Models.Entities.Users; +using OF_DL.Models.Mappers; +using OF_DL.Models.OfdlApi; +using OF_DL.Widevine; +using Serilog; +using static OF_DL.Utils.HttpUtil; +using Constants = OF_DL.Helpers.Constants; +using SinglePostCollection = OF_DL.Models.Entities.Posts.SinglePostCollection; + +namespace OF_DL.Services; + +public class ApiService(IAuthService authService, IConfigService configService, IDbService dbService) + : IApiService +{ + private const int MaxAttempts = 30; + private const int DelayBetweenAttempts = 3000; + private static readonly JsonSerializerSettings s_mJsonSerializerSettings; + private static DateTime? s_cachedDynamicRulesExpiration; + private static DynamicRules? s_cachedDynamicRules; + + static ApiService() => + s_mJsonSerializerSettings = new JsonSerializerSettings { MissingMemberHandling = MissingMemberHandling.Ignore }; + + + /// + /// Builds signed headers for API requests using dynamic rules. + /// + /// The API path. + /// The query string. + /// Signed headers required by the API. + public Dictionary GetDynamicHeaders(string path, string queryParams) + { + Log.Debug("Calling GetDynamicHeaders"); + Log.Debug("Path: {Path}", path); + Log.Debug("Query Params: {QueryParams}", queryParams); + + DynamicRules? root; + + //Check if we have a cached version of the dynamic rules + if (s_cachedDynamicRules != null && s_cachedDynamicRulesExpiration.HasValue && + DateTime.UtcNow < s_cachedDynamicRulesExpiration) + { + Log.Debug("Using cached dynamic rules"); + root = s_cachedDynamicRules; + } + else + { + // Get rules from GitHub and fallback to a local file + string? dynamicRulesJson = GetDynamicRules(); + if (!string.IsNullOrEmpty(dynamicRulesJson)) + { + Log.Debug("Using dynamic rules from GitHub"); + root = JsonConvert.DeserializeObject(dynamicRulesJson); + + // Cache the GitHub response for 15 minutes + s_cachedDynamicRules = root; + s_cachedDynamicRulesExpiration = DateTime.UtcNow.AddMinutes(15); + } + else + { + Log.Debug("Using dynamic rules from local file"); + root = JsonConvert.DeserializeObject(File.ReadAllText("rules.json")); + + // Cache the dynamic rules from a local file to prevent unnecessary disk + // operations and frequent call to GitHub. Since the GitHub dynamic rules + // are preferred to the local file, the cache time is shorter than when dynamic rules + // are successfully retrieved from GitHub. + s_cachedDynamicRules = root; + s_cachedDynamicRulesExpiration = DateTime.UtcNow.AddMinutes(5); + } + } + + if (root == null) + { + throw new Exception("Unable to parse dynamic rules. Root is null"); + } + + if (root.ChecksumConstant == null || root.ChecksumIndexes.Count == 0 || root.Prefix == null || + root.Suffix == null || root.AppToken == null) + { + throw new Exception("Invalid dynamic rules. Missing required fields"); + } + + if (authService.CurrentAuth == null) + { + throw new Exception("Auth service is null"); + } + + if (authService.CurrentAuth.UserId == null || authService.CurrentAuth.Cookie == null || + authService.CurrentAuth.UserAgent == null || authService.CurrentAuth.XBc == null) + { + throw new Exception("Auth service is missing required fields"); + } + + DateTimeOffset dto = DateTime.UtcNow; + long timestamp = dto.ToUnixTimeMilliseconds(); + + string input = $"{root.StaticParam}\n{timestamp}\n{path + queryParams}\n{authService.CurrentAuth.UserId}"; + byte[] inputBytes = Encoding.UTF8.GetBytes(input); + byte[] hashBytes = SHA1.HashData(inputBytes); + string hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); + + int checksum = root.ChecksumIndexes.Aggregate(0, (current, number) => current + hashString[number]) + + root.ChecksumConstant.Value; + string sign = $"{root.Prefix}:{hashString}:{checksum.ToString("X").ToLower()}:{root.Suffix}"; + + Dictionary headers = new() + { + { "accept", "application/json, text/plain" }, + { "app-token", root.AppToken }, + { "cookie", authService.CurrentAuth.Cookie }, + { "sign", sign }, + { "time", timestamp.ToString() }, + { "user-id", authService.CurrentAuth.UserId }, + { "user-agent", authService.CurrentAuth.UserAgent }, + { "x-bc", authService.CurrentAuth.XBc } + }; + return headers; + } + + private bool HasSignedRequestAuth() + { + Auth? currentAuth = authService.CurrentAuth; + return currentAuth is { UserId: not null, Cookie: not null, UserAgent: not null, XBc: not null }; + } + + + /// + /// Retrieves user information from the API. + /// + /// The user endpoint. + /// The user entity when available. + public async Task GetUserInfo(string endpoint) + { + Log.Debug($"Calling GetUserInfo: {endpoint}"); + + if (!HasSignedRequestAuth()) + { + return null; + } + + try + { + UserEntities.User user = new(); + Dictionary getParams = new() + { + { "limit", Constants.ApiPageSize.ToString() }, { "order", "publish_date_asc" } + }; + + HttpClient client = new(); + HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint); + + using HttpResponseMessage response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + return user; + } + + response.EnsureSuccessStatusCode(); + string body = await response.Content.ReadAsStringAsync(); + UserDtos.UserDto? userDto = + JsonConvert.DeserializeObject(body, s_mJsonSerializerSettings); + user = UserMapper.FromDto(userDto) ?? new UserEntities.User(); + return user; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return null; + } + + /// + /// Retrieves user information by ID. + /// + /// The user list endpoint. + /// A JSON object when available. + public async Task GetUserInfoById(string endpoint) + { + if (!HasSignedRequestAuth()) + { + return null; + } + + try + { + HttpClient client = new(); + HttpRequestMessage request = await BuildHttpRequestMessage(new Dictionary(), endpoint); + + using HttpResponseMessage response = await client.SendAsync(request); + + response.EnsureSuccessStatusCode(); + string body = await response.Content.ReadAsStringAsync(); + + // if the content creator doesn't exist, we get a 200 response, but the content isn't usable + // so let's not throw an exception, since "content creator no longer exists" is handled elsewhere + // which means we won't get loads of exceptions + if (body.Equals("[]")) + { + return null; + } + + JObject jObject = JObject.Parse(body); + + return jObject; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return null; + } + + /// + /// Retrieves active subscriptions. + /// + /// The subscriptions endpoint. + /// Whether to include restricted subscriptions. + /// A username-to-userId map. + public async Task?> GetActiveSubscriptions(string endpoint, bool includeRestricted) + { + Dictionary getParams = new() + { + { "offset", "0" }, { "limit", "50" }, { "type", "active" }, { "format", "infinite" } + }; + + return await GetAllSubscriptions(getParams, endpoint, includeRestricted); + } + + + /// + /// Retrieves expired subscriptions. + /// + /// The subscriptions endpoint. + /// Whether to include restricted subscriptions. + /// A username-to-userId map. + public async Task?> GetExpiredSubscriptions(string endpoint, bool includeRestricted) + { + Dictionary getParams = new() + { + { "offset", "0" }, { "limit", "50" }, { "type", "expired" }, { "format", "infinite" } + }; + + Log.Debug("Calling GetExpiredSubscriptions"); + + return await GetAllSubscriptions(getParams, endpoint, includeRestricted); + } + + + /// + /// Retrieves the user's lists. + /// + /// The lists endpoint. + /// A list name to list ID map. + public async Task?> GetLists(string endpoint) + { + Log.Debug("Calling GetLists"); + + if (!HasSignedRequestAuth()) + { + return null; + } + + try + { + int offset = 0; + Dictionary getParams = new() + { + { "offset", offset.ToString() }, + { "skip_users", "all" }, + { "limit", "50" }, + { "format", "infinite" } + }; + Dictionary lists = new(); + while (true) + { + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); + + if (body == null) + { + break; + } + + ListDtos.UserListDto? userListDto = JsonConvert.DeserializeObject(body); + ListEntities.UserList userList = UserListsMapper.FromDto(userListDto); + + foreach (ListEntities.UserListItem listItem in userList.List) + { + if (IsStringOnlyDigits(listItem.Id) && !lists.ContainsKey(listItem.Name)) + { + lists.Add(listItem.Name, Convert.ToInt32(listItem.Id)); + } + } + + if (userList.HasMore) + { + offset += 50; + getParams["offset"] = Convert.ToString(offset); + } + else + { + break; + } + } + + return lists; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return null; + } + + /// + /// Retrieves usernames in a specific list. + /// + /// The list users endpoint. + /// The usernames in the list. + public async Task?> GetListUsers(string endpoint) + { + Log.Debug($"Calling GetListUsers - {endpoint}"); + + if (!HasSignedRequestAuth()) + { + return null; + } + + try + { + int offset = 0; + Dictionary getParams = new() { { "offset", offset.ToString() }, { "limit", "50" } }; + List users = []; + + while (true) + { + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); + if (body == null) + { + break; + } + + List? usersListDto = + JsonConvert.DeserializeObject>(body); + List usersList = UserListsMapper.FromDto(usersListDto); + + if (usersList.Count <= 0) + { + break; + } + + users.AddRange(usersList.Select(ul => ul.Username)); + + if (users.Count < 50) + { + break; + } + + offset += 50; + getParams["offset"] = Convert.ToString(offset); + } + + return users; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return null; + } + + + /// + /// Retrieves media URLs for stories or highlights. + /// + /// The media type to fetch. + /// The endpoint to query. + /// Optional username context. + /// The creator folder path. + /// A mediaId-to-URL map. + public async Task?> GetMedia(MediaType mediatype, + string endpoint, + string? username, + string folder) + { + Log.Debug($"Calling GetMedia - {username}"); + + if (!HasSignedRequestAuth()) + { + return null; + } + + try + { + Dictionary returnUrls = new(); + const int limit = 5; + int offset = 0; + + Dictionary getParams = new(); + + switch (mediatype) + { + case MediaType.Stories: + getParams = new Dictionary + { + { "limit", Constants.ApiPageSize.ToString() }, + { "order", "publish_date_desc" }, + { "skip_users", "all" } + }; + break; + + case MediaType.Highlights: + getParams = new Dictionary + { + { "limit", limit.ToString() }, { "offset", offset.ToString() }, { "skip_users", "all" } + }; + break; + } + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); + + if (string.IsNullOrWhiteSpace(body)) + { + Log.Warning("GetMedia returned empty response for {Endpoint}", endpoint); + return returnUrls; + } + + if (mediatype == MediaType.Stories) + { + Log.Debug("Media Stories - " + endpoint); + + List? storiesDto = + DeserializeJson>(body, s_mJsonSerializerSettings); + List stories = StoriesMapper.FromDto(storiesDto); + + foreach (StoryEntities.Stories story in stories) + { + DateTime? storyCreatedAt = story.Media.Count > 0 ? story.Media[0].CreatedAt : null; + if (storyCreatedAt.HasValue) + { + await dbService.AddStory(folder, story.Id, "", "0", false, false, storyCreatedAt.Value); + } + else if (story.CreatedAt.HasValue) + { + await dbService.AddStory(folder, story.Id, "", "0", false, false, story.CreatedAt.Value); + } + else + { + await dbService.AddStory(folder, story.Id, "", "0", false, false, DateTime.Now); + } + + if (story.Media.Count > 0) + { + foreach (StoryEntities.Medium medium in story.Media) + { + string? mediaUrl = medium.Files.Full?.Url; + if (string.IsNullOrEmpty(mediaUrl)) + { + continue; + } + + string? mediaType = ResolveMediaType(medium.Type); + if (mediaType == null) + { + continue; + } + + await dbService.AddMedia(folder, medium.Id, story.Id, mediaUrl, null, null, null, + "Stories", mediaType, false, false, null); + + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (medium.CanView) + { + returnUrls.TryAdd(medium.Id, mediaUrl); + } + } + } + } + } + else if (mediatype == MediaType.Highlights) + { + List highlightIds = []; + HighlightDtos.HighlightsDto? highlightsDto = + DeserializeJson(body, s_mJsonSerializerSettings); + HighlightEntities.Highlights highlights = HighlightsMapper.FromDto(highlightsDto); + + if (highlights.HasMore) + { + offset += 5; + getParams["offset"] = offset.ToString(); + while (true) + { + Log.Debug("Media Highlights - " + endpoint); + + string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + if (string.IsNullOrWhiteSpace(loopbody)) + { + Log.Warning("Received empty body from API"); + break; + } + + HighlightDtos.HighlightsDto? newHighlightsDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + HighlightEntities.Highlights newHighlights = HighlightsMapper.FromDto(newHighlightsDto); + + highlights.List.AddRange(newHighlights.List); + if (!newHighlights.HasMore) + { + break; + } + + offset += 5; + getParams["offset"] = offset.ToString(); + } + } + + foreach (HighlightEntities.ListItem list in highlights.List) + { + if (!highlightIds.Contains(list.Id.ToString())) + { + highlightIds.Add(list.Id.ToString()); + } + } + + foreach (string highlightId in highlightIds) + { + Dictionary highlightHeaders = + GetDynamicHeaders("/api2/v2/stories/highlights/" + highlightId, ""); + + HttpClient highlightClient = GetHttpClient(); + + HttpRequestMessage highlightRequest = new(HttpMethod.Get, + $"https://onlyfans.com/api2/v2/stories/highlights/{highlightId}"); + + foreach (KeyValuePair keyValuePair in highlightHeaders) + { + highlightRequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); + } + + using HttpResponseMessage highlightResponse = await highlightClient.SendAsync(highlightRequest); + highlightResponse.EnsureSuccessStatusCode(); + string highlightBody = await highlightResponse.Content.ReadAsStringAsync(); + HighlightDtos.HighlightMediaDto? highlightMediaDto = + DeserializeJson(highlightBody, s_mJsonSerializerSettings); + HighlightEntities.HighlightMedia highlightMedia = HighlightsMapper.FromDto(highlightMediaDto); + + foreach (HighlightEntities.Story item in highlightMedia.Stories) + { + DateTime? createdAt = item.Media is { Count: > 0 } + ? item.Media[0].CreatedAt + : null; + + if (createdAt.HasValue) + { + await dbService.AddStory(folder, item.Id, "", "0", false, false, createdAt.Value); + } + else if (item.CreatedAt.HasValue) + { + await dbService.AddStory(folder, item.Id, "", "0", false, false, item.CreatedAt.Value); + } + else + { + await dbService.AddStory(folder, item.Id, "", "0", false, false, DateTime.Now); + } + + if (item.Media is not { Count: > 0 } || !item.Media[0].CanView) + { + continue; + } + + string? storyUrl = item.Media[0].Files?.Full?.Url; + string storyUrlValue = storyUrl ?? string.Empty; + foreach (HighlightEntities.Medium medium in item.Media) + { + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + await dbService.AddMedia(folder, medium.Id, item.Id, storyUrlValue, null, null, null, + "Stories", mediaType, false, false, null); + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!returnUrls.ContainsKey(medium.Id) && !string.IsNullOrEmpty(storyUrl)) + { + returnUrls.Add(medium.Id, storyUrl); + } + } + } + } + } + + return returnUrls; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return null; + } + + + /// + /// Retrieves paid posts and their media. + /// + /// The paid posts endpoint. + /// The creator folder path. + /// The creator username. + /// A list to collect paid media IDs. + /// Status reporter. + /// A paid post collection. + public async Task GetPaidPosts(string endpoint, string folder, + string username, + List paidPostIds, IStatusReporter statusReporter) + { + Log.Debug($"Calling GetPaidPosts - {username}"); + + try + { + PurchasedEntities.PaidPostCollection paidPostCollection = new(); + Dictionary getParams = new() + { + { "limit", Constants.ApiPageSize.ToString() }, + { "skip_users", "all" }, + { "order", "publish_date_desc" }, + { "format", "infinite" }, + { "author", username } + }; + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + PurchasedDtos.PurchasedDto? paidPostsDto = + DeserializeJson(body, s_mJsonSerializerSettings); + PurchasedEntities.Purchased paidPosts = PurchasedMapper.FromDto(paidPostsDto); + statusReporter.ReportStatus($"Getting Paid Posts - Found {paidPosts.List.Count}"); + if (paidPosts.HasMore) + { + getParams["offset"] = paidPosts.List.Count.ToString(); + while (true) + { + string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + PurchasedDtos.PurchasedDto? newPaidPostsDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + PurchasedEntities.Purchased newPaidPosts = PurchasedMapper.FromDto(newPaidPostsDto); + + paidPosts.List.AddRange(newPaidPosts.List); + statusReporter.ReportStatus($"Getting Paid Posts - Found {paidPosts.List.Count}"); + if (!newPaidPosts.HasMore) + { + break; + } + + getParams["offset"] = + Convert.ToString(Convert.ToInt32(getParams["offset"]) + Constants.ApiPageSize); + } + } + + List paidPostList = paidPosts.List; + foreach (PurchasedEntities.ListItem purchase in paidPostList) + { + if (purchase.ResponseType != "post" || purchase.Media is not { Count: > 0 }) + { + continue; + } + + List previewIds = []; + if (purchase.Previews != null) + { + for (int i = 0; i < purchase.Previews.Count; i++) + { + if (purchase.Previews[i] is long previewId) + { + if (!previewIds.Contains(previewId)) + { + previewIds.Add(previewId); + } + } + } + } + else if (purchase.Preview != null) + { + for (int i = 0; i < purchase.Preview.Count; i++) + { + if (purchase.Preview[i] is long previewId) + { + if (!previewIds.Contains(previewId)) + { + previewIds.Add(previewId); + } + } + } + } + + DateTime createdAt = purchase.CreatedAt ?? purchase.PostedAt ?? DateTime.Now; + bool isArchived = purchase.IsArchived ?? false; + await dbService.AddPost(folder, purchase.Id, purchase.Text ?? "", + purchase.Price ?? "0", + purchase is { Price: not null, IsOpened: true }, isArchived, createdAt); + paidPostCollection.PaidPostObjects.Add(purchase); + foreach (MessageEntities.Medium medium in purchase.Media) + { + if (!previewIds.Contains(medium.Id)) + { + paidPostIds.Add(medium.Id); + } + + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + string mediaType = ResolveMediaType(medium.Type) ?? ""; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = previewIds.Contains(medium.Id); + + if (previewIds.Count > 0) + { + bool has = previewIds.Any(cus => cus.Equals(medium.Id)); + if (!has && medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!paidPostCollection.PaidPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, purchase.Id, fullUrl, + null, null, null, "Posts", + mediaType, isPreview, false, null); + paidPostCollection.PaidPosts.Add(medium.Id, fullUrl); + paidPostCollection.PaidPostMedia.Add(medium); + } + } + else if (!has && medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!paidPostCollection.PaidPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, purchase.Id, + manifestDash, null, null, null, "Posts", mediaType, isPreview, false, null); + paidPostCollection.PaidPosts.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{purchase.Id}"); + paidPostCollection.PaidPostMedia.Add(medium); + } + } + } + else + { + if (medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!paidPostCollection.PaidPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, purchase.Id, fullUrl, + null, null, null, "Posts", + mediaType, isPreview, false, null); + paidPostCollection.PaidPosts.Add(medium.Id, fullUrl); + paidPostCollection.PaidPostMedia.Add(medium); + } + } + else if (medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!paidPostCollection.PaidPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, purchase.Id, + manifestDash, null, null, null, "Posts", mediaType, isPreview, false, null); + paidPostCollection.PaidPosts.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{purchase.Id}"); + paidPostCollection.PaidPostMedia.Add(medium); + } + } + } + } + } + + return paidPostCollection; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return new PurchasedEntities.PaidPostCollection(); + } + + + /// + /// Retrieves posts and their media. + /// + /// The posts endpoint. + /// The creator folder path. + /// Paid post media IDs to skip. + /// Status reporter. + /// A post collection. + public async Task GetPosts(string endpoint, string folder, List paidPostIds, + IStatusReporter statusReporter) + { + Log.Debug($"Calling GetPosts - {endpoint}"); + + try + { + PostEntities.PostCollection postCollection = new(); + Dictionary getParams = new() + { + { "limit", Constants.ApiPageSize.ToString() }, + { "order", "publish_date_desc" }, + { "format", "infinite" }, + { "skip_users", "all" } + }; + + DownloadDateSelection downloadDateSelection = DownloadDateSelection.before; + DateTime? downloadAsOf = null; + + if (configService.CurrentConfig is { DownloadOnlySpecificDates: true, CustomDate: not null }) + { + downloadDateSelection = configService.CurrentConfig.DownloadDateSelection; + downloadAsOf = configService.CurrentConfig.CustomDate; + } + else if (configService.CurrentConfig.DownloadPostsIncrementally) + { + DateTime? mostRecentPostDate = await dbService.GetMostRecentPostDate(folder); + if (mostRecentPostDate.HasValue) + { + downloadDateSelection = DownloadDateSelection.after; + downloadAsOf = mostRecentPostDate.Value.AddMinutes(-5); // Back track a little for a margin of error + } + } + + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + downloadAsOf); + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); + PostDtos.PostDto? postsDto = + DeserializeJson(body, s_mJsonSerializerSettings); + PostEntities.Post posts = PostMapper.FromDto(postsDto); + statusReporter.ReportStatus($"Getting Posts - Found {posts.List.Count}"); + if (posts.HasMore) + { + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + posts.TailMarker); + + while (true) + { + string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + PostDtos.PostDto? newPostsDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + PostEntities.Post newposts = PostMapper.FromDto(newPostsDto); + + posts.List.AddRange(newposts.List); + statusReporter.ReportStatus($"Getting Posts - Found {posts.List.Count}"); + if (!newposts.HasMore) + { + break; + } + + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + newposts.TailMarker); + } + } + + List postList = posts.List; + foreach (PostEntities.ListItem post in postList) + { + if (configService.CurrentConfig.SkipAds) + { + if (!string.IsNullOrEmpty(post.RawText) && + (post.RawText.Contains("#ad") || post.RawText.Contains("/trial/") || + post.RawText.Contains("#announcement"))) + { + continue; + } + + if (!string.IsNullOrEmpty(post.Text) && + (post.Text.Contains("#ad") || post.Text.Contains("/trial/") || + post.Text.Contains("#announcement"))) + { + continue; + } + } + + List postPreviewIds = []; + if (post.Preview is { Count: > 0 }) + { + for (int i = 0; i < post.Preview.Count; i++) + { + if (post.Preview[i] is not long previewId) + { + continue; + } + + if (!postPreviewIds.Contains(previewId)) + { + postPreviewIds.Add(previewId); + } + } + } + + await dbService.AddPost(folder, post.Id, !string.IsNullOrEmpty(post.RawText) ? post.RawText : "", + post.Price ?? "0", post is { Price: not null, IsOpened: true }, + post.IsArchived, post.PostedAt); + + postCollection.PostObjects.Add(post); + + if (post.Media is not { Count: > 0 }) + { + continue; + } + + foreach (PostEntities.Medium medium in post.Media) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + string? previewUrl = medium.Files?.Preview?.Url; + bool isPreview = postPreviewIds.Contains(medium.Id); + + if (medium.CanView && medium.Files?.Drm == null) + { + bool has = paidPostIds.Any(cus => cus.Equals(medium.Id)); + if (!has && !string.IsNullOrEmpty(fullUrl)) + { + if (!postCollection.Posts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, post.Id, fullUrl, null, null, null, + "Posts", mediaType, isPreview, false, null); + postCollection.Posts.Add(medium.Id, fullUrl); + postCollection.PostMedia.Add(medium); + } + } + else if (!has && string.IsNullOrEmpty(fullUrl) && !string.IsNullOrEmpty(previewUrl)) + { + if (!postCollection.Posts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, post.Id, previewUrl, null, null, null, + "Posts", mediaType, isPreview, false, null); + postCollection.Posts.Add(medium.Id, previewUrl); + postCollection.PostMedia.Add(medium); + } + } + } + else if (medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, out string cloudFrontPolicy, + out string cloudFrontSignature, out string cloudFrontKeyPairId)) + { + bool has = paidPostIds.Any(cus => cus.Equals(medium.Id)); + if (!has && !postCollection.Posts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, post.Id, manifestDash, null, null, null, + "Posts", mediaType, isPreview, false, null); + postCollection.Posts.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{post.Id}"); + postCollection.PostMedia.Add(medium); + } + } + } + } + + return postCollection; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return new PostEntities.PostCollection(); + } + + /// + /// Retrieves a single post and its media. + /// + /// The post endpoint. + /// The creator folder path. + /// A single post collection. + public async Task GetPost(string endpoint, string folder) + { + Log.Debug($"Calling GetPost - {endpoint}"); + + try + { + SinglePostCollection singlePostCollection = new(); + Dictionary getParams = new() { { "skip_users", "all" } }; + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); + PostDtos.SinglePostDto? singlePostDto = + DeserializeJson(body, s_mJsonSerializerSettings); + PostEntities.SinglePost singlePost = PostMapper.FromDto(singlePostDto); + + if (singlePostDto != null) + { + List postPreviewIds = []; + if (singlePost.Preview is { Count: > 0 }) + { + for (int i = 0; i < singlePost.Preview.Count; i++) + { + if (singlePost.Preview[i] is not long previewId) + { + continue; + } + + if (!postPreviewIds.Contains(previewId)) + { + postPreviewIds.Add(previewId); + } + } + } + + await dbService.AddPost(folder, singlePost.Id, + !string.IsNullOrEmpty(singlePost.Text) ? singlePost.Text : "", + singlePost.Price ?? "0", + singlePost is { Price: not null, IsOpened: true }, singlePost.IsArchived, + singlePost.PostedAt); + singlePostCollection.SinglePostObjects.Add(singlePost); + + if (singlePost.Media == null || singlePost.Media.Count <= 0) + { + return singlePostCollection; + } + + foreach (PostEntities.Medium medium in singlePost.Media) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + bool isPreview = postPreviewIds.Contains(medium.Id); + string? fullUrl = medium.Files?.Full?.Url; + string? previewUrl = medium.Files?.Preview?.Url; + + if (medium.CanView && medium.Files?.Drm == null) + { + switch (configService.CurrentConfig.DownloadVideoResolution) + { + case VideoResolution.source: + if (!string.IsNullOrEmpty(fullUrl)) + { + if (!singlePostCollection.SinglePosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, singlePost.Id, + fullUrl, null, null, null, "Posts", mediaType, isPreview, false, null); + singlePostCollection.SinglePosts.Add(medium.Id, fullUrl); + singlePostCollection.SinglePostMedia.Add(medium); + } + } + + break; + case VideoResolution._240: + string? video240 = medium.VideoSources?._240; + if (!string.IsNullOrEmpty(video240)) + { + if (!singlePostCollection.SinglePosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, singlePost.Id, video240, null, + null, null, "Posts", mediaType, isPreview, false, null); + singlePostCollection.SinglePosts.Add(medium.Id, video240); + singlePostCollection.SinglePostMedia.Add(medium); + } + } + + break; + case VideoResolution._720: + string? video720 = medium.VideoSources?._720; + if (!string.IsNullOrEmpty(video720)) + { + if (!singlePostCollection.SinglePosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, singlePost.Id, video720, null, + null, null, "Posts", mediaType, isPreview, false, null); + singlePostCollection.SinglePosts.Add(medium.Id, video720); + singlePostCollection.SinglePostMedia.Add(medium); + } + } + + break; + } + } + else if (medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, out string cloudFrontPolicy, + out string cloudFrontSignature, out string cloudFrontKeyPairId)) + { + if (!singlePostCollection.SinglePosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, singlePost.Id, manifestDash, null, null, + null, "Posts", mediaType, isPreview, false, null); + singlePostCollection.SinglePosts.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{singlePost.Id}"); + singlePostCollection.SinglePostMedia.Add(medium); + } + } + else if (!string.IsNullOrEmpty(previewUrl) && medium.Files?.Full == null) + { + if (!singlePostCollection.SinglePosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, singlePost.Id, previewUrl, null, null, null, + "Posts", mediaType, isPreview, false, null); + singlePostCollection.SinglePosts.Add(medium.Id, previewUrl); + singlePostCollection.SinglePostMedia.Add(medium); + } + } + } + } + + return singlePostCollection; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return new SinglePostCollection(); + } + + /// + /// Retrieves streams and their media. + /// + /// The streams endpoint. + /// The creator folder path. + /// Paid post media IDs to skip. + /// Status reporter. + /// A streams collection. + public async Task GetStreams(string endpoint, string folder, + List paidPostIds, + IStatusReporter statusReporter) + { + Log.Debug($"Calling GetStreams - {endpoint}"); + + try + { + StreamEntities.StreamsCollection streamsCollection = new(); + Dictionary getParams = new() + { + { "limit", Constants.ApiPageSize.ToString() }, + { "order", "publish_date_desc" }, + { "format", "infinite" }, + { "skip_users", "all" } + }; + + DownloadDateSelection downloadDateSelection = DownloadDateSelection.before; + if (configService.CurrentConfig is { DownloadOnlySpecificDates: true, CustomDate: not null }) + { + downloadDateSelection = configService.CurrentConfig.DownloadDateSelection; + } + + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + configService.CurrentConfig.CustomDate); + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); + StreamsDtos.StreamsDto? streamsDto = + DeserializeJson(body, s_mJsonSerializerSettings); + StreamEntities.Streams streams = StreamsMapper.FromDto(streamsDto); + statusReporter.ReportStatus($"Getting Streams - Found {streams.List.Count}"); + if (streams.HasMore) + { + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + streams.TailMarker); + + while (true) + { + string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + StreamsDtos.StreamsDto? newStreamsDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + StreamEntities.Streams newstreams = StreamsMapper.FromDto(newStreamsDto); + + streams.List.AddRange(newstreams.List); + statusReporter.ReportStatus($"Getting Streams - Found {streams.List.Count}"); + if (!newstreams.HasMore) + { + break; + } + + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + newstreams.TailMarker); + } + } + + List streamList = streams.List; + foreach (StreamEntities.ListItem stream in streamList) + { + List streamPreviewIds = []; + if (stream.Preview is { Count: > 0 }) + { + for (int i = 0; i < stream.Preview.Count; i++) + { + if (stream.Preview[i] is not long previewId) + { + continue; + } + + if (!streamPreviewIds.Contains(previewId)) + { + streamPreviewIds.Add(previewId); + } + } + } + + await dbService.AddPost(folder, stream.Id, !string.IsNullOrEmpty(stream.Text) ? stream.Text : "", + stream.Price ?? "0", stream is { Price: not null, IsOpened: true }, + stream.IsArchived, stream.PostedAt); + + streamsCollection.StreamObjects.Add(stream); + + if (stream.Media is not { Count: > 0 }) + { + continue; + } + + foreach (StreamEntities.Medium medium in stream.Media) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = streamPreviewIds.Contains(medium.Id); + + if (medium.CanView && medium.Files?.Drm == null) + { + bool has = paidPostIds.Any(cus => cus.Equals(medium.Id)); + if (!has && !string.IsNullOrEmpty(fullUrl)) + { + if (!streamsCollection.Streams.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, stream.Id, fullUrl, null, null, null, + "Posts", mediaType, isPreview, false, null); + streamsCollection.Streams.Add(medium.Id, fullUrl); + streamsCollection.StreamMedia.Add(medium); + } + } + } + else if (medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, out string cloudFrontPolicy, + out string cloudFrontSignature, out string cloudFrontKeyPairId)) + { + bool has = paidPostIds.Any(cus => cus.Equals(medium.Id)); + if (!has && !streamsCollection.Streams.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, stream.Id, manifestDash, null, null, null, + "Posts", mediaType, isPreview, false, null); + streamsCollection.Streams.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{stream.Id}"); + streamsCollection.StreamMedia.Add(medium); + } + } + } + } + + return streamsCollection; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return new StreamEntities.StreamsCollection(); + } + + + /// + /// Retrieves archived posts and their media. + /// + /// The archived posts endpoint. + /// The creator folder path. + /// Status reporter. + /// An archived collection. + public async Task GetArchived(string endpoint, string folder, + IStatusReporter statusReporter) + { + Log.Debug($"Calling GetArchived - {endpoint}"); + + try + { + ArchivedEntities.ArchivedCollection archivedCollection = new(); + Dictionary getParams = new() + { + { "limit", Constants.ApiPageSize.ToString() }, + { "order", "publish_date_desc" }, + { "skip_users", "all" }, + { "format", "infinite" }, + { "label", "archived" }, + { "counters", "1" } + }; + + DownloadDateSelection downloadDateSelection = DownloadDateSelection.before; + if (configService.CurrentConfig is { DownloadOnlySpecificDates: true, CustomDate: not null }) + { + downloadDateSelection = configService.CurrentConfig.DownloadDateSelection; + } + + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + configService.CurrentConfig.CustomDate); + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + if (body == null) + { + throw new Exception("Failed to retrieve archived posts. Received null response."); + } + + ArchivedDtos.ArchivedDto? archivedDto = + DeserializeJson(body, s_mJsonSerializerSettings); + ArchivedEntities.Archived archived = ArchivedMapper.FromDto(archivedDto); + statusReporter.ReportStatus($"Getting Archived Posts - Found {archived.List.Count}"); + if (archived.HasMore) + { + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + archived.TailMarker); + while (true) + { + string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + if (loopbody == null) + { + throw new Exception("Failed to retrieve archived posts. Received null response."); + } + + ArchivedDtos.ArchivedDto? newarchivedDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + ArchivedEntities.Archived newarchived = ArchivedMapper.FromDto(newarchivedDto); + + archived.List.AddRange(newarchived.List); + statusReporter.ReportStatus($"Getting Archived Posts - Found {archived.List.Count}"); + if (!newarchived.HasMore) + { + break; + } + + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + newarchived.TailMarker); + } + } + + foreach (ArchivedEntities.ListItem archive in archived.List) + { + List previewids = new(); + if (archive.Preview != null) + { + for (int i = 0; i < archive.Preview.Count; i++) + { + if (archive.Preview[i] is long previewId) + { + if (!previewids.Contains(previewId)) + { + previewids.Add(previewId); + } + } + } + } + + await dbService.AddPost(folder, archive.Id, archive.Text ?? "", + archive.Price ?? "0", + archive is { Price: not null, IsOpened: true }, archive.IsArchived, archive.PostedAt); + + archivedCollection.ArchivedPostObjects.Add(archive); + + if (archive.Media is not { Count: > 0 }) + { + continue; + } + + foreach (ArchivedEntities.Medium medium in archive.Media) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = previewids.Contains(medium.Id); + + if (medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!archivedCollection.ArchivedPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, archive.Id, fullUrl, null, null, null, + "Posts", mediaType, isPreview, false, null); + archivedCollection.ArchivedPosts.Add(medium.Id, fullUrl); + archivedCollection.ArchivedPostMedia.Add(medium); + } + } + else if (medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, out string cloudFrontPolicy, + out string cloudFrontSignature, out string cloudFrontKeyPairId)) + { + if (!archivedCollection.ArchivedPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, archive.Id, manifestDash, null, null, null, + "Posts", mediaType, isPreview, false, null); + archivedCollection.ArchivedPosts.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{archive.Id}"); + archivedCollection.ArchivedPostMedia.Add(medium); + } + } + } + } + + return archivedCollection; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return new ArchivedEntities.ArchivedCollection(); + } + + + /// + /// Retrieves messages and their media. + /// + /// The messages endpoint. + /// The creator folder path. + /// Status reporter. + /// A message collection. + public async Task GetMessages(string endpoint, string folder, + IStatusReporter statusReporter) + { + Log.Debug($"Calling GetMessages - {endpoint}"); + + try + { + MessageEntities.MessageCollection messageCollection = new(); + Dictionary getParams = new() + { + { "limit", Constants.ApiPageSize.ToString() }, { "order", "desc" }, { "skip_users", "all" } + }; + int currentUserId = GetCurrentUserIdOrDefault(); + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + MessageDtos.MessagesDto? messagesDto = + DeserializeJson(body, s_mJsonSerializerSettings); + MessageEntities.Messages messages = MessagesMapper.FromDto(messagesDto); + statusReporter.ReportStatus($"Getting Messages - Found {messages.List.Count}"); + if (messages.HasMore) + { + getParams["id"] = messages.List[^1].Id.ToString(); + while (true) + { + string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + MessageDtos.MessagesDto? newMessagesDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + MessageEntities.Messages newMessages = MessagesMapper.FromDto(newMessagesDto); + + messages.List.AddRange(newMessages.List); + statusReporter.ReportStatus($"Getting Messages - Found {messages.List.Count}"); + if (!newMessages.HasMore) + { + break; + } + + getParams["id"] = newMessages.List[^1].Id.ToString(); + } + } + + foreach (MessageEntities.ListItem list in messages.List) + { + if (configService.CurrentConfig.SkipAds) + { + if (!string.IsNullOrEmpty(list.Text) && + (list.Text.Contains("#ad") || list.Text.Contains("/trial/"))) + { + continue; + } + } + + List messagePreviewIds = []; + + if (list.Previews is { Count: > 0 }) + { + for (int i = 0; i < list.Previews.Count; i++) + { + if (list.Previews[i] is not long previewId) + { + continue; + } + + if (!messagePreviewIds.Contains(previewId)) + { + messagePreviewIds.Add(previewId); + } + } + } + + if (configService.CurrentConfig.IgnoreOwnMessages && list.FromUser?.Id == currentUserId) + { + continue; + } + + DateTime createdAt = list.CreatedAt ?? DateTime.Now; + await dbService.AddMessage(folder, list.Id, list.Text ?? "", list.Price ?? "0", + list.CanPurchaseReason == "opened" || + (list.CanPurchaseReason == "opened" && ((bool?)null ?? false)), false, + createdAt, + list.FromUser?.Id ?? int.MinValue); + + messageCollection.MessageObjects.Add(list); + + if (list.CanPurchaseReason != "opened" && list.Media is { Count: > 0 }) + { + foreach (MessageEntities.Medium medium in list.Media ?? []) + { + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = messagePreviewIds.Contains(medium.Id); + + if (medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!messageCollection.Messages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, list.Id, fullUrl, null, null, null, + "Messages", mediaType, isPreview, false, null); + messageCollection.Messages.Add(medium.Id, fullUrl); + messageCollection.MessageMedia.Add(medium); + } + } + else if (medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!messageCollection.Messages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, list.Id, manifestDash, null, null, null, + "Messages", mediaType, isPreview, false, null); + messageCollection.Messages.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{list.Id}"); + messageCollection.MessageMedia.Add(medium); + } + } + } + } + else if (messagePreviewIds.Count > 0) + { + foreach (MessageEntities.Medium medium in list.Media ?? new List()) + { + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = messagePreviewIds.Contains(medium.Id); + + if (medium.CanView && !string.IsNullOrEmpty(fullUrl) && isPreview) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!messageCollection.Messages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, list.Id, fullUrl, null, null, null, + "Messages", mediaType, isPreview, false, null); + messageCollection.Messages.Add(medium.Id, fullUrl); + messageCollection.MessageMedia.Add(medium); + } + } + else if (medium.CanView && isPreview && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!messageCollection.Messages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, list.Id, manifestDash, null, null, null, + "Messages", mediaType, isPreview, false, null); + messageCollection.Messages.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{list.Id}"); + messageCollection.MessageMedia.Add(medium); + } + } + } + } + } + + return messageCollection; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return new MessageEntities.MessageCollection(); + } + + /// + /// Retrieves a single paid message and its media. + /// + /// The paid message endpoint. + /// The creator folder path. + /// A single paid message collection. + public async Task GetPaidMessage(string endpoint, string folder) + { + Log.Debug($"Calling GetPaidMessage - {endpoint}"); + + try + { + PurchasedEntities.SinglePaidMessageCollection singlePaidMessageCollection = new(); + Dictionary getParams = + new() { { "limit", Constants.ApiPageSize.ToString() }, { "order", "desc" } }; + int currentUserId = GetCurrentUserIdOrDefault(); + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + MessageDtos.SingleMessageDto? messageDto = + DeserializeJson(body, s_mJsonSerializerSettings); + MessageEntities.SingleMessage message = MessagesMapper.FromDto(messageDto); + + if (configService.CurrentConfig.IgnoreOwnMessages && message.FromUser?.Id == currentUserId) + { + return singlePaidMessageCollection; + } + + DateTime createdAt = message.CreatedAt ?? DateTime.Now; + await dbService.AddMessage(folder, message.Id, message.Text ?? "", + message.Price?.ToString() ?? "0", true, false, + createdAt, + message.FromUser?.Id ?? int.MinValue); + + singlePaidMessageCollection.SingleMessageObjects.Add(message); + + List messagePreviewIds = []; + if (message.Previews is { Count: > 0 }) + { + for (int i = 0; i < message.Previews.Count; i++) + { + if (message.Previews[i] is not long previewId) + { + continue; + } + + if (!messagePreviewIds.Contains(previewId)) + { + messagePreviewIds.Add(previewId); + } + } + } + + if (message.Media is not { Count: > 0 }) + { + return singlePaidMessageCollection; + } + + foreach (MessageEntities.Medium medium in message.Media) + { + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = messagePreviewIds.Contains(medium.Id); + + if (!isPreview && medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!singlePaidMessageCollection.SingleMessages.TryAdd(medium.Id, fullUrl)) + { + continue; + } + + await dbService.AddMedia(folder, medium.Id, message.Id, fullUrl, null, null, null, + "Messages", mediaType, isPreview, false, null); + singlePaidMessageCollection.SingleMessageMedia.Add(medium); + } + else if (isPreview && medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!singlePaidMessageCollection.PreviewSingleMessages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, message.Id, fullUrl, null, null, null, + "Messages", mediaType, isPreview, false, null); + singlePaidMessageCollection.PreviewSingleMessages.Add(medium.Id, fullUrl); + singlePaidMessageCollection.PreviewSingleMessageMedia.Add(medium); + } + } + else if (!isPreview && medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, out string cloudFrontPolicy, + out string cloudFrontSignature, out string cloudFrontKeyPairId)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!singlePaidMessageCollection.SingleMessages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, message.Id, manifestDash, null, null, null, + "Messages", mediaType, isPreview, false, null); + singlePaidMessageCollection.SingleMessages.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{message.Id}"); + singlePaidMessageCollection.SingleMessageMedia.Add(medium); + } + } + else if (isPreview && medium.CanView && + TryGetDrmInfo(medium.Files, out string previewManifestDash, + out string previewCloudFrontPolicy, out string previewCloudFrontSignature, + out string previewCloudFrontKeyPairId)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!singlePaidMessageCollection.PreviewSingleMessages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, message.Id, previewManifestDash, null, null, + null, "Messages", mediaType, isPreview, false, null); + singlePaidMessageCollection.PreviewSingleMessages.Add(medium.Id, + $"{previewManifestDash},{previewCloudFrontPolicy},{previewCloudFrontSignature},{previewCloudFrontKeyPairId},{medium.Id},{message.Id}"); + singlePaidMessageCollection.PreviewSingleMessageMedia.Add(medium); + } + } + } + + return singlePaidMessageCollection; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return new PurchasedEntities.SinglePaidMessageCollection(); + } + + + /// + /// Retrieves paid messages and their media. + /// + /// The paid messages endpoint. + /// The creator folder path. + /// The creator username. + /// Status reporter. + /// A paid message collection. + public async Task GetPaidMessages(string endpoint, string folder, + string username, + IStatusReporter statusReporter) + { + Log.Debug($"Calling GetPaidMessages - {username}"); + + try + { + PurchasedEntities.PaidMessageCollection paidMessageCollection = new(); + Dictionary getParams = new() + { + { "limit", Constants.ApiPageSize.ToString() }, + { "order", "publish_date_desc" }, + { "format", "infinite" }, + { "author", username }, + { "skip_users", "all" } + }; + int currentUserId = GetCurrentUserIdOrDefault(); + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + PurchasedDtos.PurchasedDto? paidMessagesDto = + DeserializeJson(body, s_mJsonSerializerSettings); + PurchasedEntities.Purchased paidMessages = PurchasedMapper.FromDto(paidMessagesDto); + statusReporter.ReportStatus($"Getting Paid Messages - Found {paidMessages.List.Count}"); + if (paidMessages.HasMore) + { + getParams["offset"] = paidMessages.List.Count.ToString(); + while (true) + { + string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); + PurchasedEntities.Purchased newpaidMessages; + Dictionary loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); + HttpClient loopclient = GetHttpClient(); + + HttpRequestMessage looprequest = + new(HttpMethod.Get, $"{Constants.ApiUrl}{endpoint}{loopqueryParams}"); + + foreach (KeyValuePair keyValuePair in loopheaders) + { + looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); + } + + using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest)) + { + loopresponse.EnsureSuccessStatusCode(); + string loopbody = await loopresponse.Content.ReadAsStringAsync(); + PurchasedDtos.PurchasedDto? newPaidMessagesDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + newpaidMessages = PurchasedMapper.FromDto(newPaidMessagesDto); + } + + paidMessages.List.AddRange(newpaidMessages.List); + statusReporter.ReportStatus($"Getting Paid Messages - Found {paidMessages.List.Count}"); + if (!newpaidMessages.HasMore) + { + break; + } + + getParams["offset"] = + Convert.ToString(Convert.ToInt32(getParams["offset"]) + Constants.ApiPageSize); + } + } + + List paidMessageList = + paidMessages.List; + if (paidMessageList.Count > 0) + { + foreach (PurchasedEntities.ListItem purchase in paidMessageList + .Where(p => p.ResponseType == "message") + .OrderByDescending(p => p.PostedAt ?? p.CreatedAt)) + { + long fromUserId = purchase.FromUser?.Id ?? long.MinValue; + if (configService.CurrentConfig.IgnoreOwnMessages && fromUserId == currentUserId) + { + continue; + } + + DateTime createdAt = purchase.PostedAt ?? purchase.CreatedAt ?? DateTime.Now; + await dbService.AddMessage(folder, purchase.Id, + purchase.Text ?? "", + purchase.Price ?? "0", true, false, createdAt, + fromUserId); + + paidMessageCollection.PaidMessageObjects.Add(purchase); + if (purchase.Media is not { Count: > 0 }) + { + continue; + } + + List previewIds = []; + if (purchase.Previews != null) + { + for (int i = 0; i < purchase.Previews.Count; i++) + { + if (purchase.Previews[i] is not long previewId) + { + continue; + } + + if (!previewIds.Contains(previewId)) + { + previewIds.Add(previewId); + } + } + } + else if (purchase.Preview != null) + { + for (int i = 0; i < purchase.Preview.Count; i++) + { + if (purchase.Preview[i] is long previewId) + { + if (!previewIds.Contains(previewId)) + { + previewIds.Add(previewId); + } + } + } + } + + foreach (MessageEntities.Medium medium in purchase.Media) + { + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = previewIds.Contains(medium.Id); + + if (previewIds.Count > 0) + { + bool has = previewIds.Any(cus => cus.Equals(medium.Id)); + if (!has && medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!paidMessageCollection.PaidMessages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, purchase.Id, + fullUrl, null, null, null, "Messages", mediaType, isPreview, false, + null); + paidMessageCollection.PaidMessages.Add(medium.Id, fullUrl); + paidMessageCollection.PaidMessageMedia.Add(medium); + } + } + else if (!has && medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!paidMessageCollection.PaidMessages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, purchase.Id, + manifestDash, null, null, null, "Messages", mediaType, isPreview, false, + null); + paidMessageCollection.PaidMessages.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{purchase.Id}"); + paidMessageCollection.PaidMessageMedia.Add(medium); + } + } + } + else + { + if (medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!paidMessageCollection.PaidMessages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, purchase.Id, + fullUrl, null, null, null, "Messages", mediaType, isPreview, false, + null); + paidMessageCollection.PaidMessages.Add(medium.Id, fullUrl); + paidMessageCollection.PaidMessageMedia.Add(medium); + } + } + else if (medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!paidMessageCollection.PaidMessages.ContainsKey(medium.Id)) + { + await dbService.AddMedia(folder, medium.Id, purchase.Id, + manifestDash, null, null, null, "Messages", mediaType, isPreview, false, + null); + paidMessageCollection.PaidMessages.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{purchase.Id}"); + paidMessageCollection.PaidMessageMedia.Add(medium); + } + } + } + } + } + } + + return paidMessageCollection; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return new PurchasedEntities.PaidMessageCollection(); + } + + /// + /// Retrieves users that appear in the Purchased tab. + /// + /// The purchased tab endpoint. + /// Known users map. + /// A username-to-userId map. + public async Task> GetPurchasedTabUsers(string endpoint, Dictionary users) + { + Log.Debug($"Calling GetPurchasedTabUsers - {endpoint}"); + + try + { + Dictionary purchasedTabUsers = new(); + Dictionary getParams = new() + { + { "limit", Constants.ApiPageSize.ToString() }, + { "order", "publish_date_desc" }, + { "format", "infinite" }, + { "skip_users", "all" } + }; + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + if (body == null) + { + throw new Exception("Failed to get purchased tab users. null body returned."); + } + + PurchasedDtos.PurchasedDto? purchasedDto = + DeserializeJson(body, s_mJsonSerializerSettings); + PurchasedEntities.Purchased purchased = PurchasedMapper.FromDto(purchasedDto); + if (purchased.HasMore) + { + getParams["offset"] = purchased.List.Count.ToString(); + while (true) + { + string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); + PurchasedEntities.Purchased newPurchased; + Dictionary loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); + HttpClient loopclient = GetHttpClient(); + + HttpRequestMessage looprequest = + new(HttpMethod.Get, $"{Constants.ApiUrl}{endpoint}{loopqueryParams}"); + + foreach (KeyValuePair keyValuePair in loopheaders) + { + looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); + } + + using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest)) + { + loopresponse.EnsureSuccessStatusCode(); + string loopbody = await loopresponse.Content.ReadAsStringAsync(); + PurchasedDtos.PurchasedDto? newPurchasedDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + newPurchased = PurchasedMapper.FromDto(newPurchasedDto); + } + + purchased.List.AddRange(newPurchased.List); + if (!newPurchased.HasMore) + { + break; + } + + getParams["offset"] = + Convert.ToString(Convert.ToInt32(getParams["offset"]) + Constants.ApiPageSize); + } + } + + if (purchased.List.Count > 0) + { + foreach (PurchasedEntities.ListItem purchase in + purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt)) + { + long fromUserId = purchase.FromUser?.Id ?? 0; + long authorId = purchase.Author?.Id ?? 0; + + if (fromUserId != 0) + { + if (users.Values.Contains(fromUserId)) + { + string? matchedUsername = users.FirstOrDefault(x => x.Value == fromUserId).Key; + if (!string.IsNullOrEmpty(matchedUsername)) + { + purchasedTabUsers.TryAdd(matchedUsername, fromUserId); + } + else if (!purchasedTabUsers.ContainsKey($"Deleted User - {fromUserId}")) + { + purchasedTabUsers.Add($"Deleted User - {fromUserId}", fromUserId); + } + } + else + { + JObject? user = await GetUserInfoById($"/users/list?x[]={fromUserId}"); + string? fetchedUsername = user?[fromUserId.ToString()]?["username"]?.ToString(); + + if (string.IsNullOrEmpty(fetchedUsername)) + { + if (!configService.CurrentConfig.BypassContentForCreatorsWhoNoLongerExist && + !purchasedTabUsers.ContainsKey($"Deleted User - {fromUserId}")) + { + purchasedTabUsers.Add($"Deleted User - {fromUserId}", fromUserId); + } + + Log.Debug("Content creator not longer exists - {0}", fromUserId); + } + else + { + purchasedTabUsers.TryAdd(fetchedUsername, fromUserId); + } + } + } + else if (authorId != 0) + { + if (users.ContainsValue(authorId)) + { + string? matchedUsername = users.FirstOrDefault(x => x.Value == authorId).Key; + if (!string.IsNullOrEmpty(matchedUsername)) + { + if (!purchasedTabUsers.ContainsKey(matchedUsername) && + users.ContainsKey(matchedUsername)) + { + purchasedTabUsers.Add(matchedUsername, authorId); + } + } + else if (!purchasedTabUsers.ContainsKey($"Deleted User - {authorId}")) + { + purchasedTabUsers.Add($"Deleted User - {authorId}", authorId); + } + } + else + { + JObject? user = await GetUserInfoById($"/users/list?x[]={authorId}"); + string? fetchedUsername = user?[authorId.ToString()]?["username"]?.ToString(); + + if (string.IsNullOrEmpty(fetchedUsername)) + { + if (!configService.CurrentConfig.BypassContentForCreatorsWhoNoLongerExist && + !purchasedTabUsers.ContainsKey($"Deleted User - {authorId}")) + { + purchasedTabUsers.Add($"Deleted User - {authorId}", authorId); + } + + Log.Debug("Content creator not longer exists - {0}", authorId); + } + else if (!purchasedTabUsers.ContainsKey(fetchedUsername) && + users.ContainsKey(fetchedUsername)) + { + purchasedTabUsers.Add(fetchedUsername, authorId); + } + } + } + } + } + + return purchasedTabUsers; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return new Dictionary(); + } + + /// + /// Retrieves Purchased tab content grouped by user. + /// + /// The purchased tab endpoint. + /// The base download folder. + /// Known users map. + /// A list of purchased tab collections. + public async Task> GetPurchasedTab(string endpoint, string folder, + Dictionary users) + { + Log.Debug($"Calling GetPurchasedTab - {endpoint}"); + + try + { + Dictionary> userPurchases = new(); + List purchasedTabCollections = []; + Dictionary getParams = new() + { + { "limit", Constants.ApiPageSize.ToString() }, + { "order", "publish_date_desc" }, + { "format", "infinite" }, + { "skip_users", "all" } + }; + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); + PurchasedDtos.PurchasedDto? purchasedDto = + DeserializeJson(body, s_mJsonSerializerSettings); + PurchasedEntities.Purchased purchased = PurchasedMapper.FromDto(purchasedDto); + if (purchased.HasMore) + { + getParams["offset"] = purchased.List.Count.ToString(); + while (true) + { + string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); + PurchasedEntities.Purchased newPurchased; + Dictionary loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); + HttpClient loopclient = GetHttpClient(); + + HttpRequestMessage looprequest = + new(HttpMethod.Get, $"{Constants.ApiUrl}{endpoint}{loopqueryParams}"); + + foreach (KeyValuePair keyValuePair in loopheaders) + { + looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); + } + + using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest)) + { + loopresponse.EnsureSuccessStatusCode(); + string loopbody = await loopresponse.Content.ReadAsStringAsync(); + PurchasedDtos.PurchasedDto? newPurchasedDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + newPurchased = PurchasedMapper.FromDto(newPurchasedDto); + } + + purchased.List.AddRange(newPurchased.List); + if (!newPurchased.HasMore) + { + break; + } + + getParams["offset"] = + Convert.ToString(Convert.ToInt32(getParams["offset"]) + Constants.ApiPageSize); + } + } + + if (purchased.List.Count > 0) + { + foreach (PurchasedEntities.ListItem purchase in + purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt)) + { + if (purchase.FromUser != null) + { + if (!userPurchases.ContainsKey(purchase.FromUser.Id)) + { + userPurchases.Add(purchase.FromUser.Id, new List()); + } + + userPurchases[purchase.FromUser.Id].Add(purchase); + } + else if (purchase.Author != null) + { + if (!userPurchases.ContainsKey(purchase.Author.Id)) + { + userPurchases.Add(purchase.Author.Id, new List()); + } + + userPurchases[purchase.Author.Id].Add(purchase); + } + } + } + + foreach (KeyValuePair> user in userPurchases) + { + PurchasedEntities.PurchasedTabCollection purchasedTabCollection = new(); + JObject? userObject = await GetUserInfoById($"/users/list?x[]={user.Key}"); + purchasedTabCollection.UserId = user.Key; + string? fetchedUsername = userObject?[user.Key.ToString()]?["username"]?.ToString(); + purchasedTabCollection.Username = !string.IsNullOrEmpty(fetchedUsername) + ? fetchedUsername + : $"Deleted User - {user.Key}"; + string path = Path.Combine(folder, purchasedTabCollection.Username); + if (Path.Exists(path)) + { + foreach (PurchasedEntities.ListItem purchase in user.Value) + { + if (purchase.Media == null) + { + Log.Warning( + "PurchasedTab purchase media null, setting empty list | userId={UserId} username={Username} purchaseId={PurchaseId} responseType={ResponseType} createdAt={CreatedAt} postedAt={PostedAt}", + user.Key, purchasedTabCollection.Username, purchase.Id, purchase.ResponseType, + purchase.CreatedAt, purchase.PostedAt); + purchase.Media = new List(); + } + + switch (purchase.ResponseType) + { + case "post": + List previewids = new(); + if (purchase.Previews != null) + { + for (int i = 0; i < purchase.Previews.Count; i++) + { + if (purchase.Previews[i] is long previewId) + { + if (!previewids.Contains(previewId)) + { + previewids.Add(previewId); + } + } + } + } + else if (purchase.Preview != null) + { + for (int i = 0; i < purchase.Preview.Count; i++) + { + if (purchase.Preview[i] is long previewId) + { + if (!previewids.Contains(previewId)) + { + previewids.Add(previewId); + } + } + } + } + + DateTime createdAt = purchase.CreatedAt ?? purchase.PostedAt ?? DateTime.Now; + bool isArchived = purchase.IsArchived ?? false; + await dbService.AddPost(path, purchase.Id, + purchase.Text ?? "", + purchase.Price ?? "0", + purchase is { Price: not null, IsOpened: true }, + isArchived, + createdAt); + + purchasedTabCollection.PaidPosts.PaidPostObjects.Add(purchase); + + foreach (MessageEntities.Medium medium in purchase.Media) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = previewids.Contains(medium.Id); + + if (previewids.Count > 0) + { + bool has = previewids.Any(cus => cus.Equals(medium.Id)); + if (!has && medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!purchasedTabCollection.PaidPosts.PaidPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(path, medium.Id, purchase.Id, fullUrl, null, + null, null, "Posts", mediaType, isPreview, false, null); + purchasedTabCollection.PaidPosts.PaidPosts.Add(medium.Id, fullUrl); + purchasedTabCollection.PaidPosts.PaidPostMedia.Add(medium); + } + } + else if (!has && medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!purchasedTabCollection.PaidPosts.PaidPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(path, medium.Id, purchase.Id, manifestDash, + null, null, null, "Posts", mediaType, isPreview, false, null); + purchasedTabCollection.PaidPosts.PaidPosts.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{purchase.Id}"); + purchasedTabCollection.PaidPosts.PaidPostMedia.Add(medium); + } + } + } + else + { + if (medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!purchasedTabCollection.PaidPosts.PaidPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(path, medium.Id, purchase.Id, fullUrl, null, + null, null, "Posts", mediaType, isPreview, false, null); + purchasedTabCollection.PaidPosts.PaidPosts.Add(medium.Id, fullUrl); + purchasedTabCollection.PaidPosts.PaidPostMedia.Add(medium); + } + } + else if (medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!purchasedTabCollection.PaidPosts.PaidPosts.ContainsKey(medium.Id)) + { + await dbService.AddMedia(path, medium.Id, purchase.Id, manifestDash, + null, null, null, "Posts", mediaType, isPreview, false, null); + purchasedTabCollection.PaidPosts.PaidPosts.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{purchase.Id}"); + purchasedTabCollection.PaidPosts.PaidPostMedia.Add(medium); + } + } + } + } + + break; + case "message": + DateTime messageCreatedAt = purchase.PostedAt ?? purchase.CreatedAt ?? DateTime.Now; + long fromUserId = purchase.FromUser?.Id ?? long.MinValue; + await dbService.AddMessage(path, purchase.Id, + purchase.Text ?? "", + purchase.Price ?? "0", true, false, + messageCreatedAt, fromUserId); + + purchasedTabCollection.PaidMessages.PaidMessageObjects.Add(purchase); + if (purchase.Media is { Count: > 0 }) + { + List paidMessagePreviewIds = []; + if (purchase.Previews != null) + { + for (int i = 0; i < purchase.Previews.Count; i++) + { + if (purchase.Previews[i] is long previewId) + { + if (!paidMessagePreviewIds.Contains(previewId)) + { + paidMessagePreviewIds.Add(previewId); + } + } + } + } + else if (purchase.Preview != null) + { + for (int i = 0; i < purchase.Preview.Count; i++) + { + if (purchase.Preview[i] is long previewId) + { + if (!paidMessagePreviewIds.Contains(previewId)) + { + paidMessagePreviewIds.Add(previewId); + } + } + } + } + + foreach (MessageEntities.Medium medium in purchase.Media) + { + if (paidMessagePreviewIds.Count > 0) + { + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = paidMessagePreviewIds.Contains(medium.Id); + bool has = paidMessagePreviewIds.Any(cus => cus.Equals(medium.Id)); + if (!has && medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!purchasedTabCollection.PaidMessages.PaidMessages.ContainsKey( + medium.Id)) + { + await dbService.AddMedia(path, medium.Id, purchase.Id, + fullUrl, null, null, null, "Messages", mediaType, + isPreview, false, null); + purchasedTabCollection.PaidMessages.PaidMessages.Add(medium.Id, + fullUrl); + purchasedTabCollection.PaidMessages.PaidMessageMedia.Add( + medium); + } + } + else if (!has && medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!purchasedTabCollection.PaidMessages.PaidMessages.ContainsKey( + medium.Id)) + { + await dbService.AddMedia(path, medium.Id, purchase.Id, + manifestDash, null, null, null, "Messages", mediaType, + isPreview, false, null); + purchasedTabCollection.PaidMessages.PaidMessages.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{purchase.Id}"); + purchasedTabCollection.PaidMessages.PaidMessageMedia.Add( + medium); + } + } + } + else + { + string mediaType = ResolveMediaType(medium.Type) ?? string.Empty; + string? fullUrl = medium.Files?.Full?.Url; + bool isPreview = paidMessagePreviewIds.Contains(medium.Id); + + if (medium.CanView && !string.IsNullOrEmpty(fullUrl)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!purchasedTabCollection.PaidMessages.PaidMessages.ContainsKey( + medium.Id)) + { + await dbService.AddMedia(path, medium.Id, purchase.Id, + fullUrl, null, null, null, "Messages", mediaType, + isPreview, false, null); + purchasedTabCollection.PaidMessages.PaidMessages.Add(medium.Id, + fullUrl); + purchasedTabCollection.PaidMessages.PaidMessageMedia.Add( + medium); + } + } + else if (medium.CanView && + TryGetDrmInfo(medium.Files, out string manifestDash, + out string cloudFrontPolicy, out string cloudFrontSignature, + out string cloudFrontKeyPairId)) + { + if (!IsMediaTypeDownloadEnabled(medium.Type)) + { + continue; + } + + if (!purchasedTabCollection.PaidMessages.PaidMessages.ContainsKey( + medium.Id)) + { + await dbService.AddMedia(path, medium.Id, purchase.Id, + manifestDash, null, null, null, "Messages", mediaType, + isPreview, false, null); + purchasedTabCollection.PaidMessages.PaidMessages.Add(medium.Id, + $"{manifestDash},{cloudFrontPolicy},{cloudFrontSignature},{cloudFrontKeyPairId},{medium.Id},{purchase.Id}"); + purchasedTabCollection.PaidMessages.PaidMessageMedia.Add( + medium); + } + } + } + } + } + + break; + } + } + + purchasedTabCollections.Add(purchasedTabCollection); + } + } + + return purchasedTabCollections; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return []; + } + + + /// + /// Retrieves the Widevine PSSH from an MPD manifest. + /// + /// The MPD URL. + /// CloudFront policy token. + /// CloudFront signature token. + /// CloudFront key pair ID. + /// The PSSH value or an empty string. + public async Task GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp) + { + try + { + Auth? currentAuth = authService.CurrentAuth; + if (currentAuth == null || currentAuth.UserAgent == null || currentAuth.Cookie == null) + { + throw new Exception("Auth service is missing required fields"); + } + + HttpClient client = new(); + HttpRequestMessage request = new(HttpMethod.Get, mpdUrl); + request.Headers.Add("user-agent", currentAuth.UserAgent); + request.Headers.Add("Accept", "*/*"); + request.Headers.Add("Cookie", + $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {currentAuth.Cookie};"); + using HttpResponseMessage response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + string body = await response.Content.ReadAsStringAsync(); + XNamespace cenc = "urn:mpeg:cenc:2013"; + XDocument xmlDoc = XDocument.Parse(body); + IEnumerable psshElements = xmlDoc.Descendants(cenc + "pssh"); + string pssh = psshElements.ElementAt(1).Value; + + return pssh; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return string.Empty; + } + + + /// + /// Retrieves the Last-Modified timestamp for an MPD manifest. + /// + /// The MPD URL. + /// CloudFront policy token. + /// CloudFront signature token. + /// CloudFront key pair ID. + /// The last modified timestamp. + public async Task GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp) + { + Log.Debug("Calling GetDrmMpdLastModified"); + Log.Debug($"mpdUrl: {mpdUrl}"); + Log.Debug($"policy: {policy}"); + Log.Debug($"signature: {signature}"); + Log.Debug($"kvp: {kvp}"); + + try + { + Auth? currentAuth = authService.CurrentAuth; + if (currentAuth == null || currentAuth.UserAgent == null || currentAuth.Cookie == null) + { + throw new Exception("Auth service is missing required fields"); + } + + HttpClient client = new(); + HttpRequestMessage request = new(HttpMethod.Get, mpdUrl); + request.Headers.Add("user-agent", currentAuth.UserAgent); + request.Headers.Add("Accept", "*/*"); + request.Headers.Add("Cookie", + $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {currentAuth.Cookie};"); + using HttpResponseMessage response = + await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + DateTime lastmodified = response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now; + + Log.Debug($"Last modified: {lastmodified}"); + + return lastmodified; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return DateTime.Now; + } + + /// + /// Retrieves a decryption key via the OFDL fallback service. + /// + /// The DRM request headers. + /// The license URL. + /// The PSSH payload. + /// The decryption key string. + public async Task GetDecryptionKeyOfdl(Dictionary drmHeaders, string licenceUrl, + string pssh) + { + Log.Debug("Calling GetDecryptionKeyOfdl"); + + try + { + HttpClient client = new(); + int attempt = 0; + + OfdlRequest ofdlRequest = new() + { + Pssh = pssh, LicenseUrl = licenceUrl, Headers = JsonConvert.SerializeObject(drmHeaders) + }; + + string json = JsonConvert.SerializeObject(ofdlRequest); + + Log.Debug("Posting to ofdl.tools: {Json}", json); + + while (attempt < MaxAttempts) + { + attempt++; + + HttpRequestMessage request = new(HttpMethod.Post, "https://ofdl.tools/WV") + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + using HttpResponseMessage response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + continue; + } + + string body = await response.Content.ReadAsStringAsync(); + + if (!body.TrimStart().StartsWith('{')) + { + return body; + } + + Log.Debug($"Received JSON object instead of string. Retrying... Attempt {attempt} of {MaxAttempts}"); + await Task.Delay(DelayBetweenAttempts); + } + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return string.Empty; + } + + /// + /// Retrieves a decryption key using the local CDM integration. + /// + /// The DRM request headers. + /// The license URL. + /// The PSSH payload. + /// The decryption key string. + public async Task GetDecryptionKeyCdm(Dictionary drmHeaders, string licenceUrl, + string pssh) + { + Log.Debug("Calling GetDecryptionKeyCDM"); + + try + { + byte[] resp1 = await PostData(licenceUrl, drmHeaders, [0x08, 0x04]); + string certDataB64 = Convert.ToBase64String(resp1); + CDMApi cdm = new(); + byte[]? challenge = cdm.GetChallenge(pssh, certDataB64); + if (challenge == null) + { + throw new Exception("Failed to get challenge from CDM"); + } + + byte[] resp2 = await PostData(licenceUrl, drmHeaders, challenge); + string licenseB64 = Convert.ToBase64String(resp2); + Log.Debug("resp1: {Resp1}", resp1); + Log.Debug("certDataB64: {CertDataB64}", certDataB64); + Log.Debug("challenge: {Challenge}", challenge); + Log.Debug("resp2: {Resp2}", resp2); + Log.Debug("licenseB64: {LicenseB64}", licenseB64); + cdm.ProvideLicense(licenseB64); + List keys = cdm.GetKeys(); + if (keys.Count > 0) + { + Log.Debug("GetDecryptionKeyCDM Key: {ContentKey}", keys[0]); + return keys[0].ToString(); + } + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return string.Empty; + } + + + private async Task BuildHeaderAndExecuteRequests(Dictionary getParams, string endpoint, + HttpClient client) + { + Log.Debug("Calling BuildHeaderAndExecuteRequests"); + + HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint); + using HttpResponseMessage response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + string body = await response.Content.ReadAsStringAsync(); + + Log.Debug(body); + + return body; + } + + + private Task BuildHttpRequestMessage(Dictionary getParams, + string endpoint) + { + Log.Debug("Calling BuildHttpRequestMessage"); + + string queryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); + + Dictionary headers = GetDynamicHeaders($"/api2/v2{endpoint}", queryParams); + + HttpRequestMessage request = new(HttpMethod.Get, $"{Constants.ApiUrl}{endpoint}{queryParams}"); + + Log.Debug($"Full request URL: {Constants.ApiUrl}{endpoint}{queryParams}"); + + foreach (KeyValuePair keyValuePair in headers) + { + request.Headers.Add(keyValuePair.Key, keyValuePair.Value); + } + + return Task.FromResult(request); + } + + private static double ConvertToUnixTimestampWithMicrosecondPrecision(DateTime date) + { + DateTime origin = new(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); + TimeSpan diff = date.ToUniversalTime() - origin; + + return + diff.TotalSeconds; // This gives the number of seconds. If you need milliseconds, use diff.TotalMilliseconds + } + + private static bool IsStringOnlyDigits(string input) => input.All(char.IsDigit); + + + private HttpClient GetHttpClient() + { + HttpClient client = new(); + if (configService.CurrentConfig.Timeout is > 0) + { + client.Timeout = TimeSpan.FromSeconds(configService.CurrentConfig.Timeout.Value); + } + + return client; + } + + private static T? DeserializeJson(string? body, JsonSerializerSettings? settings = null) + { + if (string.IsNullOrWhiteSpace(body)) + { + return default; + } + + return settings == null + ? JsonConvert.DeserializeObject(body) + : JsonConvert.DeserializeObject(body, settings); + } + + private bool IsMediaTypeDownloadEnabled(string? type) + { + if (string.IsNullOrWhiteSpace(type)) + { + return true; + } + + return type switch + { + "photo" => configService.CurrentConfig.DownloadImages, + "video" => configService.CurrentConfig.DownloadVideos, + "gif" => configService.CurrentConfig.DownloadVideos, + "audio" => configService.CurrentConfig.DownloadAudios, + _ => true + }; + } + + private static string? ResolveMediaType(string? type) => + type switch + { + "photo" => "Images", + "video" => "Videos", + "gif" => "Videos", + "audio" => "Audios", + _ => null + }; + + private static bool TryGetDrmInfo(Files? files, out string manifestDash, out string cloudFrontPolicy, + out string cloudFrontSignature, out string cloudFrontKeyPairId) + { + manifestDash = string.Empty; + cloudFrontPolicy = string.Empty; + cloudFrontSignature = string.Empty; + cloudFrontKeyPairId = string.Empty; + + string? dash = files?.Drm?.Manifest?.Dash; + Dash? signatureDash = files?.Drm?.Signature?.Dash; + if (string.IsNullOrEmpty(dash) || signatureDash == null) + { + return false; + } + + if (string.IsNullOrEmpty(signatureDash.CloudFrontPolicy) || + string.IsNullOrEmpty(signatureDash.CloudFrontSignature) || + string.IsNullOrEmpty(signatureDash.CloudFrontKeyPairId)) + { + return false; + } + + manifestDash = dash; + cloudFrontPolicy = signatureDash.CloudFrontPolicy; + cloudFrontSignature = signatureDash.CloudFrontSignature; + cloudFrontKeyPairId = signatureDash.CloudFrontKeyPairId; + return true; + } + + private int GetCurrentUserIdOrDefault() + { + if (authService.CurrentAuth?.UserId == null) + { + return int.MinValue; + } + + return int.TryParse(authService.CurrentAuth.UserId, out int userId) ? userId : int.MinValue; + } + + + /// + /// this one is used during initialization only + /// if the config option is not available, then no modifications will be done on the getParams + /// + /// + /// + /// + private static void UpdateGetParamsForDateSelection(DownloadDateSelection downloadDateSelection, + ref Dictionary getParams, DateTime? dt) + { + //if (config.DownloadOnlySpecificDates && dt.HasValue) + //{ + if (dt.HasValue) + { + UpdateGetParamsForDateSelection( + downloadDateSelection, + ref getParams, + ConvertToUnixTimestampWithMicrosecondPrecision(dt.Value) + .ToString("0.000000", CultureInfo.InvariantCulture) + ); + } + //} + } + + private static void UpdateGetParamsForDateSelection(DownloadDateSelection downloadDateSelection, + ref Dictionary getParams, string? unixTimeStampInMicrosec) + { + if (string.IsNullOrWhiteSpace(unixTimeStampInMicrosec)) + { + return; + } + + switch (downloadDateSelection) + { + case DownloadDateSelection.before: + getParams["beforePublishTime"] = unixTimeStampInMicrosec; + break; + case DownloadDateSelection.after: + getParams["order"] = "publish_date_asc"; + getParams["afterPublishTime"] = unixTimeStampInMicrosec; + break; + } + } + + + private async Task?> GetAllSubscriptions(Dictionary getParams, + string endpoint, bool includeRestricted) + { + if (!HasSignedRequestAuth()) + { + return null; + } + + try + { + Dictionary users = new(); + + Log.Debug("Calling GetAllSubscrptions"); + + string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); + + SubscriptionsDtos.SubscriptionsDto? subscriptionsDto = + DeserializeJson(body, s_mJsonSerializerSettings); + SubscriptionEntities.Subscriptions subscriptions = SubscriptionsMapper.FromDto(subscriptionsDto); + if (subscriptions.HasMore) + { + getParams["offset"] = subscriptions.List.Count.ToString(); + + while (true) + { + SubscriptionEntities.Subscriptions newSubscriptions; + string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); + + if (!string.IsNullOrEmpty(loopbody) && (!loopbody.Contains("[]") || loopbody.Trim() != "[]")) + { + SubscriptionsDtos.SubscriptionsDto? newSubscriptionsDto = + DeserializeJson(loopbody, s_mJsonSerializerSettings); + newSubscriptions = SubscriptionsMapper.FromDto(newSubscriptionsDto); + } + else + { + break; + } + + subscriptions.List.AddRange(newSubscriptions.List); + if (!newSubscriptions.HasMore) + { + break; + } + + getParams["offset"] = subscriptions.List.Count.ToString(); + } + } + + foreach (SubscriptionEntities.ListItem subscription in subscriptions.List) + { + if ((!(subscription.IsRestricted ?? false) || + ((subscription.IsRestricted ?? false) && includeRestricted)) + && !users.ContainsKey(subscription.Username)) + { + users.Add(subscription.Username, subscription.Id); + } + } + + return users; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return null; + } + + private static string? GetDynamicRules() + { + Log.Debug("Calling GetDynamicRules"); + try + { + HttpClient client = new(); + HttpRequestMessage request = new(HttpMethod.Get, + "https://git.ofdl.tools/sim0n00ps/dynamic-rules/raw/branch/main/rules.json"); + using HttpResponseMessage response = client.Send(request); + + if (!response.IsSuccessStatusCode) + { + Log.Debug("GetDynamicRules did not return a Success Status Code"); + return null; + } + + string body = response.Content.ReadAsStringAsync().Result; + + Log.Debug("GetDynamicRules Response: "); + Log.Debug(body); + + return body; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return null; + } +} diff --git a/OF DL.Core/Services/AuthService.cs b/OF DL.Core/Services/AuthService.cs new file mode 100644 index 0000000..a8b725c --- /dev/null +++ b/OF DL.Core/Services/AuthService.cs @@ -0,0 +1,305 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using OF_DL.Models; +using PuppeteerSharp; +using PuppeteerSharp.BrowserData; +using Serilog; +using UserEntities = OF_DL.Models.Entities.Users; + +namespace OF_DL.Services; + +public class AuthService(IServiceProvider serviceProvider) : IAuthService +{ + private const int LoginTimeout = 600000; // 10 minutes + private const int FeedLoadTimeout = 60000; // 1 minute + + private readonly string[] _desiredCookies = + [ + "auth_id", + "sess" + ]; + + private readonly LaunchOptions _options = new() + { + Headless = false, + Channel = ChromeReleaseChannel.Stable, + DefaultViewport = null, + Args = ["--no-sandbox", "--disable-setuid-sandbox"], + UserDataDir = Path.GetFullPath("chrome-data") + }; + + /// + /// Gets or sets the current authentication state. + /// + public Auth? CurrentAuth { get; set; } + + /// + /// Loads authentication data from disk. + /// + /// The auth file path. + /// True when auth data is loaded successfully. + public async Task LoadFromFileAsync(string filePath = "auth.json") + { + try + { + if (!File.Exists(filePath)) + { + Log.Debug("Auth file not found: {FilePath}", filePath); + return false; + } + + string json = await File.ReadAllTextAsync(filePath); + CurrentAuth = JsonConvert.DeserializeObject(json); + Log.Debug("Auth file loaded and deserialized successfully"); + return CurrentAuth != null; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load auth from file"); + return false; + } + } + + /// + /// Launches a browser session and extracts auth data after login. + /// + /// True when auth data is captured successfully. + public async Task LoadFromBrowserAsync() + { + try + { + bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null; + + await SetupBrowser(runningInDocker); + CurrentAuth = await GetAuthFromBrowser(); + + return CurrentAuth != null; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load auth from browser"); + return false; + } + } + + /// + /// Persists the current auth data to disk. + /// + /// The auth file path. + public async Task SaveToFileAsync(string filePath = "auth.json") + { + if (CurrentAuth == null) + { + Log.Warning("Attempted to save null auth to file"); + return; + } + + try + { + string json = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented); + await File.WriteAllTextAsync(filePath, json); + Log.Debug($"Auth saved to file: {filePath}"); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to save auth to file"); + } + } + + private async Task SetupBrowser(bool runningInDocker) + { + string? executablePath = Environment.GetEnvironmentVariable("OFDL_PUPPETEER_EXECUTABLE_PATH"); + if (executablePath != null) + { + Log.Information( + "OFDL_PUPPETEER_EXECUTABLE_PATH environment variable found. Using browser executable path: {executablePath}", + executablePath); + _options.ExecutablePath = executablePath; + } + else + { + BrowserFetcher browserFetcher = new(); + List installedBrowsers = browserFetcher.GetInstalledBrowsers().ToList(); + if (installedBrowsers.Count == 0) + { + Log.Information("Downloading browser."); + InstalledBrowser? downloadedBrowser = await browserFetcher.DownloadAsync(); + Log.Information("Browser downloaded. Path: {executablePath}", + downloadedBrowser.GetExecutablePath()); + _options.ExecutablePath = downloadedBrowser.GetExecutablePath(); + } + else + { + _options.ExecutablePath = installedBrowsers.First().GetExecutablePath(); + } + } + + if (runningInDocker) + { + Log.Information("Running in Docker. Disabling sandbox and GPU."); + _options.Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"]; + } + } + + private async Task GetBcToken(IPage page) => + await page.EvaluateExpressionAsync("window.localStorage.getItem('bcTokenSha') || ''"); + + /// + /// Normalizes the stored cookie string to only include required cookie values. + /// + public void ValidateCookieString() + { + if (CurrentAuth == null) + { + return; + } + + if (string.IsNullOrWhiteSpace(CurrentAuth.Cookie)) + { + return; + } + + string pattern = @"(auth_id=\d+)|(sess=[^;]+)"; + MatchCollection matches = Regex.Matches(CurrentAuth.Cookie, pattern); + + string output = string.Join("; ", matches); + + if (!output.EndsWith(";")) + { + output += ";"; + } + + if (CurrentAuth.Cookie.Trim() != output.Trim()) + { + CurrentAuth.Cookie = output; + string newAuthString = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented); + File.WriteAllText("auth.json", newAuthString); + } + } + + /// + /// Validates auth by requesting the current user profile. + /// + /// The authenticated user or null when validation fails. + public async Task ValidateAuthAsync() + { + // Resolve IApiService lazily to avoid circular dependency + IApiService apiService = serviceProvider.GetRequiredService(); + return await apiService.GetUserInfo("/users/me"); + } + + /// + /// Clears persisted auth data and browser profile state. + /// + public void Logout() + { + if (Directory.Exists("chrome-data")) + { + Log.Information("Deleting chrome-data folder"); + Directory.Delete("chrome-data", true); + } + + if (File.Exists("auth.json")) + { + Log.Information("Deleting auth.json"); + File.Delete("auth.json"); + } + } + + private async Task GetAuthFromBrowser() + { + try + { + IBrowser? browser; + try + { + browser = await Puppeteer.LaunchAsync(_options); + } + catch (ProcessException e) + { + if (e.Message.Contains("Failed to launch browser") && Directory.Exists(_options.UserDataDir)) + { + Log.Error("Failed to launch browser. Deleting chrome-data directory and trying again."); + Directory.Delete(_options.UserDataDir, true); + browser = await Puppeteer.LaunchAsync(_options); + } + else + { + throw; + } + } + + if (browser == null) + { + throw new Exception("Could not get browser"); + } + + IPage[]? pages = await browser.PagesAsync(); + IPage? page = pages.First(); + + if (page == null) + { + throw new Exception("Could not get page"); + } + + Log.Debug("Navigating to OnlyFans."); + await page.GoToAsync("https://onlyfans.com"); + + Log.Debug("Waiting for user to login"); + await page.WaitForSelectorAsync(".b-feed", new WaitForSelectorOptions { Timeout = LoginTimeout }); + Log.Debug("Feed element detected (user logged in)"); + + await page.ReloadAsync(); + + await page.WaitForNavigationAsync(new NavigationOptions + { + WaitUntil = [WaitUntilNavigation.Networkidle2], Timeout = FeedLoadTimeout + }); + Log.Debug("DOM loaded. Getting BC token and cookies ..."); + + string xBc; + try + { + xBc = await GetBcToken(page); + } + catch (Exception e) + { + Log.Error(e, "Error getting bcToken"); + throw new Exception("Error getting bcToken"); + } + + Dictionary mappedCookies = (await page.GetCookiesAsync()) + .Where(cookie => cookie.Domain.Contains("onlyfans.com")) + .ToDictionary(cookie => cookie.Name, cookie => cookie.Value); + + mappedCookies.TryGetValue("auth_id", out string? userId); + if (userId == null) + { + throw new Exception("Could not find 'auth_id' cookie"); + } + + mappedCookies.TryGetValue("sess", out string? sess); + if (sess == null) + { + throw new Exception("Could not find 'sess' cookie"); + } + + string? userAgent = await browser.GetUserAgentAsync(); + if (userAgent == null) + { + throw new Exception("Could not get user agent"); + } + + string cookies = string.Join(" ", mappedCookies.Keys.Where(key => _desiredCookies.Contains(key)) + .Select(key => $"${key}={mappedCookies[key]};")); + + return new Auth { Cookie = cookies, UserAgent = userAgent, UserId = userId, XBc = xBc }; + } + catch (Exception e) + { + Log.Error(e, "Error getting auth from browser"); + return null; + } + } +} diff --git a/OF DL.Core/Services/ConfigService.cs b/OF DL.Core/Services/ConfigService.cs new file mode 100644 index 0000000..f71586b --- /dev/null +++ b/OF DL.Core/Services/ConfigService.cs @@ -0,0 +1,495 @@ +using System.Reflection; +using System.Text; +using Akka.Configuration; +using Newtonsoft.Json; +using OF_DL.Enumerations; +using OF_DL.Models.Config; +using OF_DL.Utils; +using Serilog; +using Config = OF_DL.Models.Config.Config; + +namespace OF_DL.Services; + +public class ConfigService(ILoggingService loggingService) : IConfigService +{ + /// + /// Gets the active configuration in memory. + /// + public Config CurrentConfig { get; private set; } = new(); + + /// + /// Gets whether the CLI requested non-interactive mode. + /// + public bool IsCliNonInteractive { get; private set; } + + /// + /// Loads configuration from disk and applies runtime settings. + /// + /// CLI arguments used to influence configuration. + /// True when configuration is loaded successfully. + public async Task LoadConfigurationAsync(string[] args) + { + try + { + IsCliNonInteractive = false; + if (args.Length > 0) + { + const string nonInteractiveArg = "--non-interactive"; + if (args.Any(a => a.Equals(nonInteractiveArg, StringComparison.OrdinalIgnoreCase))) + { + IsCliNonInteractive = true; + Log.Debug("NonInteractiveMode set via command line"); + } + + Log.Debug("Additional arguments:"); + foreach (string argument in args) + { + Log.Debug(argument); + } + } + + // Migrate from config.json to config.conf if needed + await MigrateFromJsonToConfAsync(); + + // Load config.conf or create default + if (File.Exists("config.conf")) + { + Log.Debug("config.conf located successfully"); + if (!await LoadConfigFromFileAsync("config.conf")) + { + return false; + } + } + else + { + Log.Debug("config.conf not found, creating default"); + await CreateDefaultConfigFileAsync(); + if (!await LoadConfigFromFileAsync("config.conf")) + { + return false; + } + } + + if (IsCliNonInteractive && !CurrentConfig.NonInteractiveMode) + { + CurrentConfig.NonInteractiveMode = true; + Log.Debug("NonInteractiveMode overridden to true via command line"); + } + + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Configuration loading failed"); + return false; + } + } + + /// + /// Saves the current configuration to disk. + /// + /// The destination config file path. + public async Task SaveConfigurationAsync(string filePath = "config.conf") + { + try + { + string hoconConfig = BuildHoconFromConfig(CurrentConfig); + await File.WriteAllTextAsync(filePath, hoconConfig); + Log.Debug($"Config saved to file: {filePath}"); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to save config to file"); + } + } + + /// + /// Replaces the current configuration and applies runtime settings. + /// + /// The new configuration instance. + public void UpdateConfig(Config newConfig) + { + CurrentConfig = newConfig; + + // Update logging level + loggingService.UpdateLoggingLevel(newConfig.LoggingLevel); + + // Apply text sanitization preference globally + XmlUtils.Passthrough = newConfig.DisableTextSanitization; + + Log.Debug("Configuration updated"); + string configString = JsonConvert.SerializeObject(newConfig, Formatting.Indented); + Log.Debug(configString); + } + + private async Task MigrateFromJsonToConfAsync() + { + if (!File.Exists("config.json")) + { + return; + } + + try + { + Log.Debug("config.json found, migrating to config.conf"); + string jsonText = await File.ReadAllTextAsync("config.json"); + Config? jsonConfig = JsonConvert.DeserializeObject(jsonText); + + if (jsonConfig != null) + { + string hoconConfig = BuildHoconFromConfig(jsonConfig); + await File.WriteAllTextAsync("config.conf", hoconConfig); + File.Delete("config.json"); + Log.Information("config.conf created successfully from config.json"); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to migrate config.json to config.conf"); + throw; + } + } + + private async Task LoadConfigFromFileAsync(string filePath) + { + try + { + string hoconText = await File.ReadAllTextAsync(filePath); + Akka.Configuration.Config? hoconConfig = ConfigurationFactory.ParseString(hoconText); + + CurrentConfig = new Config + { + // Auth + DisableBrowserAuth = hoconConfig.GetBoolean("Auth.DisableBrowserAuth"), + + // FFmpeg Settings + FFmpegPath = hoconConfig.GetString("External.FFmpegPath"), + + // Download Settings + DownloadAvatarHeaderPhoto = hoconConfig.GetBoolean("Download.Media.DownloadAvatarHeaderPhoto"), + DownloadPaidPosts = hoconConfig.GetBoolean("Download.Media.DownloadPaidPosts"), + DownloadPosts = hoconConfig.GetBoolean("Download.Media.DownloadPosts"), + DownloadArchived = hoconConfig.GetBoolean("Download.Media.DownloadArchived"), + DownloadStreams = hoconConfig.GetBoolean("Download.Media.DownloadStreams"), + DownloadStories = hoconConfig.GetBoolean("Download.Media.DownloadStories"), + DownloadHighlights = hoconConfig.GetBoolean("Download.Media.DownloadHighlights"), + DownloadMessages = hoconConfig.GetBoolean("Download.Media.DownloadMessages"), + DownloadPaidMessages = hoconConfig.GetBoolean("Download.Media.DownloadPaidMessages"), + DownloadImages = hoconConfig.GetBoolean("Download.Media.DownloadImages"), + DownloadVideos = hoconConfig.GetBoolean("Download.Media.DownloadVideos"), + DownloadAudios = hoconConfig.GetBoolean("Download.Media.DownloadAudios"), + IgnoreOwnMessages = hoconConfig.GetBoolean("Download.IgnoreOwnMessages"), + DownloadPostsIncrementally = hoconConfig.GetBoolean("Download.DownloadPostsIncrementally"), + BypassContentForCreatorsWhoNoLongerExist = + hoconConfig.GetBoolean("Download.BypassContentForCreatorsWhoNoLongerExist"), + DownloadDuplicatedMedia = hoconConfig.GetBoolean("Download.DownloadDuplicatedMedia"), + SkipAds = hoconConfig.GetBoolean("Download.SkipAds"), + DownloadPath = hoconConfig.GetString("Download.DownloadPath"), + DownloadOnlySpecificDates = hoconConfig.GetBoolean("Download.DownloadOnlySpecificDates"), + DownloadDateSelection = + Enum.Parse(hoconConfig.GetString("Download.DownloadDateSelection"), true), + CustomDate = + !string.IsNullOrWhiteSpace(hoconConfig.GetString("Download.CustomDate")) + ? DateTime.Parse(hoconConfig.GetString("Download.CustomDate")) + : null, + ShowScrapeSize = hoconConfig.GetBoolean("Download.ShowScrapeSize"), + DisableTextSanitization = + bool.TryParse(hoconConfig.GetString("Download.DisableTextSanitization", "false"), out bool dts) + ? dts + : false, + DownloadVideoResolution = + ParseVideoResolution(hoconConfig.GetString("Download.DownloadVideoResolution", "source")), + + // File Settings + PaidPostFileNameFormat = hoconConfig.GetString("File.PaidPostFileNameFormat"), + PostFileNameFormat = hoconConfig.GetString("File.PostFileNameFormat"), + PaidMessageFileNameFormat = hoconConfig.GetString("File.PaidMessageFileNameFormat"), + MessageFileNameFormat = hoconConfig.GetString("File.MessageFileNameFormat"), + RenameExistingFilesWhenCustomFormatIsSelected = + hoconConfig.GetBoolean("File.RenameExistingFilesWhenCustomFormatIsSelected"), + + // Folder Settings + FolderPerPaidPost = hoconConfig.GetBoolean("Folder.FolderPerPaidPost"), + FolderPerPost = hoconConfig.GetBoolean("Folder.FolderPerPost"), + FolderPerPaidMessage = hoconConfig.GetBoolean("Folder.FolderPerPaidMessage"), + FolderPerMessage = hoconConfig.GetBoolean("Folder.FolderPerMessage"), + + // Subscription Settings + IncludeExpiredSubscriptions = hoconConfig.GetBoolean("Subscriptions.IncludeExpiredSubscriptions"), + IncludeRestrictedSubscriptions = hoconConfig.GetBoolean("Subscriptions.IncludeRestrictedSubscriptions"), + IgnoredUsersListName = hoconConfig.GetString("Subscriptions.IgnoredUsersListName"), + + // Interaction Settings + NonInteractiveMode = hoconConfig.GetBoolean("Interaction.NonInteractiveMode"), + NonInteractiveModeListName = hoconConfig.GetString("Interaction.NonInteractiveModeListName"), + NonInteractiveModePurchasedTab = hoconConfig.GetBoolean("Interaction.NonInteractiveModePurchasedTab"), + + // Performance Settings + Timeout = + string.IsNullOrWhiteSpace(hoconConfig.GetString("Performance.Timeout")) + ? -1 + : hoconConfig.GetInt("Performance.Timeout"), + LimitDownloadRate = hoconConfig.GetBoolean("Performance.LimitDownloadRate"), + DownloadLimitInMbPerSec = hoconConfig.GetInt("Performance.DownloadLimitInMbPerSec"), + + // Logging/Debug Settings + LoggingLevel = Enum.Parse(hoconConfig.GetString("Logging.LoggingLevel"), true) + }; + + // Validate file name formats + ValidateFileNameFormat(CurrentConfig.PaidPostFileNameFormat, "PaidPostFileNameFormat"); + ValidateFileNameFormat(CurrentConfig.PostFileNameFormat, "PostFileNameFormat"); + ValidateFileNameFormat(CurrentConfig.PaidMessageFileNameFormat, "PaidMessageFileNameFormat"); + ValidateFileNameFormat(CurrentConfig.MessageFileNameFormat, "MessageFileNameFormat"); + + // Load creator-specific configs + Akka.Configuration.Config? creatorConfigsSection = hoconConfig.GetConfig("CreatorConfigs"); + if (creatorConfigsSection != null) + { + foreach ((string? creatorKey, _) in creatorConfigsSection.AsEnumerable()) + { + Akka.Configuration.Config? creatorHocon = creatorConfigsSection.GetConfig(creatorKey); + if (!CurrentConfig.CreatorConfigs.ContainsKey(creatorKey) && creatorHocon != null) + { + CurrentConfig.CreatorConfigs.Add(creatorKey, + new CreatorConfig + { + PaidPostFileNameFormat = creatorHocon.GetString("PaidPostFileNameFormat"), + PostFileNameFormat = creatorHocon.GetString("PostFileNameFormat"), + PaidMessageFileNameFormat = creatorHocon.GetString("PaidMessageFileNameFormat"), + MessageFileNameFormat = creatorHocon.GetString("MessageFileNameFormat") + }); + + ValidateFileNameFormat(CurrentConfig.CreatorConfigs[creatorKey].PaidPostFileNameFormat, + $"{creatorKey}.PaidPostFileNameFormat"); + ValidateFileNameFormat(CurrentConfig.CreatorConfigs[creatorKey].PostFileNameFormat, + $"{creatorKey}.PostFileNameFormat"); + ValidateFileNameFormat(CurrentConfig.CreatorConfigs[creatorKey].PaidMessageFileNameFormat, + $"{creatorKey}.PaidMessageFileNameFormat"); + ValidateFileNameFormat(CurrentConfig.CreatorConfigs[creatorKey].MessageFileNameFormat, + $"{creatorKey}.MessageFileNameFormat"); + } + } + } + + // Update logging level + loggingService.UpdateLoggingLevel(CurrentConfig.LoggingLevel); + + // Apply text sanitization preference globally + XmlUtils.Passthrough = CurrentConfig.DisableTextSanitization; + + Log.Debug("Configuration loaded successfully"); + string configString = JsonConvert.SerializeObject(CurrentConfig, Formatting.Indented); + Log.Debug(configString); + + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to parse config file"); + return false; + } + } + + private async Task CreateDefaultConfigFileAsync() + { + Config defaultConfig = new(); + string hoconConfig = BuildHoconFromConfig(defaultConfig); + await File.WriteAllTextAsync("config.conf", hoconConfig); + Log.Information("Created default config.conf file"); + } + + private string BuildHoconFromConfig(Config config) + { + StringBuilder hocon = new(); + + hocon.AppendLine("# Auth"); + hocon.AppendLine("Auth {"); + hocon.AppendLine($" DisableBrowserAuth = {config.DisableBrowserAuth.ToString().ToLower()}"); + hocon.AppendLine("}"); + + hocon.AppendLine("# External Tools"); + hocon.AppendLine("External {"); + hocon.AppendLine($" FFmpegPath = \"{config.FFmpegPath}\""); + hocon.AppendLine("}"); + + hocon.AppendLine("# Download Settings"); + hocon.AppendLine("Download {"); + hocon.AppendLine(" Media {"); + hocon.AppendLine($" DownloadAvatarHeaderPhoto = {config.DownloadAvatarHeaderPhoto.ToString().ToLower()}"); + hocon.AppendLine($" DownloadPaidPosts = {config.DownloadPaidPosts.ToString().ToLower()}"); + hocon.AppendLine($" DownloadPosts = {config.DownloadPosts.ToString().ToLower()}"); + hocon.AppendLine($" DownloadArchived = {config.DownloadArchived.ToString().ToLower()}"); + hocon.AppendLine($" DownloadStreams = {config.DownloadStreams.ToString().ToLower()}"); + hocon.AppendLine($" DownloadStories = {config.DownloadStories.ToString().ToLower()}"); + hocon.AppendLine($" DownloadHighlights = {config.DownloadHighlights.ToString().ToLower()}"); + hocon.AppendLine($" DownloadMessages = {config.DownloadMessages.ToString().ToLower()}"); + hocon.AppendLine($" DownloadPaidMessages = {config.DownloadPaidMessages.ToString().ToLower()}"); + hocon.AppendLine($" DownloadImages = {config.DownloadImages.ToString().ToLower()}"); + hocon.AppendLine($" DownloadVideos = {config.DownloadVideos.ToString().ToLower()}"); + hocon.AppendLine($" DownloadAudios = {config.DownloadAudios.ToString().ToLower()}"); + hocon.AppendLine(" }"); + hocon.AppendLine($" IgnoreOwnMessages = {config.IgnoreOwnMessages.ToString().ToLower()}"); + hocon.AppendLine($" DownloadPostsIncrementally = {config.DownloadPostsIncrementally.ToString().ToLower()}"); + hocon.AppendLine( + $" BypassContentForCreatorsWhoNoLongerExist = {config.BypassContentForCreatorsWhoNoLongerExist.ToString().ToLower()}"); + hocon.AppendLine($" DownloadDuplicatedMedia = {config.DownloadDuplicatedMedia.ToString().ToLower()}"); + hocon.AppendLine($" SkipAds = {config.SkipAds.ToString().ToLower()}"); + hocon.AppendLine($" DownloadPath = \"{config.DownloadPath}\""); + hocon.AppendLine($" DownloadOnlySpecificDates = {config.DownloadOnlySpecificDates.ToString().ToLower()}"); + hocon.AppendLine($" DownloadDateSelection = \"{config.DownloadDateSelection.ToString().ToLower()}\""); + hocon.AppendLine($" CustomDate = \"{config.CustomDate?.ToString("yyyy-MM-dd")}\""); + hocon.AppendLine($" ShowScrapeSize = {config.ShowScrapeSize.ToString().ToLower()}"); + hocon.AppendLine($" DisableTextSanitization = {config.DisableTextSanitization.ToString().ToLower()}"); + hocon.AppendLine( + $" DownloadVideoResolution = \"{(config.DownloadVideoResolution == VideoResolution.source ? "source" : config.DownloadVideoResolution.ToString().TrimStart('_'))}\""); + hocon.AppendLine("}"); + + hocon.AppendLine("# File Settings"); + hocon.AppendLine("File {"); + hocon.AppendLine($" PaidPostFileNameFormat = \"{config.PaidPostFileNameFormat}\""); + hocon.AppendLine($" PostFileNameFormat = \"{config.PostFileNameFormat}\""); + hocon.AppendLine($" PaidMessageFileNameFormat = \"{config.PaidMessageFileNameFormat}\""); + hocon.AppendLine($" MessageFileNameFormat = \"{config.MessageFileNameFormat}\""); + hocon.AppendLine( + $" RenameExistingFilesWhenCustomFormatIsSelected = {config.RenameExistingFilesWhenCustomFormatIsSelected.ToString().ToLower()}"); + hocon.AppendLine("}"); + + hocon.AppendLine("# Creator-Specific Configurations"); + hocon.AppendLine("CreatorConfigs {"); + foreach (KeyValuePair creatorConfig in config.CreatorConfigs) + { + hocon.AppendLine($" \"{creatorConfig.Key}\" {{"); + hocon.AppendLine($" PaidPostFileNameFormat = \"{creatorConfig.Value.PaidPostFileNameFormat}\""); + hocon.AppendLine($" PostFileNameFormat = \"{creatorConfig.Value.PostFileNameFormat}\""); + hocon.AppendLine($" PaidMessageFileNameFormat = \"{creatorConfig.Value.PaidMessageFileNameFormat}\""); + hocon.AppendLine($" MessageFileNameFormat = \"{creatorConfig.Value.MessageFileNameFormat}\""); + hocon.AppendLine(" }"); + } + + hocon.AppendLine("}"); + + hocon.AppendLine("# Folder Settings"); + hocon.AppendLine("Folder {"); + hocon.AppendLine($" FolderPerPaidPost = {config.FolderPerPaidPost.ToString().ToLower()}"); + hocon.AppendLine($" FolderPerPost = {config.FolderPerPost.ToString().ToLower()}"); + hocon.AppendLine($" FolderPerPaidMessage = {config.FolderPerPaidMessage.ToString().ToLower()}"); + hocon.AppendLine($" FolderPerMessage = {config.FolderPerMessage.ToString().ToLower()}"); + hocon.AppendLine("}"); + + hocon.AppendLine("# Subscription Settings"); + hocon.AppendLine("Subscriptions {"); + hocon.AppendLine($" IncludeExpiredSubscriptions = {config.IncludeExpiredSubscriptions.ToString().ToLower()}"); + hocon.AppendLine( + $" IncludeRestrictedSubscriptions = {config.IncludeRestrictedSubscriptions.ToString().ToLower()}"); + hocon.AppendLine($" IgnoredUsersListName = \"{config.IgnoredUsersListName}\""); + hocon.AppendLine("}"); + + hocon.AppendLine("# Interaction Settings"); + hocon.AppendLine("Interaction {"); + hocon.AppendLine($" NonInteractiveMode = {config.NonInteractiveMode.ToString().ToLower()}"); + hocon.AppendLine($" NonInteractiveModeListName = \"{config.NonInteractiveModeListName}\""); + hocon.AppendLine( + $" NonInteractiveModePurchasedTab = {config.NonInteractiveModePurchasedTab.ToString().ToLower()}"); + hocon.AppendLine("}"); + + hocon.AppendLine("# Performance Settings"); + hocon.AppendLine("Performance {"); + hocon.AppendLine($" Timeout = {(config.Timeout.HasValue ? config.Timeout.Value : -1)}"); + hocon.AppendLine($" LimitDownloadRate = {config.LimitDownloadRate.ToString().ToLower()}"); + hocon.AppendLine($" DownloadLimitInMbPerSec = {config.DownloadLimitInMbPerSec}"); + hocon.AppendLine("}"); + + hocon.AppendLine("# Logging/Debug Settings"); + hocon.AppendLine("Logging {"); + hocon.AppendLine($" LoggingLevel = \"{config.LoggingLevel.ToString().ToLower()}\""); + hocon.AppendLine("}"); + + return hocon.ToString(); + } + + private void ValidateFileNameFormat(string? format, string settingName) + { + if (!string.IsNullOrEmpty(format) && + !format.Contains("{mediaId}", StringComparison.OrdinalIgnoreCase) && + !format.Contains("{filename}", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{settingName} is not unique enough. Please include either '{{mediaId}}' or '{{filename}}' to ensure files are not overwritten."); + } + } + + /// + /// Returns toggleable config properties and their current values. + /// + public List<(string Name, bool Value)> GetToggleableProperties() + { + List<(string Name, bool Value)> result = []; + foreach (PropertyInfo propInfo in typeof(Config).GetProperties()) + { + ToggleableConfigAttribute? attr = propInfo.GetCustomAttribute(); + if (attr != null) + { + bool? value = (bool?)propInfo.GetValue(CurrentConfig); + if (value == null) + { + continue; + } + + result.Add((propInfo.Name, value.Value)); + } + } + + return result; + } + + /// + /// Applies a set of toggle selections to the configuration. + /// + /// The names of toggles that should be enabled. + /// True when any values were changed. + public bool ApplyToggleableSelections(List selectedNames) + { + bool configChanged = false; + Config newConfig = new(); + + foreach (PropertyInfo propInfo in typeof(Config).GetProperties()) + { + ToggleableConfigAttribute? attr = propInfo.GetCustomAttribute(); + if (attr != null) + { + bool newValue = selectedNames.Contains(propInfo.Name); + bool? oldValue = (bool?)propInfo.GetValue(CurrentConfig); + + if (oldValue == null) + { + continue; + } + + propInfo.SetValue(newConfig, newValue); + + if (newValue != oldValue.Value) + { + configChanged = true; + } + } + else + { + propInfo.SetValue(newConfig, propInfo.GetValue(CurrentConfig)); + } + } + + UpdateConfig(newConfig); + return configChanged; + } + + private VideoResolution ParseVideoResolution(string value) + { + if (value.Equals("source", StringComparison.OrdinalIgnoreCase)) + { + return VideoResolution.source; + } + + return Enum.Parse("_" + value, true); + } +} diff --git a/OF DL.Core/Services/DbService.cs b/OF DL.Core/Services/DbService.cs new file mode 100644 index 0000000..872118d --- /dev/null +++ b/OF DL.Core/Services/DbService.cs @@ -0,0 +1,591 @@ +using System.Text; +using Microsoft.Data.Sqlite; +using OF_DL.Helpers; +using Serilog; + +namespace OF_DL.Services; + +public class DbService(IConfigService configService) : IDbService +{ + /// + /// Creates or updates the per-user metadata database. + /// + /// The user folder path. + public async Task CreateDb(string folder) + { + try + { + if (!Directory.Exists(folder + "/Metadata")) + { + Directory.CreateDirectory(folder + "/Metadata"); + } + + string dbFilePath = $"{folder}/Metadata/user_data.db"; + + // connect to the new database file + await using SqliteConnection connection = new($"Data Source={dbFilePath}"); + // open the connection + connection.Open(); + + // create the 'medias' table + await using (SqliteCommand cmd = + new( + "CREATE TABLE IF NOT EXISTS medias (id INTEGER NOT NULL, media_id INTEGER, post_id INTEGER NOT NULL, link VARCHAR, directory VARCHAR, filename VARCHAR, size INTEGER, api_type VARCHAR, media_type VARCHAR, preview INTEGER, linked VARCHAR, downloaded INTEGER, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(media_id));", + connection)) + { + await cmd.ExecuteNonQueryAsync(); + } + + await EnsureCreatedAtColumnExists(connection, "medias"); + + // + // Alter existing databases to create unique constraint on `medias` + // + await using (SqliteCommand cmd = new(@" + PRAGMA foreign_keys=off; + + BEGIN TRANSACTION; + + ALTER TABLE medias RENAME TO old_medias; + + CREATE TABLE medias ( + id INTEGER NOT NULL, + media_id INTEGER, + post_id INTEGER NOT NULL, + link VARCHAR, + directory VARCHAR, + filename VARCHAR, + size INTEGER, + api_type VARCHAR, + media_type VARCHAR, + preview INTEGER, + linked VARCHAR, + downloaded INTEGER, + created_at TIMESTAMP, + record_created_at TIMESTAMP, + PRIMARY KEY(id), + UNIQUE(media_id, api_type) + ); + + INSERT INTO medias SELECT * FROM old_medias; + + DROP TABLE old_medias; + + COMMIT; + + PRAGMA foreign_keys=on;", connection)) + { + await cmd.ExecuteNonQueryAsync(); + } + + // create the 'messages' table + await using (SqliteCommand cmd = + new( + "CREATE TABLE IF NOT EXISTS messages (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, user_id INTEGER, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", + connection)) + { + await cmd.ExecuteNonQueryAsync(); + } + + // create the 'posts' table + await using (SqliteCommand cmd = + new( + "CREATE TABLE IF NOT EXISTS posts (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", + connection)) + { + await cmd.ExecuteNonQueryAsync(); + } + + // create the 'stories' table + await using (SqliteCommand cmd = + new( + "CREATE TABLE IF NOT EXISTS stories (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", + connection)) + { + await cmd.ExecuteNonQueryAsync(); + } + + // create the 'others' table + await using (SqliteCommand cmd = + new( + "CREATE TABLE IF NOT EXISTS others (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", + connection)) + { + await cmd.ExecuteNonQueryAsync(); + } + + // create the 'products' table + await using (SqliteCommand cmd = + new( + "CREATE TABLE IF NOT EXISTS products (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, title VARCHAR, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", + connection)) + { + await cmd.ExecuteNonQueryAsync(); + } + + // create the 'profiles' table + await using (SqliteCommand cmd = + new( + "CREATE TABLE IF NOT EXISTS profiles (id INTEGER NOT NULL, user_id INTEGER NOT NULL, username VARCHAR NOT NULL, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(username));", + connection)) + { + await cmd.ExecuteNonQueryAsync(); + } + + connection.Close(); + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + } + + /// + /// Creates or updates the global users database. + /// + /// The users to seed or update. + public async Task CreateUsersDb(Dictionary users) + { + try + { + await using SqliteConnection connection = new($"Data Source={Directory.GetCurrentDirectory()}/users.db"); + Log.Debug("Database data source: " + connection.DataSource); + + connection.Open(); + + await using (SqliteCommand cmd = + new( + "CREATE TABLE IF NOT EXISTS users (id INTEGER NOT NULL, user_id INTEGER NOT NULL, username VARCHAR NOT NULL, PRIMARY KEY(id), UNIQUE(username));", + connection)) + { + await cmd.ExecuteNonQueryAsync(); + } + + Log.Debug("Adding missing creators"); + foreach (KeyValuePair user in users) + { + await using SqliteCommand checkCmd = new("SELECT user_id, username FROM users WHERE user_id = @userId;", + connection); + checkCmd.Parameters.AddWithValue("@userId", user.Value); + await using SqliteDataReader reader = await checkCmd.ExecuteReaderAsync(); + if (!reader.Read()) + { + await using SqliteCommand insertCmd = + new("INSERT INTO users (user_id, username) VALUES (@userId, @username);", + connection); + insertCmd.Parameters.AddWithValue("@userId", user.Value); + insertCmd.Parameters.AddWithValue("@username", user.Key); + await insertCmd.ExecuteNonQueryAsync(); + Log.Debug("Inserted new creator: " + user.Key); + } + else + { + Log.Debug("Creator " + user.Key + " already exists"); + } + } + + connection.Close(); + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + } + + /// + /// Ensures a username matches the stored user ID and migrates folders if needed. + /// + /// The user pair to validate. + /// The expected user folder path. + public async Task CheckUsername(KeyValuePair user, string path) + { + try + { + await using SqliteConnection connection = new($"Data Source={Directory.GetCurrentDirectory()}/users.db"); + + connection.Open(); + + await using (SqliteCommand checkCmd = new("SELECT user_id, username FROM users WHERE user_id = @userId;", + connection)) + { + checkCmd.Parameters.AddWithValue("@userId", user.Value); + await using (SqliteDataReader reader = await checkCmd.ExecuteReaderAsync()) + { + if (reader.Read()) + { + string storedUsername = reader.GetString(1); + + if (storedUsername != user.Key) + { + await using (SqliteCommand updateCmd = + new("UPDATE users SET username = @newUsername WHERE user_id = @userId;", + connection)) + { + updateCmd.Parameters.AddWithValue("@newUsername", user.Key); + updateCmd.Parameters.AddWithValue("@userId", user.Value); + await updateCmd.ExecuteNonQueryAsync(); + } + + string oldPath = path.Replace(path.Split("/")[^1], storedUsername); + + if (Directory.Exists(oldPath)) + { + Directory.Move(path.Replace(path.Split("/")[^1], storedUsername), path); + } + } + } + } + } + + connection.Close(); + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + } + + /// + /// Inserts a message record when it does not already exist. + /// + /// The user folder path. + /// The message or post ID. + /// The message text. + /// The price string. + /// Whether the message is paid. + /// Whether the message is archived. + /// The creation timestamp. + /// The sender user ID. + public async Task AddMessage(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt, long userId) + { + try + { + await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); + connection.Open(); + await EnsureCreatedAtColumnExists(connection, "messages"); + await using SqliteCommand cmd = new("SELECT COUNT(*) FROM messages WHERE post_id=@post_id", connection); + cmd.Parameters.AddWithValue("@post_id", postId); + int count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); + if (count == 0) + { + // If the record doesn't exist, insert a new one + await using SqliteCommand insertCmd = + new( + "INSERT INTO messages(post_id, text, price, paid, archived, created_at, user_id, record_created_at) VALUES(@post_id, @message_text, @price, @is_paid, @is_archived, @created_at, @user_id, @record_created_at)", + connection); + insertCmd.Parameters.AddWithValue("@post_id", postId); + insertCmd.Parameters.AddWithValue("@message_text", messageText); + insertCmd.Parameters.AddWithValue("@price", price); + insertCmd.Parameters.AddWithValue("@is_paid", isPaid); + insertCmd.Parameters.AddWithValue("@is_archived", isArchived); + insertCmd.Parameters.AddWithValue("@created_at", createdAt); + insertCmd.Parameters.AddWithValue("@user_id", userId); + insertCmd.Parameters.AddWithValue("@record_created_at", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + await insertCmd.ExecuteNonQueryAsync(); + } + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + } + + + /// + /// Inserts a post record when it does not already exist. + /// + /// The user folder path. + /// The post ID. + /// The post text. + /// The price string. + /// Whether the post is paid. + /// Whether the post is archived. + /// The creation timestamp. + public async Task AddPost(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt) + { + try + { + await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); + connection.Open(); + await EnsureCreatedAtColumnExists(connection, "posts"); + await using SqliteCommand cmd = new("SELECT COUNT(*) FROM posts WHERE post_id=@post_id", connection); + cmd.Parameters.AddWithValue("@post_id", postId); + int count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); + if (count == 0) + { + // If the record doesn't exist, insert a new one + await using SqliteCommand insertCmd = + new( + "INSERT INTO posts(post_id, text, price, paid, archived, created_at, record_created_at) VALUES(@post_id, @message_text, @price, @is_paid, @is_archived, @created_at, @record_created_at)", + connection); + insertCmd.Parameters.AddWithValue("@post_id", postId); + insertCmd.Parameters.AddWithValue("@message_text", messageText); + insertCmd.Parameters.AddWithValue("@price", price); + insertCmd.Parameters.AddWithValue("@is_paid", isPaid); + insertCmd.Parameters.AddWithValue("@is_archived", isArchived); + insertCmd.Parameters.AddWithValue("@created_at", createdAt); + insertCmd.Parameters.AddWithValue("@record_created_at", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + await insertCmd.ExecuteNonQueryAsync(); + } + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + } + + + /// + /// Inserts a story record when it does not already exist. + /// + /// The user folder path. + /// The story ID. + /// The story text. + /// The price string. + /// Whether the story is paid. + /// Whether the story is archived. + /// The creation timestamp. + public async Task AddStory(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt) + { + try + { + await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); + connection.Open(); + await EnsureCreatedAtColumnExists(connection, "stories"); + await using SqliteCommand cmd = new("SELECT COUNT(*) FROM stories WHERE post_id=@post_id", connection); + cmd.Parameters.AddWithValue("@post_id", postId); + int count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); + if (count == 0) + { + // If the record doesn't exist, insert a new one + await using SqliteCommand insertCmd = + new( + "INSERT INTO stories(post_id, text, price, paid, archived, created_at, record_created_at) VALUES(@post_id, @message_text, @price, @is_paid, @is_archived, @created_at, @record_created_at)", + connection); + insertCmd.Parameters.AddWithValue("@post_id", postId); + insertCmd.Parameters.AddWithValue("@message_text", messageText); + insertCmd.Parameters.AddWithValue("@price", price); + insertCmd.Parameters.AddWithValue("@is_paid", isPaid); + insertCmd.Parameters.AddWithValue("@is_archived", isArchived); + insertCmd.Parameters.AddWithValue("@created_at", createdAt); + insertCmd.Parameters.AddWithValue("@record_created_at", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + await insertCmd.ExecuteNonQueryAsync(); + } + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + } + + + /// + /// Inserts a media record when it does not already exist. + /// + /// The user folder path. + /// The media ID. + /// The parent post ID. + /// The media URL. + /// The local directory path. + /// The local filename. + /// The media size in bytes. + /// The API type label. + /// The media type label. + /// Whether the media is a preview. + /// Whether the media is downloaded. + /// The creation timestamp. + public async Task AddMedia(string folder, long mediaId, long postId, string link, string? directory, + string? filename, long? size, string apiType, string mediaType, bool preview, bool downloaded, + DateTime? createdAt) + { + try + { + await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); + connection.Open(); + await EnsureCreatedAtColumnExists(connection, "medias"); + StringBuilder sql = new("SELECT COUNT(*) FROM medias WHERE media_id=@media_id"); + if (configService.CurrentConfig.DownloadDuplicatedMedia) + { + sql.Append(" and api_type=@api_type"); + } + + await using SqliteCommand cmd = new(sql.ToString(), connection); + cmd.Parameters.AddWithValue("@media_id", mediaId); + cmd.Parameters.AddWithValue("@api_type", apiType); + int count = Convert.ToInt32(cmd.ExecuteScalar()); + if (count == 0) + { + // If the record doesn't exist, insert a new one + await using SqliteCommand insertCmd = new( + $"INSERT INTO medias(media_id, post_id, link, directory, filename, size, api_type, media_type, preview, downloaded, created_at, record_created_at) VALUES({mediaId}, {postId}, '{link}', '{directory ?? "NULL"}', '{filename ?? "NULL"}', {size?.ToString() ?? "NULL"}, '{apiType}', '{mediaType}', {Convert.ToInt32(preview)}, {Convert.ToInt32(downloaded)}, '{createdAt?.ToString("yyyy-MM-dd HH:mm:ss")}', '{DateTime.Now:yyyy-MM-dd HH:mm:ss}')", + connection); + await insertCmd.ExecuteNonQueryAsync(); + } + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + } + + + /// + /// Checks whether the media has been marked as downloaded. + /// + /// The user folder path. + /// The media ID. + /// The API type label. + /// True when the media is marked as downloaded. + public async Task CheckDownloaded(string folder, long mediaId, string apiType) + { + try + { + await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); + StringBuilder sql = new("SELECT downloaded FROM medias WHERE media_id=@media_id"); + if (configService.CurrentConfig.DownloadDuplicatedMedia) + { + sql.Append(" and api_type=@api_type"); + } + + connection.Open(); + await using SqliteCommand cmd = new(sql.ToString(), connection); + cmd.Parameters.AddWithValue("@media_id", mediaId); + cmd.Parameters.AddWithValue("@api_type", apiType); + bool downloaded = Convert.ToBoolean(await cmd.ExecuteScalarAsync()); + + return downloaded; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return false; + } + + + /// + /// Updates the media record with local file details. + /// + /// The user folder path. + /// The media ID. + /// The API type label. + /// The local directory path. + /// The local filename. + /// The file size in bytes. + /// Whether the media is downloaded. + /// The creation timestamp. + public async Task UpdateMedia(string folder, long mediaId, string apiType, string directory, string filename, + long size, bool downloaded, DateTime createdAt) + { + await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); + connection.Open(); + + // Construct the update command + StringBuilder sql = + new( + "UPDATE medias SET directory=@directory, filename=@filename, size=@size, downloaded=@downloaded, created_at=@created_at WHERE media_id=@media_id"); + if (configService.CurrentConfig.DownloadDuplicatedMedia) + { + sql.Append(" and api_type=@api_type"); + } + + // Create a new command object + await using SqliteCommand command = new(sql.ToString(), connection); + // Add parameters to the command object + command.Parameters.AddWithValue("@directory", directory); + command.Parameters.AddWithValue("@filename", filename); + command.Parameters.AddWithValue("@size", size); + command.Parameters.AddWithValue("@downloaded", downloaded ? 1 : 0); + command.Parameters.AddWithValue("@created_at", createdAt); + command.Parameters.AddWithValue("@media_id", mediaId); + command.Parameters.AddWithValue("@api_type", apiType); + + // Execute the command + await command.ExecuteNonQueryAsync(); + } + + + /// + /// Returns the stored size for a media record. + /// + /// The user folder path. + /// The media ID. + /// The API type label. + /// The stored file size. + public async Task GetStoredFileSize(string folder, long mediaId, string apiType) + { + await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); + connection.Open(); + await using SqliteCommand cmd = new("SELECT size FROM medias WHERE media_id=@media_id and api_type=@api_type", + connection); + cmd.Parameters.AddWithValue("@media_id", mediaId); + cmd.Parameters.AddWithValue("@api_type", apiType); + long size = Convert.ToInt64(await cmd.ExecuteScalarAsync()); + + return size; + } + + /// + /// Returns the most recent post date based on downloaded and pending media. + /// + /// The user folder path. + /// The most recent post date if available. + public async Task GetMostRecentPostDate(string folder) + { + DateTime? mostRecentDate = null; + await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); + connection.Open(); + await using SqliteCommand cmd = new(@" + SELECT + MIN(created_at) AS created_at + FROM ( + SELECT MAX(P.created_at) AS created_at + FROM posts AS P + LEFT OUTER JOIN medias AS m + ON P.post_id = m.post_id + AND m.downloaded = 1 + UNION + SELECT MIN(P.created_at) AS created_at + FROM posts AS P + INNER JOIN medias AS m + ON P.post_id = m.post_id + WHERE m.downloaded = 0 + )", connection); + object? scalarValue = await cmd.ExecuteScalarAsync(); + if (scalarValue != null && scalarValue != DBNull.Value) + { + mostRecentDate = Convert.ToDateTime(scalarValue); + } + + return mostRecentDate; + } + + private async Task EnsureCreatedAtColumnExists(SqliteConnection connection, string tableName) + { + await using SqliteCommand cmd = new($"PRAGMA table_info({tableName});", connection); + await using SqliteDataReader reader = await cmd.ExecuteReaderAsync(); + bool columnExists = false; + + while (await reader.ReadAsync()) + { + if (reader["name"].ToString() != "record_created_at") + { + continue; + } + + columnExists = true; + break; + } + + if (!columnExists) + { + await using SqliteCommand alterCmd = new($"ALTER TABLE {tableName} ADD COLUMN record_created_at TIMESTAMP;", + connection); + await alterCmd.ExecuteNonQueryAsync(); + } + } +} diff --git a/OF DL.Core/Services/DownloadOrchestrationService.cs b/OF DL.Core/Services/DownloadOrchestrationService.cs new file mode 100644 index 0000000..c821c31 --- /dev/null +++ b/OF DL.Core/Services/DownloadOrchestrationService.cs @@ -0,0 +1,619 @@ +using Newtonsoft.Json.Linq; +using OF_DL.Enumerations; +using OF_DL.Models.Config; +using OF_DL.Models.Downloads; +using Serilog; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; +using UserEntities = OF_DL.Models.Entities.Users; + +namespace OF_DL.Services; + +public class DownloadOrchestrationService( + IApiService apiService, + IConfigService configService, + IDownloadService downloadService, + IDbService dbService) : IDownloadOrchestrationService +{ + /// + /// Gets the list of paid post media IDs to avoid duplicates. + /// + public List PaidPostIds { get; } = []; + + /// + /// Retrieves the available users and lists based on the current configuration. + /// + /// A result containing users, lists, and any errors. + public async Task GetAvailableUsersAsync() + { + UserListResult result = new(); + Config config = configService.CurrentConfig; + + Dictionary? activeSubs = + await apiService.GetActiveSubscriptions("/subscriptions/subscribes", + config.IncludeRestrictedSubscriptions); + + if (activeSubs != null) + { + Log.Debug("Subscriptions: "); + foreach (KeyValuePair activeSub in activeSubs) + { + if (!result.Users.ContainsKey(activeSub.Key)) + { + result.Users.Add(activeSub.Key, activeSub.Value); + Log.Debug($"Name: {activeSub.Key} ID: {activeSub.Value}"); + } + } + } + else + { + Log.Error("Couldn't get active subscriptions. Received null response."); + } + + + if (config.IncludeExpiredSubscriptions) + { + Log.Debug("Inactive Subscriptions: "); + Dictionary? expiredSubs = + await apiService.GetExpiredSubscriptions("/subscriptions/subscribes", + config.IncludeRestrictedSubscriptions); + + if (expiredSubs != null) + { + foreach (KeyValuePair expiredSub in expiredSubs.Where(expiredSub => + !result.Users.ContainsKey(expiredSub.Key))) + { + result.Users.Add(expiredSub.Key, expiredSub.Value); + Log.Debug("Name: {ExpiredSubKey} ID: {ExpiredSubValue}", expiredSub.Key, expiredSub.Value); + } + } + else + { + Log.Error("Couldn't get expired subscriptions. Received null response."); + } + } + + result.Lists = await apiService.GetLists("/lists") ?? new Dictionary(); + + // Remove users from the list if they are in the ignored list + if (!string.IsNullOrEmpty(config.IgnoredUsersListName)) + { + if (!result.Lists.TryGetValue(config.IgnoredUsersListName, out long ignoredUsersListId)) + { + result.IgnoredListError = $"Ignored users list '{config.IgnoredUsersListName}' not found"; + Log.Error(result.IgnoredListError); + } + else + { + List ignoredUsernames = + await apiService.GetListUsers($"/lists/{ignoredUsersListId}/users") ?? []; + result.Users = result.Users.Where(x => !ignoredUsernames.Contains(x.Key)) + .ToDictionary(x => x.Key, x => x.Value); + } + } + + await dbService.CreateUsersDb(result.Users); + return result; + } + + /// + /// Resolves the users that belong to a specific list. + /// + /// The list name. + /// All available users. + /// Known lists keyed by name. + /// The users that belong to the list. + public async Task> GetUsersForListAsync( + string listName, Dictionary allUsers, Dictionary lists) + { + long listId = lists[listName]; + List listUsernames = await apiService.GetListUsers($"/lists/{listId}/users") ?? []; + return allUsers.Where(x => listUsernames.Contains(x.Key)) + .ToDictionary(x => x.Key, x => x.Value); + } + + /// + /// Resolves the download path for a username based on configuration. + /// + /// The creator username. + /// The resolved download path. + public string ResolveDownloadPath(string username) => + !string.IsNullOrEmpty(configService.CurrentConfig.DownloadPath) + ? Path.Combine(configService.CurrentConfig.DownloadPath, username) + : $"__user_data__/sites/OnlyFans/{username}"; + + /// + /// Ensures the user folder and metadata database exist. + /// + /// The creator username. + /// The creator user ID. + /// The creator folder path. + public async Task PrepareUserFolderAsync(string username, long userId, string path) + { + await dbService.CheckUsername(new KeyValuePair(username, userId), path); + + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + Log.Debug($"Created folder for {username}"); + } + + await dbService.CreateDb(path); + } + + /// + /// Downloads all configured content types for a creator. + /// + /// The creator username. + /// The creator user ID. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// Download event handler. + /// Counts of downloaded items per content type. + public async Task DownloadCreatorContentAsync( + string username, long userId, string path, + Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + IDownloadEventHandler eventHandler) + { + Config config = configService.CurrentConfig; + CreatorDownloadResult counts = new(); + + eventHandler.OnUserStarting(username); + Log.Debug($"Scraping Data for {username}"); + + await PrepareUserFolderAsync(username, userId, path); + + if (config.DownloadAvatarHeaderPhoto) + { + UserEntities.User? userInfo = await apiService.GetUserInfo($"/users/{username}"); + if (userInfo != null) + { + await downloadService.DownloadAvatarHeader(userInfo.Avatar, userInfo.Header, path, username); + } + } + + if (config.DownloadPaidPosts) + { + counts.PaidPostCount = await DownloadContentTypeAsync("Paid Posts", + async statusReporter => + await apiService.GetPaidPosts("/posts/paid/post", path, username, PaidPostIds, statusReporter), + posts => posts.PaidPosts.Count, + posts => posts.PaidPostObjects.Count, + posts => posts.PaidPosts.Values.ToList(), + async (posts, reporter) => await downloadService.DownloadPaidPosts(username, userId, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, posts, reporter), + eventHandler); + } + + if (config.DownloadPosts) + { + eventHandler.OnMessage( + "Getting Posts (this may take a long time, depending on the number of Posts the creator has)"); + Log.Debug($"Calling DownloadFreePosts - {username}"); + + counts.PostCount = await DownloadContentTypeAsync("Posts", + async statusReporter => + await apiService.GetPosts($"/users/{userId}/posts", path, PaidPostIds, statusReporter), + posts => posts.Posts.Count, + posts => posts.PostObjects.Count, + posts => posts.Posts.Values.ToList(), + async (posts, reporter) => await downloadService.DownloadFreePosts(username, userId, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, posts, reporter), + eventHandler); + } + + if (config.DownloadArchived) + { + counts.ArchivedCount = await DownloadContentTypeAsync("Archived Posts", + async statusReporter => + await apiService.GetArchived($"/users/{userId}/posts", path, statusReporter), + archived => archived.ArchivedPosts.Count, + archived => archived.ArchivedPostObjects.Count, + archived => archived.ArchivedPosts.Values.ToList(), + async (archived, reporter) => await downloadService.DownloadArchived(username, userId, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, archived, reporter), + eventHandler); + } + + if (config.DownloadStreams) + { + counts.StreamsCount = await DownloadContentTypeAsync("Streams", + async statusReporter => + await apiService.GetStreams($"/users/{userId}/posts/streams", path, PaidPostIds, statusReporter), + streams => streams.Streams.Count, + streams => streams.StreamObjects.Count, + streams => streams.Streams.Values.ToList(), + async (streams, reporter) => await downloadService.DownloadStreams(username, userId, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, streams, reporter), + eventHandler); + } + + if (config.DownloadStories) + { + eventHandler.OnMessage("Getting Stories"); + Dictionary? tempStories = await apiService.GetMedia(MediaType.Stories, + $"/users/{userId}/stories", null, path); + + if (tempStories is { Count: > 0 }) + { + eventHandler.OnContentFound("Stories", tempStories.Count, tempStories.Count); + + long totalSize = config.ShowScrapeSize + ? await downloadService.CalculateTotalFileSize(tempStories.Values.ToList()) + : tempStories.Count; + + DownloadResult result = await eventHandler.WithProgressAsync( + $"Downloading {tempStories.Count} Stories", totalSize, config.ShowScrapeSize, + async reporter => await downloadService.DownloadStories(username, userId, path, + PaidPostIds.ToHashSet(), reporter)); + + eventHandler.OnDownloadComplete("Stories", result); + counts.StoriesCount = result.TotalCount; + } + else + { + eventHandler.OnNoContentFound("Stories"); + } + } + + if (config.DownloadHighlights) + { + eventHandler.OnMessage("Getting Highlights"); + Dictionary? tempHighlights = await apiService.GetMedia(MediaType.Highlights, + $"/users/{userId}/stories/highlights", null, path); + + if (tempHighlights is { Count: > 0 }) + { + eventHandler.OnContentFound("Highlights", tempHighlights.Count, tempHighlights.Count); + + long totalSize = config.ShowScrapeSize + ? await downloadService.CalculateTotalFileSize(tempHighlights.Values.ToList()) + : tempHighlights.Count; + + DownloadResult result = await eventHandler.WithProgressAsync( + $"Downloading {tempHighlights.Count} Highlights", totalSize, config.ShowScrapeSize, + async reporter => await downloadService.DownloadHighlights(username, userId, path, + PaidPostIds.ToHashSet(), reporter)); + + eventHandler.OnDownloadComplete("Highlights", result); + counts.HighlightsCount = result.TotalCount; + } + else + { + eventHandler.OnNoContentFound("Highlights"); + } + } + + if (config.DownloadMessages) + { + counts.MessagesCount = await DownloadContentTypeAsync("Messages", + async statusReporter => + await apiService.GetMessages($"/chats/{userId}/messages", path, statusReporter), + messages => messages.Messages.Count, + messages => messages.MessageObjects.Count, + messages => messages.Messages.Values.ToList(), + async (messages, reporter) => await downloadService.DownloadMessages(username, userId, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, messages, reporter), + eventHandler); + } + + if (config.DownloadPaidMessages) + { + counts.PaidMessagesCount = await DownloadContentTypeAsync("Paid Messages", + async statusReporter => + await apiService.GetPaidMessages("/posts/paid/chat", path, username, statusReporter), + paidMessages => paidMessages.PaidMessages.Count, + paidMessages => paidMessages.PaidMessageObjects.Count, + paidMessages => paidMessages.PaidMessages.Values.ToList(), + async (paidMessages, reporter) => await downloadService.DownloadPaidMessages(username, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, paidMessages, reporter), + eventHandler); + } + + eventHandler.OnUserComplete(username, counts); + return counts; + } + + /// + /// Downloads a single post by ID for a creator. + /// + /// The creator username. + /// The post ID. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// Download event handler. + public async Task DownloadSinglePostAsync( + string username, long postId, string path, + Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + IDownloadEventHandler eventHandler) + { + Log.Debug($"Calling DownloadSinglePost - {postId}"); + eventHandler.OnMessage("Getting Post"); + + PostEntities.SinglePostCollection post = await apiService.GetPost($"/posts/{postId}", path); + if (post.SinglePosts.Count == 0) + { + eventHandler.OnMessage("Couldn't find post"); + Log.Debug("Couldn't find post"); + return; + } + + Config config = configService.CurrentConfig; + long totalSize = config.ShowScrapeSize + ? await downloadService.CalculateTotalFileSize(post.SinglePosts.Values.ToList()) + : post.SinglePosts.Count; + + DownloadResult result = await eventHandler.WithProgressAsync( + "Downloading Post", totalSize, config.ShowScrapeSize, + async reporter => await downloadService.DownloadSinglePost(username, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, post, reporter)); + + if (result.NewDownloads > 0) + { + eventHandler.OnMessage($"Post {postId} downloaded"); + Log.Debug($"Post {postId} downloaded"); + } + else + { + eventHandler.OnMessage($"Post {postId} already downloaded"); + Log.Debug($"Post {postId} already downloaded"); + } + } + + /// + /// Downloads content from the Purchased tab across creators. + /// + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// Download event handler. + public async Task DownloadPurchasedTabAsync( + Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + IDownloadEventHandler eventHandler) + { + Config config = configService.CurrentConfig; + + Dictionary purchasedTabUsers = + await apiService.GetPurchasedTabUsers("/posts/paid/all", users); + + eventHandler.OnMessage("Checking folders for users in Purchased Tab"); + + foreach (KeyValuePair user in purchasedTabUsers) + { + string path = ResolveDownloadPath(user.Key); + Log.Debug($"Download path: {path}"); + + await dbService.CheckUsername(user, path); + + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + Log.Debug($"Created folder for {user.Key}"); + } + + await apiService.GetUserInfo($"/users/{user.Key}"); + await dbService.CreateDb(path); + } + + string basePath = !string.IsNullOrEmpty(config.DownloadPath) + ? config.DownloadPath + : "__user_data__/sites/OnlyFans/"; + + Log.Debug($"Download path: {basePath}"); + + List purchasedTabCollections = + await apiService.GetPurchasedTab("/posts/paid/all", basePath, users); + + foreach (PurchasedEntities.PurchasedTabCollection purchasedTabCollection in purchasedTabCollections) + { + eventHandler.OnUserStarting(purchasedTabCollection.Username); + string path = ResolveDownloadPath(purchasedTabCollection.Username); + Log.Debug($"Download path: {path}"); + + int paidPostCount = 0; + int paidMessagesCount = 0; + + // Download paid posts + if (purchasedTabCollection.PaidPosts.PaidPosts.Count > 0) + { + eventHandler.OnContentFound("Paid Posts", + purchasedTabCollection.PaidPosts.PaidPosts.Count, + purchasedTabCollection.PaidPosts.PaidPostObjects.Count); + + long totalSize = config.ShowScrapeSize + ? await downloadService.CalculateTotalFileSize( + purchasedTabCollection.PaidPosts.PaidPosts.Values.ToList()) + : purchasedTabCollection.PaidPosts.PaidPosts.Count; + + DownloadResult postResult = await eventHandler.WithProgressAsync( + $"Downloading {purchasedTabCollection.PaidPosts.PaidPosts.Count} Paid Posts", + totalSize, config.ShowScrapeSize, + async reporter => await downloadService.DownloadPaidPostsPurchasedTab( + purchasedTabCollection.Username, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, + purchasedTabCollection.PaidPosts, reporter)); + + eventHandler.OnDownloadComplete("Paid Posts", postResult); + paidPostCount = postResult.TotalCount; + } + else + { + eventHandler.OnNoContentFound("Paid Posts"); + } + + // Download paid messages + if (purchasedTabCollection.PaidMessages.PaidMessages.Count > 0) + { + eventHandler.OnContentFound("Paid Messages", + purchasedTabCollection.PaidMessages.PaidMessages.Count, + purchasedTabCollection.PaidMessages.PaidMessageObjects.Count); + + long totalSize = config.ShowScrapeSize + ? await downloadService.CalculateTotalFileSize( + purchasedTabCollection.PaidMessages.PaidMessages.Values.ToList()) + : purchasedTabCollection.PaidMessages.PaidMessages.Count; + + DownloadResult msgResult = await eventHandler.WithProgressAsync( + $"Downloading {purchasedTabCollection.PaidMessages.PaidMessages.Count} Paid Messages", + totalSize, config.ShowScrapeSize, + async reporter => await downloadService.DownloadPaidMessagesPurchasedTab( + purchasedTabCollection.Username, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, + purchasedTabCollection.PaidMessages, reporter)); + + eventHandler.OnDownloadComplete("Paid Messages", msgResult); + paidMessagesCount = msgResult.TotalCount; + } + else + { + eventHandler.OnNoContentFound("Paid Messages"); + } + + eventHandler.OnPurchasedTabUserComplete(purchasedTabCollection.Username, paidPostCount, paidMessagesCount); + } + } + + /// + /// Downloads a single paid message by ID. + /// + /// The creator username. + /// The message ID. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// Download event handler. + public async Task DownloadSinglePaidMessageAsync( + string username, long messageId, string path, + Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + IDownloadEventHandler eventHandler) + { + Log.Debug($"Calling DownloadSinglePaidMessage - {username}"); + eventHandler.OnMessage("Getting Paid Message"); + + PurchasedEntities.SinglePaidMessageCollection singlePaidMessageCollection = + await apiService.GetPaidMessage($"/messages/{messageId}", path); + + if (singlePaidMessageCollection.SingleMessages.Count == 0) + { + eventHandler.OnNoContentFound("Paid Messages"); + return; + } + + Config config = configService.CurrentConfig; + + // Handle preview messages + if (singlePaidMessageCollection.PreviewSingleMessages.Count > 0) + { + eventHandler.OnContentFound("Preview Paid Messages", + singlePaidMessageCollection.PreviewSingleMessages.Count, + singlePaidMessageCollection.SingleMessageObjects.Count); + + long previewSize = config.ShowScrapeSize + ? await downloadService.CalculateTotalFileSize( + singlePaidMessageCollection.PreviewSingleMessages.Values.ToList()) + : singlePaidMessageCollection.PreviewSingleMessages.Count; + + DownloadResult previewResult = await eventHandler.WithProgressAsync( + $"Downloading {singlePaidMessageCollection.PreviewSingleMessages.Count} Preview Paid Messages", + previewSize, config.ShowScrapeSize, + async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter)); + + eventHandler.OnDownloadComplete("Paid Messages", previewResult); + } + else if (singlePaidMessageCollection.SingleMessages.Count > 0) + { + // Only actual paid messages, no preview + eventHandler.OnContentFound("Paid Messages", + singlePaidMessageCollection.SingleMessages.Count, + singlePaidMessageCollection.SingleMessageObjects.Count); + + long totalSize = config.ShowScrapeSize + ? await downloadService.CalculateTotalFileSize( + singlePaidMessageCollection.SingleMessages.Values.ToList()) + : singlePaidMessageCollection.SingleMessages.Count; + + DownloadResult result = await eventHandler.WithProgressAsync( + $"Downloading {singlePaidMessageCollection.SingleMessages.Count} Paid Messages", + totalSize, config.ShowScrapeSize, + async reporter => await downloadService.DownloadSinglePaidMessage(username, path, users, + clientIdBlobMissing, devicePrivateKeyMissing, singlePaidMessageCollection, reporter)); + + eventHandler.OnDownloadComplete("Paid Messages", result); + } + else + { + eventHandler.OnNoContentFound("Paid Messages"); + } + } + + /// + /// Resolves a username for a user ID, including deleted users. + /// + /// The user ID. + /// The resolved username or a deleted user placeholder. + public async Task ResolveUsernameAsync(long userId) + { + JObject? user = await apiService.GetUserInfoById($"/users/list?x[]={userId}"); + if (user == null) + { + return $"Deleted User - {userId}"; + } + + string? username = user[userId.ToString()]?["username"]?.ToString(); + return !string.IsNullOrEmpty(username) ? username : $"Deleted User - {userId}"; + } + + /// + /// Generic helper for the common pattern: fetch with status -> check count -> download with progress. + /// + private async Task DownloadContentTypeAsync( + string contentType, + Func> fetchData, + Func getMediaCount, + Func getObjectCount, + Func?> getUrls, + Func> downloadData, + IDownloadEventHandler eventHandler) + { + T data = await eventHandler.WithStatusAsync($"Getting {contentType}", + async statusReporter => await fetchData(statusReporter)); + + int mediaCount = getMediaCount(data); + if (mediaCount <= 0) + { + eventHandler.OnNoContentFound(contentType); + Log.Debug($"Found 0 {contentType}"); + return 0; + } + + int objectCount = getObjectCount(data); + eventHandler.OnContentFound(contentType, mediaCount, objectCount); + Log.Debug($"Found {mediaCount} Media from {objectCount} {contentType}"); + + Config config = configService.CurrentConfig; + List? urls = getUrls(data); + long totalSize = config.ShowScrapeSize && urls != null + ? await downloadService.CalculateTotalFileSize(urls) + : mediaCount; + + DownloadResult result = await eventHandler.WithProgressAsync( + $"Downloading {mediaCount} {contentType}", totalSize, config.ShowScrapeSize, + async reporter => await downloadData(data, reporter)); + + eventHandler.OnDownloadComplete(contentType, result); + Log.Debug( + $"{contentType} Already Downloaded: {result.ExistingDownloads} New {contentType} Downloaded: {result.NewDownloads}"); + + return result.TotalCount; + } +} diff --git a/OF DL.Core/Services/DownloadService.cs b/OF DL.Core/Services/DownloadService.cs new file mode 100644 index 0000000..631fb20 --- /dev/null +++ b/OF DL.Core/Services/DownloadService.cs @@ -0,0 +1,2089 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using FFmpeg.NET; +using FFmpeg.NET.Events; +using OF_DL.Models; +using OF_DL.Enumerations; +using OF_DL.Helpers; +using OF_DL.Models.Downloads; +using ArchivedEntities = OF_DL.Models.Entities.Archived; +using MessageEntities = OF_DL.Models.Entities.Messages; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; +using StreamEntities = OF_DL.Models.Entities.Streams; +using OF_DL.Utils; +using Serilog; +using Serilog.Events; + +namespace OF_DL.Services; + +public class DownloadService( + IAuthService authService, + IConfigService configService, + IDbService dbService, + IFileNameService fileNameService, + IApiService apiService) + : IDownloadService +{ + private TaskCompletionSource _completionSource = new(); + + /// + /// Downloads profile avatar and header images for a creator. + /// + /// The avatar URL. + /// The header URL. + /// The creator folder path. + /// The creator username. + public async Task DownloadAvatarHeader(string? avatarUrl, string? headerUrl, string folder, string username) + { + try + { + const string path = "/Profile"; + + if (!Directory.Exists(folder + path)) + { + Directory.CreateDirectory(folder + path); + } + + if (!string.IsNullOrEmpty(avatarUrl)) + { + await DownloadProfileImage(avatarUrl, folder, $"{path}/Avatars", username); + } + + if (!string.IsNullOrEmpty(headerUrl)) + { + await DownloadProfileImage(headerUrl, folder, $"{path}/Headers", username); + } + } + catch (Exception ex) + { + Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); + + if (ex.InnerException != null) + { + Console.WriteLine("\nInner Exception:"); + Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, + ex.InnerException.StackTrace); + } + } + } + + private static async Task DownloadProfileImage(string url, string folder, string subFolder, string username) + { + if (!Directory.Exists(folder + subFolder)) + { + Directory.CreateDirectory(folder + subFolder); + } + + List md5Hashes = CalculateFolderMd5(folder + subFolder); + + Uri uri = new(url); + string destinationPath = $"{folder}{subFolder}/"; + + HttpClient client = new(); + + HttpRequestMessage request = new() { Method = HttpMethod.Get, RequestUri = uri }; + using HttpResponseMessage response = + await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + using MemoryStream memoryStream = new(); + await response.Content.CopyToAsync(memoryStream); + memoryStream.Seek(0, SeekOrigin.Begin); + + MD5 md5 = MD5.Create(); + byte[] hash = await md5.ComputeHashAsync(memoryStream); + memoryStream.Seek(0, SeekOrigin.Begin); + if (!md5Hashes.Contains(BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant())) + { + destinationPath = destinationPath + string.Format("{0} {1}.jpg", username, + response.Content.Headers.LastModified.HasValue + ? response.Content.Headers.LastModified.Value.LocalDateTime.ToString("dd-MM-yyyy") + : DateTime.Now.ToString("dd-MM-yyyy")); + + await using (FileStream fileStream = File.Create(destinationPath)) + { + await memoryStream.CopyToAsync(fileStream); + } + + File.SetLastWriteTime(destinationPath, + response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now); + } + } + + private async Task DownloadDrmMedia(string userAgent, string policy, string signature, string kvp, + string sess, string url, string decryptionKey, string folder, DateTime lastModified, long mediaId, + string apiType, IProgressReporter progressReporter, string customFileName, string filename, string path) + { + try + { + _completionSource = new TaskCompletionSource(); + + int pos1 = decryptionKey.IndexOf(':'); + string decKey = ""; + if (pos1 >= 0) + { + decKey = decryptionKey[(pos1 + 1)..]; + } + + int streamIndex = 0; + string tempFilename = $"{folder}{path}/{filename}_source.mp4"; + + // Configure ffmpeg log level and optional report file location + bool ffmpegDebugLogging = Log.IsEnabled(LogEventLevel.Debug); + + string logLevelArgs = ffmpegDebugLogging || + configService.CurrentConfig.LoggingLevel is LoggingLevel.Verbose or LoggingLevel.Debug + ? "-loglevel debug -report" + : configService.CurrentConfig.LoggingLevel switch + { + LoggingLevel.Information => "-loglevel info", + LoggingLevel.Warning => "-loglevel warning", + LoggingLevel.Error => "-loglevel error", + LoggingLevel.Fatal => "-loglevel fatal", + _ => "" + }; + + if (logLevelArgs.Contains("-report", StringComparison.OrdinalIgnoreCase)) + { + // Direct ffmpeg report files into the same logs directory Serilog uses (relative to current working directory) + string logDir = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs")); + Directory.CreateDirectory(logDir); + string ffReportPath = Path.Combine(logDir, "ffmpeg-%p-%t.log"); // ffmpeg will replace %p/%t + Environment.SetEnvironmentVariable("FFREPORT", $"file={ffReportPath}:level=32"); + Log.Debug("FFREPORT enabled at: {FFREPORT} (cwd: {Cwd})", + Environment.GetEnvironmentVariable("FFREPORT"), Environment.CurrentDirectory); + } + else + { + Environment.SetEnvironmentVariable("FFREPORT", null); + Log.Debug("FFREPORT disabled (cwd: {Cwd})", Environment.CurrentDirectory); + } + + string cookieHeader = + "Cookie: " + + $"CloudFront-Policy={policy}; " + + $"CloudFront-Signature={signature}; " + + $"CloudFront-Key-Pair-Id={kvp}; " + + $"{sess}"; + + string parameters = + $"{logLevelArgs} " + + $"-cenc_decryption_key {decKey} " + + $"-headers \"{cookieHeader}\" " + + $"-user_agent \"{userAgent}\" " + + "-referer \"https://onlyfans.com\" " + + "-rw_timeout 20000000 " + + "-reconnect 1 -reconnect_streamed 1 -reconnect_on_network_error 1 -reconnect_delay_max 10 " + + "-y " + + $"-i \"{url}\" " + + $"-map 0:v:{streamIndex} -map 0:a? " + + "-c copy " + + $"\"{tempFilename}\""; + + Log.Debug($"Calling FFMPEG with Parameters: {parameters}"); + + Engine ffmpeg = new(configService.CurrentConfig.FFmpegPath); + ffmpeg.Error += OnError; + ffmpeg.Complete += async (_, _) => + { + _completionSource.TrySetResult(true); + await OnFFMPEGDownloadComplete(tempFilename, lastModified, folder, path, customFileName, filename, + mediaId, apiType, progressReporter); + }; + await ffmpeg.ExecuteAsync(parameters, CancellationToken.None); + + return await _completionSource.Task; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return false; + } + + private async Task OnFFMPEGDownloadComplete(string tempFilename, DateTime lastModified, string folder, string path, + string customFileName, string filename, long mediaId, string apiType, IProgressReporter progressReporter) + { + try + { + if (File.Exists(tempFilename)) + { + File.SetLastWriteTime(tempFilename, lastModified); + } + + if (!string.IsNullOrEmpty(customFileName)) + { + File.Move(tempFilename, $"{folder + path + "/" + customFileName + ".mp4"}"); + } + + // Cleanup Files + long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) + ? folder + path + "/" + customFileName + ".mp4" + : tempFilename).Length; + ReportProgress(progressReporter, fileSizeInBytes); + + await dbService.UpdateMedia(folder, mediaId, apiType, folder + path, + !string.IsNullOrEmpty(customFileName) ? customFileName + ".mp4" : filename + "_source.mp4", + fileSizeInBytes, true, lastModified); + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + } + + private void OnError(object? sender, ConversionErrorEventArgs e) + { + // Guard all fields to avoid NullReference exceptions from FFmpeg.NET + string input = e.Input?.Name ?? ""; + string output = e.Output?.Name ?? ""; + string exitCode = e.Exception?.ExitCode.ToString() ?? ""; + string message = e.Exception?.Message ?? ""; + string inner = e.Exception?.InnerException?.Message ?? ""; + + Log.Error("FFmpeg failed. Input={Input} Output={Output} ExitCode={ExitCode} Message={Message} Inner={Inner}", + input, output, exitCode, message, inner); + + _completionSource.TrySetResult(false); + } + + private static List CalculateFolderMd5(string folder) + { + List md5Hashes = []; + if (!Directory.Exists(folder)) + { + return md5Hashes; + } + + string[] files = Directory.GetFiles(folder); + + md5Hashes.AddRange(files.Select(CalculateMd5)); + + return md5Hashes; + } + + private static string CalculateMd5(string filePath) + { + using MD5 md5 = MD5.Create(); + using FileStream stream = File.OpenRead(filePath); + byte[] hash = md5.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + private async Task CreateDirectoriesAndDownloadMedia(string path, + string url, + string folder, + long mediaId, + string apiType, + IProgressReporter progressReporter, + string serverFileName, + string resolvedFileName) + { + try + { + if (!Directory.Exists(folder + path)) + { + Directory.CreateDirectory(folder + path); + } + + string extension = Path.GetExtension(url.Split("?")[0]); + + path = UpdatePathBasedOnExtension(folder, path, extension); + + return await ProcessMediaDownload(folder, mediaId, apiType, url, path, serverFileName, resolvedFileName, + extension, progressReporter); + } + catch (Exception ex) + { + Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); + + if (ex.InnerException != null) + { + Console.WriteLine("\nInner Exception:"); + Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, + ex.InnerException.StackTrace); + } + } + + return false; + } + + + /// + /// Updates the given path based on the file extension. + /// + /// The parent folder. + /// The initial relative path. + /// The file extension. + /// A string that represents the updated path based on the file extension. + private static string UpdatePathBasedOnExtension(string folder, string path, string extension) + { + string subdirectory = ""; + + switch (extension.ToLower()) + { + case ".jpg": + case ".jpeg": + case ".png": + subdirectory = "/Images"; + break; + case ".mp4": + case ".avi": + case ".wmv": + case ".gif": + case ".mov": + subdirectory = "/Videos"; + break; + case ".mp3": + case ".wav": + case ".ogg": + subdirectory = "/Audios"; + break; + } + + if (!string.IsNullOrEmpty(subdirectory)) + { + path += subdirectory; + string fullPath = folder + path; + + if (!Directory.Exists(fullPath)) + { + Directory.CreateDirectory(fullPath); + } + } + + return path; + } + + + /// + /// Generates a custom filename based on the given format and properties. + /// + /// + /// The format string for the filename. + /// General information about the post. + /// Media associated with the post. + /// Author of the post. + /// + /// Dictionary containing user-related data. + /// + /// + /// A Task resulting in a string that represents the custom filename. + private static async Task GenerateCustomFileName(string filename, + string? filenameFormat, + object? postInfo, + object? postMedia, + object? author, + string username, + Dictionary users, + IFileNameService fileNameService, + CustomFileNameOption option) + { + if (string.IsNullOrEmpty(filenameFormat) || postInfo == null || postMedia == null || author == null) + { + return option switch + { + CustomFileNameOption.ReturnOriginal => filename, + CustomFileNameOption.ReturnEmpty => "", + _ => filename + }; + } + + List properties = new(); + string pattern = @"\{(.*?)\}"; + MatchCollection matches = Regex.Matches(filenameFormat, pattern); + properties.AddRange(matches.Select(match => match.Groups[1].Value)); + + Dictionary values = + await fileNameService.GetFilename(postInfo, postMedia, author, properties, username, users); + return await fileNameService.BuildFilename(filenameFormat, values); + } + + + private async Task GetFileSizeAsync(string url) + { + long fileSize = 0; + + try + { + if (authService.CurrentAuth == null) + { + throw new Exception("No authentication information available."); + } + + if (authService.CurrentAuth.Cookie == null) + { + throw new Exception("No authentication cookie available."); + } + + if (authService.CurrentAuth.UserAgent == null) + { + throw new Exception("No user agent available."); + } + + Uri uri = new(url); + + if (uri.Host == "cdn3.onlyfans.com" && uri.LocalPath.Contains("/dash/files")) + { + string[] messageUrlParsed = url.Split(','); + string mpdUrl = messageUrlParsed[0]; + string policy = messageUrlParsed[1]; + string signature = messageUrlParsed[2]; + string kvp = messageUrlParsed[3]; + + mpdUrl = mpdUrl.Replace(".mpd", "_source.mp4"); + + using HttpClient client = new(); + client.DefaultRequestHeaders.Add("Cookie", + $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {authService.CurrentAuth.Cookie}"); + client.DefaultRequestHeaders.Add("User-Agent", authService.CurrentAuth.UserAgent); + + using HttpResponseMessage response = + await client.GetAsync(mpdUrl, HttpCompletionOption.ResponseHeadersRead); + if (response.IsSuccessStatusCode) + { + fileSize = response.Content.Headers.ContentLength ?? 0; + } + } + else + { + using HttpClient client = new(); + client.DefaultRequestHeaders.Add("User-Agent", authService.CurrentAuth.UserAgent); + using HttpResponseMessage response = + await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + if (response.IsSuccessStatusCode) + { + fileSize = response.Content.Headers.ContentLength ?? 0; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error getting file size for URL '{url}': {ex.Message}"); + } + + return fileSize; + } + + /// + /// Retrieves the last modified timestamp for a DRM media URL. + /// + /// The DRM media URL (including CloudFront tokens). + /// The current auth context. + /// The last modified timestamp if available. + public static async Task GetDrmVideoLastModified(string url, Auth auth) + { + string[] messageUrlParsed = url.Split(','); + string mpdUrl = messageUrlParsed[0]; + string policy = messageUrlParsed[1]; + string signature = messageUrlParsed[2]; + string kvp = messageUrlParsed[3]; + + mpdUrl = mpdUrl.Replace(".mpd", "_source.mp4"); + + using HttpClient client = new(); + client.DefaultRequestHeaders.Add("Cookie", + $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {auth.Cookie}"); + client.DefaultRequestHeaders.Add("User-Agent", auth.UserAgent); + + using HttpResponseMessage response = await client.GetAsync(mpdUrl, HttpCompletionOption.ResponseHeadersRead); + return response is { IsSuccessStatusCode: true, Content.Headers.LastModified: not null } + ? response.Content.Headers.LastModified.Value.DateTime + : DateTime.Now; + } + + /// + /// Retrieves the last modified timestamp for a media URL. + /// + /// The media URL. + /// The last modified timestamp if available. + public static async Task GetMediaLastModified(string url) + { + using HttpClient client = new(); + + using HttpResponseMessage response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + if (!response.IsSuccessStatusCode) + { + return DateTime.Now; + } + + return response.Content.Headers.LastModified?.DateTime ?? DateTime.Now; + } + + /// + /// Processes the download and database update of media. + /// + /// The folder where the media is stored. + /// The ID of the media. + /// + /// The URL from where to download the media. + /// The relative path to the media. + /// + /// The filename after any required manipulations. + /// The file extension. + /// + /// A Task resulting in a boolean indicating whether the media is newly downloaded or not. + public async Task ProcessMediaDownload(string folder, + long mediaId, + string apiType, + string url, + string path, + string serverFilename, + string resolvedFilename, + string extension, + IProgressReporter progressReporter) + { + try + { + if (!await dbService.CheckDownloaded(folder, mediaId, apiType)) + { + return await HandleNewMedia(folder, + mediaId, + apiType, + url, + path, + serverFilename, + resolvedFilename, + extension, + progressReporter); + } + + bool status = await HandlePreviouslyDownloadedMediaAsync(folder, mediaId, apiType, progressReporter); + if (configService.CurrentConfig.RenameExistingFilesWhenCustomFormatIsSelected && + serverFilename != resolvedFilename) + { + await HandleRenamingOfExistingFilesAsync(folder, mediaId, apiType, path, serverFilename, + resolvedFilename, extension); + } + + return status; + } + catch (Exception ex) + { + // Handle exception (e.g., log it) + Console.WriteLine($"An error occurred: {ex.Message}"); + return false; + } + } + + + private async Task HandleRenamingOfExistingFilesAsync(string folder, + long mediaId, + string apiType, + string path, + string serverFilename, + string resolvedFilename, + string extension) + { + string fullPathWithTheServerFileName = $"{folder}{path}/{serverFilename}{extension}"; + string fullPathWithTheNewFileName = $"{folder}{path}/{resolvedFilename}{extension}"; + if (!File.Exists(fullPathWithTheServerFileName)) + { + return; + } + + try + { + File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); + } + catch (Exception ex) + { + Console.WriteLine($"An error occurred: {ex.Message}"); + return; + } + + long size = await dbService.GetStoredFileSize(folder, mediaId, apiType); + DateTime lastModified = File.GetLastWriteTime(fullPathWithTheNewFileName); + await dbService.UpdateMedia(folder, mediaId, apiType, folder + path, resolvedFilename + extension, size, true, + lastModified); + } + + + /// + /// Handles new media by downloading and updating the database. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// A Task resulting in a boolean indicating whether the media is newly downloaded or not. + private async Task HandleNewMedia(string folder, + long mediaId, + string apiType, + string url, + string path, + string serverFilename, + string resolvedFilename, + string extension, + IProgressReporter progressReporter) + { + long fileSizeInBytes; + DateTime lastModified; + bool status; + + string fullPathWithTheServerFileName = $"{folder}{path}/{serverFilename}{extension}"; + string fullPathWithTheNewFileName = $"{folder}{path}/{resolvedFilename}{extension}"; + + //there are a few possibilities here. + //1.file has been downloaded in the past but it has the server filename + // in that case it should be set as existing and it should be renamed + //2.file has been downloaded in the past but it has custom filename. + // it should be set as existing and nothing else. + // of coures 1 and 2 depends in the fact that there may be a difference in the resolved file name + // (ie user has selected a custom format. If he doesn't then the resolved name will be the same as the server filename + //3.file doesn't exist and it should be downloaded. + + // Handle the case where the file has been downloaded in the past with the server filename + //but it has downloaded outsite of this application so it doesn't exist in the database + if (File.Exists(fullPathWithTheServerFileName)) + { + string finalPath; + if (fullPathWithTheServerFileName != fullPathWithTheNewFileName) + { + finalPath = fullPathWithTheNewFileName; + //rename. + try + { + File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); + } + catch (Exception ex) + { + Console.WriteLine($"An error occurred: {ex.Message}"); + } + } + else + { + finalPath = fullPathWithTheServerFileName; + } + + fileSizeInBytes = GetLocalFileSize(finalPath); + lastModified = File.GetLastWriteTime(finalPath); + ReportProgress(progressReporter, fileSizeInBytes); + + status = false; + } + // Handle the case where the file has been downloaded in the past with a custom filename. + // but it has downloaded outside of this application so it doesn't exist in the database + // this is a bit improbable but we should check for that. + else if (File.Exists(fullPathWithTheNewFileName)) + { + fileSizeInBytes = GetLocalFileSize(fullPathWithTheNewFileName); + lastModified = File.GetLastWriteTime(fullPathWithTheNewFileName); + ReportProgress(progressReporter, fileSizeInBytes); + + status = false; + } + else //file doesn't exist and we should download it. + { + lastModified = await DownloadFile(url, fullPathWithTheNewFileName, progressReporter); + fileSizeInBytes = GetLocalFileSize(fullPathWithTheNewFileName); + status = true; + } + + //finaly check which filename we should use. Custom or the server one. + //if a custom is used, then the serverFilename will be different from the resolved filename. + string finalName = serverFilename == resolvedFilename ? serverFilename : resolvedFilename; + await dbService.UpdateMedia(folder, mediaId, apiType, folder + path, finalName + extension, fileSizeInBytes, + true, lastModified); + return status; + } + + + /// + /// Handles media that has been previously downloaded and updates the task accordingly. + /// + /// + /// + /// + /// + /// A boolean indicating whether the media is newly downloaded or not. + private async Task HandlePreviouslyDownloadedMediaAsync(string folder, long mediaId, string apiType, + IProgressReporter progressReporter) + { + long size = configService.CurrentConfig.ShowScrapeSize + ? await dbService.GetStoredFileSize(folder, mediaId, apiType) + : 1; + ReportProgress(progressReporter, size); + + return false; + } + + + /// + /// Gets the file size of the media. + /// + /// The path to the file. + /// The file size in bytes. + private static long GetLocalFileSize(string filePath) => new FileInfo(filePath).Length; + + + /// + /// Downloads a file from the given URL and saves it to the specified destination path. + /// + /// The URL to download the file from. + /// The path where the downloaded file will be saved. + /// + /// A Task resulting in a DateTime indicating the last modified date of the downloaded file. + private async Task DownloadFile(string url, string destinationPath, IProgressReporter progressReporter) + { + using HttpClient client = new(); + HttpRequestMessage request = new() { Method = HttpMethod.Get, RequestUri = new Uri(url) }; + + using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + Stream body = await response.Content.ReadAsStreamAsync(); + + // Wrap the body stream with the ThrottledStream to limit read rate. + await using (ThrottledStream throttledStream = new(body, + configService.CurrentConfig.DownloadLimitInMbPerSec * 1_000_000, + configService.CurrentConfig.LimitDownloadRate)) + { + await using FileStream fileStream = new(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, + 16384, + true); + byte[] buffer = new byte[16384]; + int read; + while ((read = await throttledStream.ReadAsync(buffer, CancellationToken.None)) > 0) + { + if (configService.CurrentConfig.ShowScrapeSize) + { + progressReporter.ReportProgress(read); + } + + await fileStream.WriteAsync(buffer.AsMemory(0, read), CancellationToken.None); + } + } + + File.SetLastWriteTime(destinationPath, response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now); + if (!configService.CurrentConfig.ShowScrapeSize) + { + progressReporter.ReportProgress(1); + } + + return response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now; + } + + /// + /// Calculates the total size of a set of URLs by fetching their metadata. + /// + /// The media URLs. + /// The total size in bytes. + public async Task CalculateTotalFileSize(List urls) + { + long totalFileSize = 0; + if (urls.Count > 250) + { + const int batchSize = 250; + + List> tasks = []; + + for (int i = 0; i < urls.Count; i += batchSize) + { + List batchUrls = urls.Skip(i).Take(batchSize).ToList(); + + Task[] batchTasks = batchUrls.Select(GetFileSizeAsync).ToArray(); + tasks.AddRange(batchTasks); + await Task.WhenAll(batchTasks); + + await Task.Delay(5000); + } + + long[] fileSizes = await Task.WhenAll(tasks); + totalFileSize += fileSizes.Sum(); + } + else + { + List> tasks = []; + tasks.AddRange(urls.Select(GetFileSizeAsync)); + + long[] fileSizes = await Task.WhenAll(tasks); + totalFileSize += fileSizes.Sum(); + } + + return totalFileSize; + } + + /// + /// Downloads a single media item, applying filename formatting and folder rules. + /// + /// The media URL. + /// The creator folder path. + /// The media ID. + /// The API type label. + /// Progress reporter. + /// The relative folder path. + /// Optional filename format. + /// Post or message info. + /// Media info. + /// Author info. + /// Known users map. + /// True when the media is newly downloaded. + private async Task DownloadMedia(string url, string folder, long mediaId, string apiType, + IProgressReporter progressReporter, string path, + string? filenameFormat, object? postInfo, object? postMedia, + object? author, Dictionary users) + { + Uri uri = new(url); + string filename = Path.GetFileNameWithoutExtension(uri.LocalPath); + string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, postInfo, postMedia, author, + folder.Split("/")[^1], users, fileNameService, CustomFileNameOption.ReturnOriginal); + + return await CreateDirectoriesAndDownloadMedia(path, url, folder, mediaId, apiType, progressReporter, + filename, resolvedFilename); + } + + /// + /// Downloads a DRM-protected video using the provided decryption key. + /// + /// CloudFront policy token. + /// CloudFront signature token. + /// CloudFront key pair ID. + /// The MPD URL. + /// The decryption key. + /// The creator folder path. + /// The source last modified timestamp. + /// The media ID. + /// The API type label. + /// Progress reporter. + /// The relative folder path. + /// Optional filename format. + /// Post or message info. + /// Media info. + /// Author info. + /// Known users map. + /// True when the media is newly downloaded. + private async Task DownloadDrmVideo(string policy, string signature, string kvp, string url, + string decryptionKey, string folder, DateTime lastModified, long mediaId, string apiType, + IProgressReporter progressReporter, string path, + string? filenameFormat, object? postInfo, object? postMedia, + object? author, Dictionary users) + { + try + { + if (authService.CurrentAuth == null) + { + throw new Exception("No authentication information available."); + } + + if (authService.CurrentAuth.Cookie == null) + { + throw new Exception("No authentication cookie available."); + } + + if (authService.CurrentAuth.UserAgent == null) + { + throw new Exception("No user agent available."); + } + + Uri uri = new(url); + string filename = Path.GetFileName(uri.LocalPath).Split(".")[0]; + + if (!Directory.Exists(folder + path)) + { + Directory.CreateDirectory(folder + path); + } + + string customFileName = await GenerateCustomFileName(filename, filenameFormat, postInfo, postMedia, author, + folder.Split("/")[^1], users, fileNameService, CustomFileNameOption.ReturnEmpty); + + if (!await dbService.CheckDownloaded(folder, mediaId, apiType)) + { + if (!string.IsNullOrEmpty(customFileName) + ? !File.Exists(folder + path + "/" + customFileName + ".mp4") + : !File.Exists(folder + path + "/" + filename + "_source.mp4")) + { + return await DownloadDrmMedia(authService.CurrentAuth.UserAgent, policy, signature, kvp, + authService.CurrentAuth.Cookie, url, decryptionKey, folder, lastModified, mediaId, apiType, + progressReporter, customFileName, filename, path); + } + + long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) + ? folder + path + "/" + customFileName + ".mp4" + : folder + path + "/" + filename + "_source.mp4").Length; + ReportProgress(progressReporter, fileSizeInBytes); + + await dbService.UpdateMedia(folder, mediaId, apiType, folder + path, + !string.IsNullOrEmpty(customFileName) ? customFileName + ".mp4" : filename + "_source.mp4", + fileSizeInBytes, true, lastModified); + } + else + { + if (!string.IsNullOrEmpty(customFileName)) + { + if (configService.CurrentConfig.RenameExistingFilesWhenCustomFormatIsSelected && + filename + "_source" != customFileName) + { + string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; + string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; + if (!File.Exists(fullPathWithTheServerFileName)) + { + return false; + } + + try + { + File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); + } + catch (Exception ex) + { + Console.WriteLine($"An error occurred: {ex.Message}"); + return false; + } + + long storedFileSize = await dbService.GetStoredFileSize(folder, mediaId, apiType); + await dbService.UpdateMedia(folder, mediaId, apiType, folder + path, customFileName + ".mp4", + storedFileSize, true, lastModified); + } + } + + long progressSize = configService.CurrentConfig.ShowScrapeSize + ? await dbService.GetStoredFileSize(folder, mediaId, apiType) + : 1; + ReportProgress(progressReporter, progressSize); + } + + return false; + } + catch (Exception ex) + { + ExceptionLoggerHelper.LogException(ex); + } + + return false; + } + + private void ReportProgress(IProgressReporter reporter, long sizeOrCount) => + reporter.ReportProgress(configService.CurrentConfig.ShowScrapeSize ? sizeOrCount : 1); + + /// + /// Retrieves decryption information for a DRM media item. + /// + /// The MPD URL. + /// CloudFront policy token. + /// CloudFront signature token. + /// CloudFront key pair ID. + /// The media ID. + /// The content ID. + /// The DRM type. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The decryption key and last modified timestamp. + public async Task<(string decryptionKey, DateTime lastModified)?> GetDecryptionInfo( + string mpdUrl, string policy, string signature, string kvp, + string mediaId, string contentId, string drmType, + bool clientIdBlobMissing, bool devicePrivateKeyMissing) + { + string pssh = await apiService.GetDrmMpdPssh(mpdUrl, policy, signature, kvp); + + DateTime lastModified = await apiService.GetDrmMpdLastModified(mpdUrl, policy, signature, kvp); + Dictionary drmHeaders = + apiService.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/{drmType}/{contentId}", + "?type=widevine"); + string licenseUrl = + $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/{drmType}/{contentId}?type=widevine"; + + string decryptionKey = clientIdBlobMissing || devicePrivateKeyMissing + ? await apiService.GetDecryptionKeyOfdl(drmHeaders, licenseUrl, pssh) + : await apiService.GetDecryptionKeyCdm(drmHeaders, licenseUrl, pssh); + + return (decryptionKey, lastModified); + } + + /// + /// Downloads highlight media for a creator. + /// + /// The creator username. + /// The creator user ID. + /// The creator folder path. + /// Paid post media IDs. + /// Progress reporter. + /// The download result. + public async Task DownloadHighlights(string username, long userId, string path, + HashSet paidPostIds, IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadHighlights - {username}"); + + Dictionary? highlights = await apiService.GetMedia(MediaType.Highlights, + $"/users/{userId}/stories/highlights", null, path); + + if (highlights == null || highlights.Count == 0) + { + Log.Debug("Found 0 Highlights"); + return new DownloadResult + { + TotalCount = 0, + NewDownloads = 0, + ExistingDownloads = 0, + MediaType = "Highlights", + Success = true + }; + } + + Log.Debug($"Found {highlights.Count} Highlights"); + + int oldHighlightsCount = 0; + int newHighlightsCount = 0; + + foreach (KeyValuePair highlightKvp in highlights) + { + bool isNew = + await DownloadMedia(highlightKvp.Value, path, highlightKvp.Key, "Stories", progressReporter, + "/Stories/Free", null, null, null, null, new Dictionary()); + if (isNew) + { + newHighlightsCount++; + } + else + { + oldHighlightsCount++; + } + } + + Log.Debug( + $"Highlights Already Downloaded: {oldHighlightsCount} New Highlights Downloaded: {newHighlightsCount}"); + + return new DownloadResult + { + TotalCount = highlights.Count, + NewDownloads = newHighlightsCount, + ExistingDownloads = oldHighlightsCount, + MediaType = "Highlights", + Success = true + }; + } + + /// + /// Downloads story media for a creator. + /// + /// The creator username. + /// The creator user ID. + /// The creator folder path. + /// Paid post media IDs. + /// Progress reporter. + /// The download result. + public async Task DownloadStories(string username, long userId, string path, + HashSet paidPostIds, IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadStories - {username}"); + + Dictionary? stories = await apiService.GetMedia(MediaType.Stories, $"/users/{userId}/stories", + null, path); + + if (stories == null || stories.Count == 0) + { + Log.Debug("Found 0 Stories"); + return new DownloadResult + { + TotalCount = 0, + NewDownloads = 0, + ExistingDownloads = 0, + MediaType = "Stories", + Success = true + }; + } + + Log.Debug($"Found {stories.Count} Stories"); + + int oldStoriesCount = 0; + int newStoriesCount = 0; + + foreach (KeyValuePair storyKvp in stories) + { + bool isNew = await DownloadMedia(storyKvp.Value, path, storyKvp.Key, "Stories", progressReporter, + "/Stories/Free", null, null, null, null, new Dictionary()); + if (isNew) + { + newStoriesCount++; + } + else + { + oldStoriesCount++; + } + } + + Log.Debug($"Stories Already Downloaded: {oldStoriesCount} New Stories Downloaded: {newStoriesCount}"); + + return new DownloadResult + { + TotalCount = stories.Count, + NewDownloads = newStoriesCount, + ExistingDownloads = oldStoriesCount, + MediaType = "Stories", + Success = true + }; + } + + /// + /// Downloads archived posts for a creator. + /// + /// The creator username. + /// The creator user ID. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The archived posts collection. + /// Progress reporter. + /// The download result. + public async Task DownloadArchived(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + ArchivedEntities.ArchivedCollection archived, IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadArchived - {username}"); + + if (archived.ArchivedPosts.Count == 0) + { + Log.Debug("Found 0 Archived Posts"); + return new DownloadResult + { + TotalCount = 0, + NewDownloads = 0, + ExistingDownloads = 0, + MediaType = "Archived Posts", + Success = true + }; + } + + Log.Debug( + $"Found {archived.ArchivedPosts.Count} Media from {archived.ArchivedPostObjects.Count} Archived Posts"); + + int oldArchivedCount = 0; + int newArchivedCount = 0; + + foreach (KeyValuePair archivedKvp in archived.ArchivedPosts) + { + bool isNew; + ArchivedEntities.Medium? mediaInfo = + archived.ArchivedPostMedia.FirstOrDefault(m => m.Id == archivedKvp.Key); + ArchivedEntities.ListItem? postInfo = mediaInfo == null + ? null + : archived.ArchivedPostObjects.FirstOrDefault(p => p.Media?.Contains(mediaInfo) == true); + string filenameFormat = + configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).PostFileNameFormat ?? ""; + + if (archivedKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = archivedKvp.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, archivedKvp.Key, "Posts", + progressReporter, "/Archived/Posts/Free/Videos", filenameFormat, + postInfo, mediaInfo, postInfo?.Author, users); + } + else + { + isNew = await DownloadMedia(archivedKvp.Value, path, archivedKvp.Key, "Posts", progressReporter, + "/Archived/Posts/Free", filenameFormat, postInfo, mediaInfo, postInfo?.Author, users); + } + + if (isNew) + { + newArchivedCount++; + } + else + { + oldArchivedCount++; + } + } + + Log.Debug( + $"Archived Posts Already Downloaded: {oldArchivedCount} New Archived Posts Downloaded: {newArchivedCount}"); + + return new DownloadResult + { + TotalCount = archived.ArchivedPosts.Count, + NewDownloads = newArchivedCount, + ExistingDownloads = oldArchivedCount, + MediaType = "Archived Posts", + Success = true + }; + } + + /// + /// Downloads free messages for a creator. + /// + /// The creator username. + /// The creator user ID. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The messages collection. + /// Progress reporter. + /// The download result. + public async Task DownloadMessages(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + MessageEntities.MessageCollection messages, IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadMessages - {username}"); + + if (messages.Messages.Count == 0) + { + Log.Debug("Found 0 Messages"); + return new DownloadResult + { + TotalCount = 0, + NewDownloads = 0, + ExistingDownloads = 0, + MediaType = "Messages", + Success = true + }; + } + + Log.Debug($"Found {messages.Messages.Count} Media from {messages.MessageObjects.Count} Messages"); + + int oldMessagesCount = 0; + int newMessagesCount = 0; + + foreach (KeyValuePair messageKvp in messages.Messages) + { + bool isNew; + MessageEntities.Medium? mediaInfo = messages.MessageMedia.FirstOrDefault(m => m.Id == messageKvp.Key); + MessageEntities.ListItem? messageInfo = messages.MessageObjects.FirstOrDefault(p => + p.Media?.Any(m => m.Id == messageKvp.Key) == true); + string filenameFormat = + configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).MessageFileNameFormat ?? ""; + string messagePath = configService.CurrentConfig.FolderPerMessage && messageInfo != null && + messageInfo.Id != 0 && messageInfo.CreatedAt is not null + ? $"/Messages/Free/{messageInfo.Id} {messageInfo.CreatedAt.Value:yyyy-MM-dd HH-mm-ss}" + : "/Messages/Free"; + + if (messageKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = messageKvp.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, messageKvp.Key, "Messages", + progressReporter, messagePath + "/Videos", filenameFormat, + messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + else + { + isNew = await DownloadMedia(messageKvp.Value, path, messageKvp.Key, "Messages", progressReporter, + messagePath, filenameFormat, messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + + if (isNew) + { + newMessagesCount++; + } + else + { + oldMessagesCount++; + } + } + + Log.Debug($"Messages Already Downloaded: {oldMessagesCount} New Messages Downloaded: {newMessagesCount}"); + + return new DownloadResult + { + TotalCount = messages.Messages.Count, + NewDownloads = newMessagesCount, + ExistingDownloads = oldMessagesCount, + MediaType = "Messages", + Success = true + }; + } + + + /// + /// Downloads paid messages for a creator. + /// + /// The creator username. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The paid message collection. + /// Progress reporter. + /// The download result. + public async Task DownloadPaidMessages(string username, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidMessageCollection paidMessageCollection, + IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadPaidMessages - {username}"); + + if (paidMessageCollection.PaidMessages.Count == 0) + { + Log.Debug("Found 0 Paid Messages"); + return new DownloadResult + { + TotalCount = 0, + NewDownloads = 0, + ExistingDownloads = 0, + MediaType = "Paid Messages", + Success = true + }; + } + + Log.Debug( + $"Found {paidMessageCollection.PaidMessages.Count} Media from {paidMessageCollection.PaidMessageObjects.Count} Paid Messages"); + + int oldCount = 0; + int newCount = 0; + + foreach (KeyValuePair kvpEntry in paidMessageCollection.PaidMessages) + { + bool isNew; + MessageEntities.Medium? mediaInfo = + paidMessageCollection.PaidMessageMedia.FirstOrDefault(m => m.Id == kvpEntry.Key); + PurchasedEntities.ListItem? messageInfo = paidMessageCollection.PaidMessageObjects.FirstOrDefault(p => + p.Media?.Any(m => m.Id == kvpEntry.Key) == true); + string filenameFormat = + configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).MessageFileNameFormat ?? ""; + string paidMsgPath = configService.CurrentConfig.FolderPerPaidMessage && messageInfo != null && + messageInfo.Id != 0 && messageInfo.CreatedAt is not null + ? $"/Messages/Paid/{messageInfo.Id} {messageInfo.CreatedAt.Value:yyyy-MM-dd HH-mm-ss}" + : "/Messages/Paid"; + + if (kvpEntry.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = kvpEntry.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, kvpEntry.Key, "Messages", + progressReporter, paidMsgPath + "/Videos", filenameFormat, + messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + else + { + isNew = await DownloadMedia(kvpEntry.Value, path, kvpEntry.Key, "Messages", progressReporter, + paidMsgPath, filenameFormat, messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + + if (isNew) + { + newCount++; + } + else + { + oldCount++; + } + } + + Log.Debug($"Paid Messages Already Downloaded: {oldCount} New Paid Messages Downloaded: {newCount}"); + return new DownloadResult + { + TotalCount = paidMessageCollection.PaidMessages.Count, + NewDownloads = newCount, + ExistingDownloads = oldCount, + MediaType = "Paid Messages", + Success = true + }; + } + + /// + /// Downloads stream posts for a creator. + /// + /// The creator username. + /// The creator user ID. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The streams collection. + /// Progress reporter. + /// The download result. + public async Task DownloadStreams(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + StreamEntities.StreamsCollection streams, IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadStreams - {username}"); + + if (streams.Streams.Count == 0) + { + Log.Debug("Found 0 Streams"); + return new DownloadResult + { + TotalCount = 0, + NewDownloads = 0, + ExistingDownloads = 0, + MediaType = "Streams", + Success = true + }; + } + + Log.Debug($"Found {streams.Streams.Count} Media from {streams.StreamObjects.Count} Streams"); + + int oldCount = 0; + int newCount = 0; + + foreach (KeyValuePair kvpEntry in streams.Streams) + { + bool isNew; + StreamEntities.Medium? mediaInfo = streams.StreamMedia.FirstOrDefault(m => m.Id == kvpEntry.Key); + StreamEntities.ListItem? streamInfo = mediaInfo == null + ? null + : streams.StreamObjects.FirstOrDefault(p => p.Media?.Contains(mediaInfo) == true); + string filenameFormat = + configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).PostFileNameFormat ?? ""; + string streamPath = configService.CurrentConfig.FolderPerPost && streamInfo != null && streamInfo.Id != 0 + ? $"/Posts/Free/{streamInfo.Id} {streamInfo.PostedAt:yyyy-MM-dd HH-mm-ss}" + : "/Posts/Free"; + + if (kvpEntry.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = kvpEntry.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, kvpEntry.Key, "Streams", + progressReporter, streamPath + "/Videos", filenameFormat, + streamInfo, mediaInfo, streamInfo?.Author, users); + } + else + { + isNew = await DownloadMedia(kvpEntry.Value, path, kvpEntry.Key, "Streams", progressReporter, + streamPath, filenameFormat, streamInfo, mediaInfo, streamInfo?.Author, users); + } + + if (isNew) + { + newCount++; + } + else + { + oldCount++; + } + } + + Log.Debug($"Streams Already Downloaded: {oldCount} New Streams Downloaded: {newCount}"); + return new DownloadResult + { + TotalCount = streams.Streams.Count, + NewDownloads = newCount, + ExistingDownloads = oldCount, + MediaType = "Streams", + Success = true + }; + } + + /// + /// Downloads free posts for a creator. + /// + /// The creator username. + /// The creator user ID. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The posts collection. + /// Progress reporter. + /// The download result. + public async Task DownloadFreePosts(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PostEntities.PostCollection posts, + IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadFreePosts - {username}"); + + if (posts.Posts.Count == 0) + { + Log.Debug("Found 0 Posts"); + return new DownloadResult + { + TotalCount = 0, + NewDownloads = 0, + ExistingDownloads = 0, + MediaType = "Posts", + Success = true + }; + } + + Log.Debug($"Found {posts.Posts.Count} Media from {posts.PostObjects.Count} Posts"); + + int oldCount = 0, newCount = 0; + + foreach (KeyValuePair postKvp in posts.Posts) + { + bool isNew; + PostEntities.Medium? mediaInfo = posts.PostMedia.FirstOrDefault(m => m.Id == postKvp.Key); + PostEntities.ListItem? postInfo = mediaInfo == null + ? null + : posts.PostObjects.FirstOrDefault(p => p.Media?.Contains(mediaInfo) == true); + string filenameFormat = + configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).PostFileNameFormat ?? ""; + string postPath = configService.CurrentConfig.FolderPerPost && postInfo != null && postInfo.Id != 0 + ? $"/Posts/Free/{postInfo.Id} {postInfo.PostedAt:yyyy-MM-dd HH-mm-ss}" + : "/Posts/Free"; + + if (postKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = postKvp.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts", + progressReporter, postPath + "/Videos", filenameFormat, + postInfo, mediaInfo, postInfo?.Author, users); + } + else + { + isNew = await DownloadMedia(postKvp.Value, path, postKvp.Key, "Posts", progressReporter, + postPath, filenameFormat, postInfo, mediaInfo, postInfo?.Author, users); + } + + if (isNew) + { + newCount++; + } + else + { + oldCount++; + } + } + + Log.Debug($"Posts Already Downloaded: {oldCount} New Posts Downloaded: {newCount}"); + return new DownloadResult + { + TotalCount = posts.Posts.Count, + NewDownloads = newCount, + ExistingDownloads = oldCount, + MediaType = "Posts", + Success = true + }; + } + + /// + /// Downloads paid posts for a creator. + /// + /// The creator username. + /// The creator user ID. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The paid post collection. + /// Progress reporter. + /// The download result. + public async Task DownloadPaidPosts(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidPostCollection purchasedPosts, IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadPaidPosts - {username}"); + + if (purchasedPosts.PaidPosts.Count == 0) + { + Log.Debug("Found 0 Paid Posts"); + return new DownloadResult + { + TotalCount = 0, + NewDownloads = 0, + ExistingDownloads = 0, + MediaType = "Paid Posts", + Success = true + }; + } + + Log.Debug( + $"Found {purchasedPosts.PaidPosts.Count} Media from {purchasedPosts.PaidPostObjects.Count} Paid Posts"); + + int oldCount = 0, newCount = 0; + + foreach (KeyValuePair postKvp in purchasedPosts.PaidPosts) + { + bool isNew; + MessageEntities.Medium? mediaInfo = + purchasedPosts.PaidPostMedia.FirstOrDefault(m => m.Id == postKvp.Key); + PurchasedEntities.ListItem? postInfo = + purchasedPosts.PaidPostObjects.FirstOrDefault(p => p.Media?.Any(m => m.Id == postKvp.Key) == true); + string filenameFormat = + configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).PostFileNameFormat ?? ""; + string paidPostPath = configService.CurrentConfig.FolderPerPaidPost && postInfo != null && + postInfo.Id != 0 && postInfo.PostedAt is not null + ? $"/Posts/Paid/{postInfo.Id} {postInfo.PostedAt.Value:yyyy-MM-dd HH-mm-ss}" + : "/Posts/Paid"; + + if (postKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = postKvp.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts", + progressReporter, paidPostPath + "/Videos", filenameFormat, + postInfo, mediaInfo, postInfo?.FromUser, users); + } + else + { + isNew = await DownloadMedia(postKvp.Value, path, postKvp.Key, "Posts", progressReporter, + paidPostPath, filenameFormat, postInfo, mediaInfo, postInfo?.FromUser, users); + } + + if (isNew) + { + newCount++; + } + else + { + oldCount++; + } + } + + Log.Debug($"Paid Posts Already Downloaded: {oldCount} New Paid Posts Downloaded: {newCount}"); + return new DownloadResult + { + TotalCount = purchasedPosts.PaidPosts.Count, + NewDownloads = newCount, + ExistingDownloads = oldCount, + MediaType = "Paid Posts", + Success = true + }; + } + + /// + /// Downloads paid posts sourced from the Purchased tab. + /// + /// The creator username. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The paid post collection. + /// Progress reporter. + /// The download result. + public async Task DownloadPaidPostsPurchasedTab(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidPostCollection purchasedPosts, IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadPaidPostsPurchasedTab - {username}"); + + if (purchasedPosts.PaidPosts.Count == 0) + { + Log.Debug("Found 0 Paid Posts"); + return new DownloadResult { TotalCount = 0, MediaType = "Paid Posts", Success = true }; + } + + int oldCount = 0, newCount = 0; + + foreach (KeyValuePair purchasedPostKvp in purchasedPosts.PaidPosts) + { + bool isNew; + MessageEntities.Medium? mediaInfo = + purchasedPosts?.PaidPostMedia?.FirstOrDefault(m => m.Id == purchasedPostKvp.Key); + PurchasedEntities.ListItem? postInfo = mediaInfo != null + ? purchasedPosts?.PaidPostObjects?.FirstOrDefault(p => + p.Media?.Any(m => m.Id == purchasedPostKvp.Key) == true) + : null; + string filenameFormat = configService.CurrentConfig.GetCreatorFileNameFormatConfig(username) + .PaidPostFileNameFormat ?? ""; + string paidPostPath = configService.CurrentConfig.FolderPerPaidPost && postInfo != null && + postInfo.Id != 0 && postInfo.PostedAt is not null + ? $"/Posts/Paid/{postInfo.Id} {postInfo.PostedAt.Value:yyyy-MM-dd HH-mm-ss}" + : "/Posts/Paid"; + + if (purchasedPostKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = purchasedPostKvp.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, purchasedPostKvp.Key, + "Posts", progressReporter, paidPostPath + "/Videos", filenameFormat, + postInfo, mediaInfo, postInfo?.FromUser, users); + } + else + { + isNew = await DownloadMedia(purchasedPostKvp.Value, path, + purchasedPostKvp.Key, "Posts", progressReporter, + paidPostPath, filenameFormat, postInfo, mediaInfo, postInfo?.FromUser, users); + } + + if (isNew) + { + newCount++; + } + else + { + oldCount++; + } + } + + Log.Debug($"Paid Posts Already Downloaded: {oldCount} New Paid Posts Downloaded: {newCount}"); + return new DownloadResult + { + TotalCount = purchasedPosts?.PaidPosts.Count ?? 0, + NewDownloads = newCount, + ExistingDownloads = oldCount, + MediaType = "Paid Posts", + Success = true + }; + } + + /// + /// Downloads paid messages sourced from the Purchased tab. + /// + /// The creator username. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The paid message collection. + /// Progress reporter. + /// The download result. + public async Task DownloadPaidMessagesPurchasedTab(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidMessageCollection paidMessageCollection, IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadPaidMessagesPurchasedTab - {username}"); + + if (paidMessageCollection.PaidMessages.Count == 0) + { + Log.Debug("Found 0 Paid Messages"); + return new DownloadResult { TotalCount = 0, MediaType = "Paid Messages", Success = true }; + } + + int oldCount = 0, newCount = 0; + + foreach (KeyValuePair paidMessageKvp in paidMessageCollection.PaidMessages) + { + bool isNew; + MessageEntities.Medium? mediaInfo = + paidMessageCollection.PaidMessageMedia.FirstOrDefault(m => m.Id == paidMessageKvp.Key); + PurchasedEntities.ListItem? messageInfo = + paidMessageCollection.PaidMessageObjects.FirstOrDefault(p => + p.Media?.Any(m => m.Id == paidMessageKvp.Key) == true); + string filenameFormat = configService.CurrentConfig.GetCreatorFileNameFormatConfig(username) + .PaidMessageFileNameFormat ?? ""; + string paidMsgPath = configService.CurrentConfig.FolderPerPaidMessage && messageInfo != null && + messageInfo.Id != 0 && messageInfo.CreatedAt is not null + ? $"/Messages/Paid/{messageInfo.Id} {messageInfo.CreatedAt.Value:yyyy-MM-dd HH-mm-ss}" + : "/Messages/Paid"; + + if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = paidMessageKvp.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], + parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key, + "Messages", progressReporter, paidMsgPath + "/Videos", filenameFormat, + messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + else + { + isNew = await DownloadMedia(paidMessageKvp.Value, path, + paidMessageKvp.Key, "Messages", progressReporter, + paidMsgPath, filenameFormat, messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + + if (isNew) + { + newCount++; + } + else + { + oldCount++; + } + } + + Log.Debug($"Paid Messages Already Downloaded: {oldCount} New Paid Messages Downloaded: {newCount}"); + return new DownloadResult + { + TotalCount = paidMessageCollection.PaidMessages.Count, + NewDownloads = newCount, + ExistingDownloads = oldCount, + MediaType = "Paid Messages", + Success = true + }; + } + + /// + /// Downloads a single post collection. + /// + /// The creator username. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The single post collection. + /// Progress reporter. + /// The download result. + public async Task DownloadSinglePost(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PostEntities.SinglePostCollection post, IProgressReporter progressReporter) + { + Log.Debug($"Calling DownloadSinglePost - {username}"); + + if (post.SinglePosts.Count == 0) + { + Log.Debug("Couldn't find post"); + return new DownloadResult { TotalCount = 0, MediaType = "Posts", Success = true }; + } + + int oldCount = 0, newCount = 0; + + foreach (KeyValuePair postKvp in post.SinglePosts) + { + PostEntities.Medium? mediaInfo = post.SinglePostMedia.FirstOrDefault(m => m.Id == postKvp.Key); + PostEntities.SinglePost? postInfo = mediaInfo == null + ? null + : post.SinglePostObjects.FirstOrDefault(p => p.Media?.Contains(mediaInfo) == true); + string filenameFormat = configService.CurrentConfig.GetCreatorFileNameFormatConfig(username) + .PostFileNameFormat ?? ""; + string postPath = configService.CurrentConfig.FolderPerPost && postInfo != null && postInfo.Id != 0 + ? $"/Posts/Free/{postInfo.Id} {postInfo.PostedAt:yyyy-MM-dd HH-mm-ss}" + : "/Posts/Free"; + + bool isNew; + if (postKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = postKvp.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts", + progressReporter, postPath + "/Videos", filenameFormat, + postInfo, mediaInfo, postInfo?.Author, users); + } + else + { + try + { + isNew = await DownloadMedia(postKvp.Value, path, + postKvp.Key, "Posts", progressReporter, + postPath, filenameFormat, postInfo, mediaInfo, postInfo?.Author, users); + } + catch + { + Log.Warning("Media was null"); + continue; + } + } + + if (isNew) + { + newCount++; + } + else + { + oldCount++; + } + } + + return new DownloadResult + { + TotalCount = post.SinglePosts.Count, + NewDownloads = newCount, + ExistingDownloads = oldCount, + MediaType = "Posts", + Success = true + }; + } + + /// + /// Downloads a single paid message collection (including previews). + /// + /// The creator username. + /// The creator folder path. + /// Known users map. + /// Whether the CDM client ID blob is missing. + /// Whether the CDM private key is missing. + /// The single paid message collection. + /// Progress reporter. + /// The download result. + public async Task DownloadSinglePaidMessage(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.SinglePaidMessageCollection singlePaidMessageCollection, + IProgressReporter progressReporter) + { + Log.Debug("Calling DownloadSinglePaidMessage - {Username}", username); + + int totalNew = 0, totalOld = 0; + + // Download preview messages + if (singlePaidMessageCollection.PreviewSingleMessages.Count > 0) + { + foreach (KeyValuePair paidMessageKvp in singlePaidMessageCollection.PreviewSingleMessages) + { + MessageEntities.Medium? mediaInfo = + singlePaidMessageCollection.PreviewSingleMessageMedia.FirstOrDefault(m => + m.Id == paidMessageKvp.Key); + MessageEntities.SingleMessage? messageInfo = + singlePaidMessageCollection.SingleMessageObjects.FirstOrDefault(p => + p.Media?.Any(m => m.Id == paidMessageKvp.Key) == true); + string filenameFormat = configService.CurrentConfig.GetCreatorFileNameFormatConfig(username) + .PaidMessageFileNameFormat ?? ""; + string previewMsgPath = configService.CurrentConfig.FolderPerMessage && messageInfo != null && + messageInfo.Id != 0 && messageInfo.CreatedAt is not null + ? $"/Messages/Free/{messageInfo.Id} {messageInfo.CreatedAt.Value:yyyy-MM-dd HH-mm-ss}" + : "/Messages/Free"; + + bool isNew; + if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = paidMessageKvp.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], + parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key, + "Messages", progressReporter, previewMsgPath + "/Videos", filenameFormat, + messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + else + { + isNew = await DownloadMedia(paidMessageKvp.Value, path, + paidMessageKvp.Key, "Messages", progressReporter, + previewMsgPath, filenameFormat, messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + + if (isNew) + { + totalNew++; + } + else + { + totalOld++; + } + } + } + + // Download actual paid messages + if (singlePaidMessageCollection.SingleMessages.Count > 0) + { + foreach (KeyValuePair paidMessageKvp in singlePaidMessageCollection.SingleMessages) + { + MessageEntities.Medium? mediaInfo = + singlePaidMessageCollection.SingleMessageMedia.FirstOrDefault(m => + m.Id == paidMessageKvp.Key); + MessageEntities.SingleMessage? messageInfo = + singlePaidMessageCollection.SingleMessageObjects.FirstOrDefault(p => + p.Media?.Any(m => m.Id == paidMessageKvp.Key) == true); + string filenameFormat = configService.CurrentConfig.GetCreatorFileNameFormatConfig(username) + .PaidMessageFileNameFormat ?? ""; + string singlePaidMsgPath = configService.CurrentConfig.FolderPerPaidMessage && + messageInfo != null && messageInfo.Id != 0 && + messageInfo.CreatedAt is not null + ? $"/Messages/Paid/{messageInfo.Id} {messageInfo.CreatedAt.Value:yyyy-MM-dd HH-mm-ss}" + : "/Messages/Paid"; + + bool isNew; + if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) + { + string[] parsed = paidMessageKvp.Value.Split(','); + (string decryptionKey, DateTime lastModified)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], + parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); + if (drmInfo == null) + { + continue; + } + + isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], + drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key, + "Messages", progressReporter, singlePaidMsgPath + "/Videos", filenameFormat, + messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + else + { + isNew = await DownloadMedia(paidMessageKvp.Value, path, + paidMessageKvp.Key, "Messages", progressReporter, + singlePaidMsgPath, filenameFormat, messageInfo, mediaInfo, messageInfo?.FromUser, users); + } + + if (isNew) + { + totalNew++; + } + else + { + totalOld++; + } + } + } + + int totalCount = singlePaidMessageCollection.PreviewSingleMessages.Count + + singlePaidMessageCollection.SingleMessages.Count; + Log.Debug($"Paid Messages Already Downloaded: {totalOld} New Paid Messages Downloaded: {totalNew}"); + return new DownloadResult + { + TotalCount = totalCount, + NewDownloads = totalNew, + ExistingDownloads = totalOld, + MediaType = "Paid Messages", + Success = true + }; + } +} diff --git a/OF DL.Core/Services/FileNameService.cs b/OF DL.Core/Services/FileNameService.cs new file mode 100644 index 0000000..92414e8 --- /dev/null +++ b/OF DL.Core/Services/FileNameService.cs @@ -0,0 +1,214 @@ +using System.Reflection; +using HtmlAgilityPack; + +namespace OF_DL.Services; + +public class FileNameService(IAuthService authService) : IFileNameService +{ + /// + /// Builds a map of filename token values from post, media, and author data. + /// + /// The post or message object. + /// The media object. + /// The author object. + /// The tokens requested by the filename format. + /// The resolved username when available. + /// Optional lookup of user IDs to usernames. + /// A dictionary of token values keyed by token name. + public async Task> GetFilename(object info, object media, object author, + List selectedProperties, string username, Dictionary? users = null) + { + Dictionary values = new(); + Type type1 = info.GetType(); + Type type2 = media.GetType(); + PropertyInfo[] properties1 = type1.GetProperties(); + PropertyInfo[] properties2 = type2.GetProperties(); + + foreach (string propertyName in selectedProperties) + { + if (propertyName.Contains("media")) + { + object? drmProperty = null; + object? fileProperty = GetNestedPropertyValue(media, "Files"); + if (fileProperty != null) + { + drmProperty = GetNestedPropertyValue(media, "Files.Drm"); + } + + if (fileProperty != null && drmProperty != null && propertyName == "mediaCreatedAt") + { + string? mpdUrl = GetNestedPropertyValue(media, "Files.Drm.Manifest.Dash") as string; + string? policy = + GetNestedPropertyValue(media, "Files.Drm.Signature.Dash.CloudFrontPolicy") as string; + string? signature = + GetNestedPropertyValue(media, "Files.Drm.Signature.Dash.CloudFrontSignature") as string; + string? kvp = + GetNestedPropertyValue(media, "Files.Drm.Signature.Dash.CloudFrontKeyPairId") as string; + + if (string.IsNullOrEmpty(mpdUrl) || string.IsNullOrEmpty(policy) || + string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(kvp) || + authService.CurrentAuth == null) + { + continue; + } + + DateTime lastModified = + await DownloadService.GetDrmVideoLastModified(string.Join(",", mpdUrl, policy, signature, kvp), + authService.CurrentAuth); + values.Add(propertyName, lastModified.ToString("yyyy-MM-dd")); + continue; + } + + if ((fileProperty == null || drmProperty == null) && propertyName == "mediaCreatedAt") + { + object? source = GetNestedPropertyValue(media, "Files.Full.Url"); + if (source != null) + { + DateTime lastModified = await DownloadService.GetMediaLastModified(source.ToString() ?? ""); + values.Add(propertyName, lastModified.ToString("yyyy-MM-dd")); + continue; + } + + object? preview = GetNestedPropertyValue(media, "Preview"); + if (preview != null) + { + DateTime lastModified = await DownloadService.GetMediaLastModified(preview.ToString() ?? ""); + values.Add(propertyName, lastModified.ToString("yyyy-MM-dd")); + continue; + } + } + + PropertyInfo? property = Array.Find(properties2, + p => p.Name.Equals(propertyName.Replace("media", ""), StringComparison.OrdinalIgnoreCase)); + if (property != null) + { + object? propertyValue = property.GetValue(media); + if (propertyValue != null) + { + if (propertyValue is DateTime dateTimeValue) + { + values.Add(propertyName, dateTimeValue.ToString("yyyy-MM-dd")); + } + else + { + values.Add(propertyName, propertyValue.ToString() ?? ""); + } + } + } + } + else if (propertyName.Contains("filename")) + { + object? sourcePropertyValue = GetNestedPropertyValue(media, "Files.Full.Url"); + if (sourcePropertyValue != null) + { + Uri uri = new(sourcePropertyValue.ToString() ?? ""); + string filename = Path.GetFileName(uri.LocalPath); + values.Add(propertyName, filename.Split(".")[0]); + } + else + { + object? nestedPropertyValue = GetNestedPropertyValue(media, "Files.Drm.Manifest.Dash"); + if (nestedPropertyValue != null) + { + Uri uri = new(nestedPropertyValue.ToString() ?? ""); + string filename = Path.GetFileName(uri.LocalPath); + values.Add(propertyName, filename.Split(".")[0] + "_source"); + } + } + } + else if (propertyName.Contains("username")) + { + if (!string.IsNullOrEmpty(username)) + { + values.Add(propertyName, username); + } + else + { + object? nestedPropertyValue = GetNestedPropertyValue(author, "Id"); + if (nestedPropertyValue != null && users != null) + { + values.Add(propertyName, + users.FirstOrDefault(u => u.Value == Convert.ToInt32(nestedPropertyValue.ToString())).Key); + } + } + } + else if (propertyName.Contains("text", StringComparison.OrdinalIgnoreCase)) + { + PropertyInfo? property = Array.Find(properties1, + p => p.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase)); + if (property != null) + { + object? propertyValue = property.GetValue(info); + if (propertyValue != null) + { + HtmlDocument pageDoc = new(); + pageDoc.LoadHtml(propertyValue.ToString() ?? ""); + string str = pageDoc.DocumentNode.InnerText; + if (str.Length > 100) // TODO: add length limit to config + { + str = str[..100]; + } + + values.Add(propertyName, str); + } + } + } + else + { + PropertyInfo? property = Array.Find(properties1, + p => p.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase)); + if (property != null) + { + object? propertyValue = property.GetValue(info); + if (propertyValue != null) + { + if (propertyValue is DateTime dateTimeValue) + { + values.Add(propertyName, dateTimeValue.ToString("yyyy-MM-dd")); + } + else + { + values.Add(propertyName, propertyValue.ToString() ?? ""); + } + } + } + } + } + + return values; + } + + /// + /// Applies token values to a filename format and removes invalid file name characters. + /// + /// The filename format string. + /// Token values to substitute. + /// The resolved filename. + public Task BuildFilename(string fileFormat, Dictionary values) + { + foreach (KeyValuePair kvp in values) + { + string placeholder = "{" + kvp.Key + "}"; + fileFormat = fileFormat.Replace(placeholder, kvp.Value); + } + + return Task.FromResult(RemoveInvalidFileNameChars($"{fileFormat}")); + } + + private static object? GetNestedPropertyValue(object source, string propertyPath) + { + object? value = source; + foreach (string propertyName in propertyPath.Split('.')) + { + PropertyInfo property = value?.GetType().GetProperty(propertyName) ?? + throw new ArgumentException($"Property '{propertyName}' not found."); + value = property.GetValue(value); + } + + return value; + } + + private static string RemoveInvalidFileNameChars(string fileName) => string.IsNullOrEmpty(fileName) + ? fileName + : string.Concat(fileName.Split(Path.GetInvalidFileNameChars())); +} diff --git a/OF DL.Core/Services/IApiService.cs b/OF DL.Core/Services/IApiService.cs new file mode 100644 index 0000000..1a293bc --- /dev/null +++ b/OF DL.Core/Services/IApiService.cs @@ -0,0 +1,130 @@ +using Newtonsoft.Json.Linq; +using OF_DL.Enumerations; +using ArchivedEntities = OF_DL.Models.Entities.Archived; +using MessageEntities = OF_DL.Models.Entities.Messages; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; +using StreamEntities = OF_DL.Models.Entities.Streams; +using UserEntities = OF_DL.Models.Entities.Users; + +namespace OF_DL.Services; + +public interface IApiService +{ + /// + /// Retrieves a decryption key using the local CDM integration. + /// + Task GetDecryptionKeyCdm(Dictionary drmHeaders, string licenceUrl, string pssh); + + /// + /// Retrieves the last modified timestamp for a DRM MPD manifest. + /// + Task GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp); + + /// + /// Retrieves the Widevine PSSH from an MPD manifest. + /// + Task GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp); + + /// + /// Retrieves the user's lists. + /// + Task?> GetLists(string endpoint); + + /// + /// Retrieves usernames for a specific list. + /// + Task?> GetListUsers(string endpoint); + + /// + /// Retrieves media URLs for stories or highlights. + /// + Task?> GetMedia(MediaType mediaType, string endpoint, string? username, string folder); + + /// + /// Retrieves paid posts and their media. + /// + Task GetPaidPosts(string endpoint, string folder, string username, + List paidPostIds, + IStatusReporter statusReporter); + + /// + /// Retrieves posts and their media. + /// + Task GetPosts(string endpoint, string folder, List paidPostIds, + IStatusReporter statusReporter); + + /// + /// Retrieves a single post and its media. + /// + Task GetPost(string endpoint, string folder); + + /// + /// Retrieves streams and their media. + /// + Task GetStreams(string endpoint, string folder, List paidPostIds, + IStatusReporter statusReporter); + + /// + /// Retrieves archived posts and their media. + /// + Task GetArchived(string endpoint, string folder, + IStatusReporter statusReporter); + + /// + /// Retrieves messages and their media. + /// + Task GetMessages(string endpoint, string folder, IStatusReporter statusReporter); + + /// + /// Retrieves paid messages and their media. + /// + Task GetPaidMessages(string endpoint, string folder, string username, + IStatusReporter statusReporter); + + /// + /// Retrieves a single paid message and its media. + /// + Task GetPaidMessage(string endpoint, string folder); + + /// + /// Retrieves users that appear in the Purchased tab. + /// + Task> GetPurchasedTabUsers(string endpoint, Dictionary users); + + /// + /// Retrieves Purchased tab content grouped by user. + /// + Task> GetPurchasedTab(string endpoint, string folder, + Dictionary users); + + /// + /// Retrieves user information. + /// + Task GetUserInfo(string endpoint); + + /// + /// Retrieves user information by ID. + /// + Task GetUserInfoById(string endpoint); + + /// + /// Builds signed headers for API requests. + /// + Dictionary GetDynamicHeaders(string path, string queryParam); + + /// + /// Retrieves active subscriptions. + /// + Task?> GetActiveSubscriptions(string endpoint, bool includeRestrictedSubscriptions); + + /// + /// Retrieves expired subscriptions. + /// + Task?> GetExpiredSubscriptions(string endpoint, bool includeRestrictedSubscriptions); + + /// + /// Retrieves a decryption key via the OF DL fallback service. + /// + Task GetDecryptionKeyOfdl(Dictionary drmHeaders, string licenceUrl, string pssh); +} diff --git a/OF DL.Core/Services/IAuthService.cs b/OF DL.Core/Services/IAuthService.cs new file mode 100644 index 0000000..432d13f --- /dev/null +++ b/OF DL.Core/Services/IAuthService.cs @@ -0,0 +1,42 @@ +using OF_DL.Models; +using UserEntities = OF_DL.Models.Entities.Users; + +namespace OF_DL.Services; + +public interface IAuthService +{ + /// + /// Gets or sets the current authentication state. + /// + Auth? CurrentAuth { get; set; } + + /// + /// Loads authentication data from the disk. + /// + Task LoadFromFileAsync(string filePath = "auth.json"); + + /// + /// Launches a browser session and extracts auth data after login. + /// + Task LoadFromBrowserAsync(); + + /// + /// Persists the current auth data to disk. + /// + Task SaveToFileAsync(string filePath = "auth.json"); + + /// + /// Cleans up the cookie string to only contain auth_id and sess cookies. + /// + void ValidateCookieString(); + + /// + /// Validates auth by calling the API and returns the user info if valid. + /// + Task ValidateAuthAsync(); + + /// + /// Logs out by deleting chrome-data and auth.json. + /// + void Logout(); +} diff --git a/OF DL.Core/Services/IConfigService.cs b/OF DL.Core/Services/IConfigService.cs new file mode 100644 index 0000000..27c767d --- /dev/null +++ b/OF DL.Core/Services/IConfigService.cs @@ -0,0 +1,41 @@ +using OF_DL.Models.Config; + +namespace OF_DL.Services; + +public interface IConfigService +{ + /// + /// Gets the active configuration in memory. + /// + Config CurrentConfig { get; } + + /// + /// Gets whether the CLI requested non-interactive mode. + /// + bool IsCliNonInteractive { get; } + + /// + /// Loads configuration from disk and applies runtime settings. + /// + Task LoadConfigurationAsync(string[] args); + + /// + /// Saves the current configuration to disk. + /// + Task SaveConfigurationAsync(string filePath = "config.conf"); + + /// + /// Replaces the current configuration and applies runtime settings. + /// + void UpdateConfig(Config newConfig); + + /// + /// Returns property names and current values for toggleable config properties. + /// + List<(string Name, bool Value)> GetToggleableProperties(); + + /// + /// Applies selected toggleable properties. Returns true if any changed. + /// + bool ApplyToggleableSelections(List selectedNames); +} diff --git a/OF DL.Core/Services/IDbService.cs b/OF DL.Core/Services/IDbService.cs new file mode 100644 index 0000000..90ba140 --- /dev/null +++ b/OF DL.Core/Services/IDbService.cs @@ -0,0 +1,64 @@ +namespace OF_DL.Services; + +public interface IDbService +{ + /// + /// Inserts a message record when it does not already exist. + /// + Task AddMessage(string folder, long postId, string messageText, string price, bool isPaid, bool isArchived, + DateTime createdAt, long userId); + + /// + /// Inserts a post record when it does not already exist. + /// + Task AddPost(string folder, long postId, string messageText, string price, bool isPaid, bool isArchived, + DateTime createdAt); + + /// + /// Inserts a story record when it does not already exist. + /// + Task AddStory(string folder, long postId, string messageText, string price, bool isPaid, bool isArchived, + DateTime createdAt); + + /// + /// Creates or updates the per-user metadata database. + /// + Task CreateDb(string folder); + + /// + /// Creates or updates the global users database. + /// + Task CreateUsersDb(Dictionary users); + + /// + /// Ensures a username matches the stored user ID and migrates folders if needed. + /// + Task CheckUsername(KeyValuePair user, string path); + + /// + /// Inserts a media record when it does not already exist. + /// + Task AddMedia(string folder, long mediaId, long postId, string link, string? directory, string? filename, + long? size, string apiType, string mediaType, bool preview, bool downloaded, DateTime? createdAt); + + /// + /// Updates the media record with local file details. + /// + Task UpdateMedia(string folder, long mediaId, string apiType, string directory, string filename, long size, + bool downloaded, DateTime createdAt); + + /// + /// Returns the stored size for a media record. + /// + Task GetStoredFileSize(string folder, long mediaId, string apiType); + + /// + /// Checks whether the media has been marked as downloaded. + /// + Task CheckDownloaded(string folder, long mediaId, string apiType); + + /// + /// Returns the most recent post date based on downloaded and pending media. + /// + Task GetMostRecentPostDate(string folder); +} diff --git a/OF DL.Core/Services/IDownloadEventHandler.cs b/OF DL.Core/Services/IDownloadEventHandler.cs new file mode 100644 index 0000000..602f6c2 --- /dev/null +++ b/OF DL.Core/Services/IDownloadEventHandler.cs @@ -0,0 +1,63 @@ +using OF_DL.Models.Downloads; + +namespace OF_DL.Services; + +/// +/// UI callback contract for download orchestration. Implementations handle +/// status display, progress bars, and notifications in a UI-framework-specific way. +/// +public interface IDownloadEventHandler +{ + /// + /// Wraps work in a status indicator (spinner) during API fetching. + /// The implementation controls how the status is displayed. + /// + Task WithStatusAsync(string statusMessage, Func> work); + + /// + /// Wraps work in a progress bar during downloading. + /// The implementation controls how progress is displayed. + /// + Task WithProgressAsync(string description, long maxValue, bool showSize, + Func> work); + + /// + /// Called when content of a specific type is found for a creator. + /// + void OnContentFound(string contentType, int mediaCount, int objectCount); + + /// + /// Called when no content of a specific type is found for a creator. + /// + void OnNoContentFound(string contentType); + + /// + /// Called when downloading of a content type completes. + /// + void OnDownloadComplete(string contentType, DownloadResult result); + + /// + /// Called when starting to process a specific user/creator. + /// + void OnUserStarting(string username); + + /// + /// Called when all downloads for a user/creator are complete. + /// + void OnUserComplete(string username, CreatorDownloadResult result); + + /// + /// Called when a purchased tab user's downloads are complete. + /// + void OnPurchasedTabUserComplete(string username, int paidPostCount, int paidMessagesCount); + + /// + /// Called when the entire scrape operation completes. + /// + void OnScrapeComplete(TimeSpan elapsed); + + /// + /// General status message display. + /// + void OnMessage(string message); +} diff --git a/OF DL.Core/Services/IDownloadOrchestrationService.cs b/OF DL.Core/Services/IDownloadOrchestrationService.cs new file mode 100644 index 0000000..f471bfd --- /dev/null +++ b/OF DL.Core/Services/IDownloadOrchestrationService.cs @@ -0,0 +1,72 @@ +using OF_DL.Models.Downloads; + +namespace OF_DL.Services; + +public interface IDownloadOrchestrationService +{ + /// + /// Fetch subscriptions, lists, filter ignored users. + /// + Task GetAvailableUsersAsync(); + + /// + /// Get users for a specific list by name. + /// + Task> GetUsersForListAsync( + string listName, Dictionary allUsers, Dictionary lists); + + /// + /// Resolve download path for a username based on config. + /// + string ResolveDownloadPath(string username); + + /// + /// Prepare user folder (create dir, check username, create DB). + /// + Task PrepareUserFolderAsync(string username, long userId, string path); + + /// + /// Download all configured content types for a single creator. + /// + Task DownloadCreatorContentAsync( + string username, long userId, string path, + Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + IDownloadEventHandler eventHandler); + + /// + /// Download a single post by ID. + /// + Task DownloadSinglePostAsync( + string username, long postId, string path, + Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + IDownloadEventHandler eventHandler); + + /// + /// Download purchased tab content for all users. + /// + Task DownloadPurchasedTabAsync( + Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + IDownloadEventHandler eventHandler); + + /// + /// Download a single paid message by message ID. + /// + Task DownloadSinglePaidMessageAsync( + string username, long messageId, string path, + Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + IDownloadEventHandler eventHandler); + + /// + /// Resolve username from user ID via API. + /// + Task ResolveUsernameAsync(long userId); + + /// + /// Tracks paid post IDs across downloads. + /// + List PaidPostIds { get; } +} diff --git a/OF DL.Core/Services/IDownloadService.cs b/OF DL.Core/Services/IDownloadService.cs new file mode 100644 index 0000000..598b09b --- /dev/null +++ b/OF DL.Core/Services/IDownloadService.cs @@ -0,0 +1,119 @@ +using OF_DL.Models.Downloads; +using ArchivedEntities = OF_DL.Models.Entities.Archived; +using MessageEntities = OF_DL.Models.Entities.Messages; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; +using StreamEntities = OF_DL.Models.Entities.Streams; + +namespace OF_DL.Services; + +public interface IDownloadService +{ + /// + /// Calculates the total size of a set of URLs by fetching their metadata. + /// + Task CalculateTotalFileSize(List urls); + + /// + /// Downloads media and updates metadata storage. + /// + Task ProcessMediaDownload(string folder, long mediaId, string apiType, string url, string path, + string serverFileName, string resolvedFileName, string extension, IProgressReporter progressReporter); + + /// + /// Retrieves decryption information for a DRM media item. + /// + Task<(string decryptionKey, DateTime lastModified)?> GetDecryptionInfo( + string mpdUrl, string policy, string signature, string kvp, + string mediaId, string contentId, string drmType, + bool clientIdBlobMissing, bool devicePrivateKeyMissing); + + /// + /// Downloads profile avatar and header images for a creator. + /// + Task DownloadAvatarHeader(string? avatarUrl, string? headerUrl, string folder, string username); + + /// + /// Downloads highlight media for a creator. + /// + Task DownloadHighlights(string username, long userId, string path, HashSet paidPostIds, + IProgressReporter progressReporter); + + /// + /// Downloads story media for a creator. + /// + Task DownloadStories(string username, long userId, string path, HashSet paidPostIds, + IProgressReporter progressReporter); + + /// + /// Downloads archived posts for a creator. + /// + Task DownloadArchived(string username, long userId, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, ArchivedEntities.ArchivedCollection archived, + IProgressReporter progressReporter); + + /// + /// Downloads free messages for a creator. + /// + Task DownloadMessages(string username, long userId, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, MessageEntities.MessageCollection messages, + IProgressReporter progressReporter); + + /// + /// Downloads paid messages for a creator. + /// + Task DownloadPaidMessages(string username, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidMessageCollection paidMessageCollection, + IProgressReporter progressReporter); + + /// + /// Downloads stream posts for a creator. + /// + Task DownloadStreams(string username, long userId, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, StreamEntities.StreamsCollection streams, + IProgressReporter progressReporter); + + /// + /// Downloads free posts for a creator. + /// + Task DownloadFreePosts(string username, long userId, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, PostEntities.PostCollection posts, + IProgressReporter progressReporter); + + /// + /// Downloads paid posts for a creator. + /// + Task DownloadPaidPosts(string username, long userId, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, PurchasedEntities.PaidPostCollection purchasedPosts, + IProgressReporter progressReporter); + + /// + /// Downloads paid posts sourced from the Purchased tab. + /// + Task DownloadPaidPostsPurchasedTab(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidPostCollection purchasedPosts, IProgressReporter progressReporter); + + /// + /// Downloads paid messages sourced from the Purchased tab. + /// + Task DownloadPaidMessagesPurchasedTab(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidMessageCollection paidMessageCollection, IProgressReporter progressReporter); + + /// + /// Downloads a single post collection. + /// + Task DownloadSinglePost(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PostEntities.SinglePostCollection post, IProgressReporter progressReporter); + + /// + /// Downloads a single paid message collection. + /// + Task DownloadSinglePaidMessage(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.SinglePaidMessageCollection singlePaidMessageCollection, + IProgressReporter progressReporter); +} diff --git a/OF DL.Core/Services/IFileNameService.cs b/OF DL.Core/Services/IFileNameService.cs new file mode 100644 index 0000000..51c97b1 --- /dev/null +++ b/OF DL.Core/Services/IFileNameService.cs @@ -0,0 +1,16 @@ +namespace OF_DL.Services; + +public interface IFileNameService +{ + /// + /// Applies token values to a filename format and removes invalid characters. + /// + Task BuildFilename(string fileFormat, Dictionary values); + + /// + /// Builds a map of filename token values from post, media, and author data. + /// + Task> GetFilename(object info, object media, object author, + List selectedProperties, + string username, Dictionary? users = null); +} diff --git a/OF DL.Core/Services/ILoggingService.cs b/OF DL.Core/Services/ILoggingService.cs new file mode 100644 index 0000000..69f5277 --- /dev/null +++ b/OF DL.Core/Services/ILoggingService.cs @@ -0,0 +1,22 @@ +using OF_DL.Enumerations; +using Serilog.Core; + +namespace OF_DL.Services; + +public interface ILoggingService +{ + /// + /// Gets the level switch that controls runtime logging verbosity. + /// + LoggingLevelSwitch LevelSwitch { get; } + + /// + /// Updates the minimum logging level at runtime. + /// + void UpdateLoggingLevel(LoggingLevel newLevel); + + /// + /// Returns the current minimum logging level. + /// + LoggingLevel GetCurrentLoggingLevel(); +} diff --git a/OF DL.Core/Services/IProgressReporter.cs b/OF DL.Core/Services/IProgressReporter.cs new file mode 100644 index 0000000..dc59aca --- /dev/null +++ b/OF DL.Core/Services/IProgressReporter.cs @@ -0,0 +1,14 @@ +namespace OF_DL.Services; + +/// +/// Interface for reporting download progress in a UI-agnostic way. +/// This allows the download service to report progress without being coupled to any specific UI framework. +/// +public interface IProgressReporter +{ + /// + /// Reports progress increment. The value represents either bytes downloaded or file count depending on configuration. + /// + /// The amount to increment progress by + void ReportProgress(long increment); +} diff --git a/OF DL.Core/Services/IStartupService.cs b/OF DL.Core/Services/IStartupService.cs new file mode 100644 index 0000000..12bd881 --- /dev/null +++ b/OF DL.Core/Services/IStartupService.cs @@ -0,0 +1,16 @@ +using OF_DL.Models; + +namespace OF_DL.Services; + +public interface IStartupService +{ + /// + /// Validates the runtime environment and returns a structured result. + /// + Task ValidateEnvironmentAsync(); + + /// + /// Checks the current application version against the latest release tag. + /// + Task CheckVersionAsync(); +} diff --git a/OF DL.Core/Services/IStatusReporter.cs b/OF DL.Core/Services/IStatusReporter.cs new file mode 100644 index 0000000..fe693c5 --- /dev/null +++ b/OF DL.Core/Services/IStatusReporter.cs @@ -0,0 +1,14 @@ +namespace OF_DL.Services; + +/// +/// Interface for reporting status updates in a UI-agnostic way. +/// This replaces Spectre.Console's StatusContext in the service layer. +/// +public interface IStatusReporter +{ + /// + /// Reports a status message (e.g., "Getting Posts\n Found 42"). + /// The reporter implementation decides how to format and display the message. + /// + void ReportStatus(string message); +} diff --git a/OF DL.Core/Services/LoggingService.cs b/OF DL.Core/Services/LoggingService.cs new file mode 100644 index 0000000..a0b11b4 --- /dev/null +++ b/OF DL.Core/Services/LoggingService.cs @@ -0,0 +1,48 @@ +using OF_DL.Enumerations; +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace OF_DL.Services; + +public class LoggingService : ILoggingService +{ + public LoggingService() + { + LevelSwitch = new LoggingLevelSwitch(); + InitializeLogger(); + } + + /// + /// Gets the level switch that controls runtime logging verbosity. + /// + public LoggingLevelSwitch LevelSwitch { get; } + + /// + /// Updates the minimum logging level at runtime. + /// + /// The new minimum log level. + public void UpdateLoggingLevel(LoggingLevel newLevel) + { + LevelSwitch.MinimumLevel = (LogEventLevel)newLevel; + Log.Debug("Logging level updated to: {LoggingLevel}", newLevel); + } + + /// + /// Returns the current minimum logging level. + /// + public LoggingLevel GetCurrentLoggingLevel() => (LoggingLevel)LevelSwitch.MinimumLevel; + + private void InitializeLogger() + { + // Set the initial level to Error (until we've read from config) + LevelSwitch.MinimumLevel = LogEventLevel.Error; + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.ControlledBy(LevelSwitch) + .WriteTo.File("logs/OFDL.txt", rollingInterval: RollingInterval.Day) + .CreateLogger(); + + Log.Debug("Logging service initialized"); + } +} diff --git a/OF DL.Core/Services/StartupService.cs b/OF DL.Core/Services/StartupService.cs new file mode 100644 index 0000000..c5e4570 --- /dev/null +++ b/OF DL.Core/Services/StartupService.cs @@ -0,0 +1,239 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using Newtonsoft.Json; +using OF_DL.Helpers; +using OF_DL.Models; +using OF_DL.Models.OfdlApi; +using Serilog; +using static Newtonsoft.Json.JsonConvert; +using WidevineConstants = OF_DL.Widevine.Constants; + +namespace OF_DL.Services; + +public class StartupService(IConfigService configService, IAuthService authService) : IStartupService +{ + /// + /// Validates the runtime environment and returns a structured result. + /// + /// A result describing environment checks and detected tools. + public async Task ValidateEnvironmentAsync() + { + StartupResult result = new(); + + // OS validation + OperatingSystem os = Environment.OSVersion; + result.OsVersionString = os.VersionString; + Log.Debug($"Operating system information: {os.VersionString}"); + + if (os.Platform == PlatformID.Win32NT && os.Version.Major < 10) + { + result.IsWindowsVersionValid = false; + Log.Error("Windows version prior to 10.x: {0}", os.VersionString); + } + + // FFmpeg detection + DetectFfmpeg(result); + + if (result.FfmpegFound && result.FfmpegPath != null) + { + // Escape backslashes for Windows + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + result.FfmpegPath.Contains(@":\") && + !result.FfmpegPath.Contains(@":\\")) + { + result.FfmpegPath = result.FfmpegPath.Replace(@"\", @"\\"); + configService.CurrentConfig.FFmpegPath = result.FfmpegPath; + } + + // Get FFmpeg version + result.FfmpegVersion = await GetFfmpegVersionAsync(result.FfmpegPath); + } + + // Widevine device checks + result.ClientIdBlobMissing = !File.Exists(Path.Join(WidevineConstants.DEVICES_FOLDER, + WidevineConstants.DEVICE_NAME, "device_client_id_blob")); + result.DevicePrivateKeyMissing = !File.Exists(Path.Join(WidevineConstants.DEVICES_FOLDER, + WidevineConstants.DEVICE_NAME, "device_private_key")); + + Log.Debug("device_client_id_blob {Status}", result.ClientIdBlobMissing ? "missing" : "found"); + Log.Debug("device_private_key {Status}", result.DevicePrivateKeyMissing ? " missing" : "found"); + + // rules.json validation + if (!File.Exists("rules.json")) + { + return result; + } + + result.RulesJsonExists = true; + try + { + DeserializeObject(await File.ReadAllTextAsync("rules.json")); + Log.Debug("Rules.json: "); + Log.Debug(SerializeObject(await File.ReadAllTextAsync("rules.json"), Formatting.Indented)); + result.RulesJsonValid = true; + } + catch (Exception e) + { + result.RulesJsonError = e.Message; + Log.Error("rules.json processing failed. {ErrorMessage}", e.Message); + } + + return result; + } + + /// + /// Checks the current application version against the latest release tag. + /// + /// A result describing the version check status. + public async Task CheckVersionAsync() + { + VersionCheckResult result = new(); + +#if !DEBUG + try + { + result.LocalVersion = Assembly.GetEntryAssembly()?.GetName().Version; + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + string? latestReleaseTag; + + try + { + latestReleaseTag = await VersionHelper.GetLatestReleaseTag(cts.Token); + } + catch (OperationCanceledException) + { + result.TimedOut = true; + Log.Warning("Version check timed out after 30 seconds"); + return result; + } + + if (latestReleaseTag == null) + { + result.CheckFailed = true; + Log.Error("Failed to get the latest release tag."); + return result; + } + + result.LatestVersion = new Version(latestReleaseTag.Replace("OFDLV", "")); + int? versionComparison = result.LocalVersion?.CompareTo(result.LatestVersion); + result.IsUpToDate = versionComparison >= 0; + + Log.Debug("Detected client running version " + + $"{result.LocalVersion?.Major}.{result.LocalVersion?.Minor}.{result.LocalVersion?.Build}"); + Log.Debug("Latest release version " + + $"{result.LatestVersion.Major}.{result.LatestVersion.Minor}.{result.LatestVersion.Build}"); + } + catch (Exception e) + { + result.CheckFailed = true; + Log.Error("Error checking latest release on GitHub. {Message}", e.Message); + } +#else + await Task.CompletedTask; + Log.Debug("Running in Debug/Local mode. Version check skipped."); + result.IsUpToDate = true; +#endif + + return result; + } + + private void DetectFfmpeg(StartupResult result) + { + if (!string.IsNullOrEmpty(configService.CurrentConfig.FFmpegPath) && + ValidateFilePath(configService.CurrentConfig.FFmpegPath)) + { + result.FfmpegFound = true; + result.FfmpegPath = configService.CurrentConfig.FFmpegPath; + Log.Debug($"FFMPEG found: {result.FfmpegPath}"); + Log.Debug("FFMPEG path set in config.conf"); + } + else if (!string.IsNullOrEmpty(authService.CurrentAuth?.FfmpegPath) && + ValidateFilePath(authService.CurrentAuth.FfmpegPath)) + { + result.FfmpegFound = true; + result.FfmpegPath = authService.CurrentAuth.FfmpegPath; + configService.CurrentConfig.FFmpegPath = result.FfmpegPath; + Log.Debug($"FFMPEG found: {result.FfmpegPath}"); + Log.Debug("FFMPEG path set in auth.json"); + } + else if (string.IsNullOrEmpty(configService.CurrentConfig.FFmpegPath)) + { + string? ffmpegPath = GetFullPath("ffmpeg") ?? GetFullPath("ffmpeg.exe"); + if (ffmpegPath != null) + { + result.FfmpegFound = true; + result.FfmpegPathAutoDetected = true; + result.FfmpegPath = ffmpegPath; + configService.CurrentConfig.FFmpegPath = ffmpegPath; + Log.Debug($"FFMPEG found: {ffmpegPath}"); + Log.Debug("FFMPEG path found via PATH or current directory"); + } + } + + if (!result.FfmpegFound) + { + Log.Error($"Cannot locate FFmpeg with path: {configService.CurrentConfig.FFmpegPath}"); + } + } + + private static async Task GetFfmpegVersionAsync(string ffmpegPath) + { + try + { + ProcessStartInfo processStartInfo = new() + { + FileName = ffmpegPath, + Arguments = "-version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using Process? process = Process.Start(processStartInfo); + if (process != null) + { + string output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + Log.Information("FFmpeg version output:\n{Output}", output); + + string firstLine = output.Split('\n')[0].Trim(); + if (firstLine.StartsWith("ffmpeg version")) + { + int versionStart = "ffmpeg version ".Length; + int copyrightIndex = firstLine.IndexOf(" Copyright", StringComparison.Ordinal); + return copyrightIndex > versionStart + ? firstLine.Substring(versionStart, copyrightIndex - versionStart) + : firstLine.Substring(versionStart); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to get FFmpeg version"); + } + + return null; + } + + private static bool ValidateFilePath(string path) + { + char[] invalidChars = Path.GetInvalidPathChars(); + return !path.Any(c => invalidChars.Contains(c)) && File.Exists(path); + } + + private static string? GetFullPath(string filename) + { + if (File.Exists(filename)) + { + return Path.GetFullPath(filename); + } + + string pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; + return pathEnv.Split(Path.PathSeparator).Select(path => Path.Combine(path, filename)) + .FirstOrDefault(File.Exists); + } +} diff --git a/OF DL.Core/Utils/HttpUtil.cs b/OF DL.Core/Utils/HttpUtil.cs new file mode 100644 index 0000000..5982964 --- /dev/null +++ b/OF DL.Core/Utils/HttpUtil.cs @@ -0,0 +1,141 @@ +using System.Net; +using System.Text; +using OF_DL.Helpers; + +namespace OF_DL.Utils; + +internal class HttpUtil +{ + public static HttpClient Client { get; set; } = new(new HttpClientHandler + { + AllowAutoRedirect = true + //Proxy = null + }); + + public static async Task PostData(string URL, Dictionary headers, string postData) + { + string mediaType = postData.StartsWith("{") ? "application/json" : "application/x-www-form-urlencoded"; + HttpResponseMessage response = await PerformOperation(async () => + { + StringContent content = new(postData, Encoding.UTF8, mediaType); + //ByteArrayContent content = new ByteArrayContent(postData); + + return await Post(URL, headers, content); + }); + + byte[] bytes = await response.Content.ReadAsByteArrayAsync(); + return bytes; + } + + public static async Task PostData(string URL, Dictionary headers, byte[] postData) + { + HttpResponseMessage response = await PerformOperation(async () => + { + ByteArrayContent content = new(postData); + + return await Post(URL, headers, content); + }); + + byte[] bytes = await response.Content.ReadAsByteArrayAsync(); + return bytes; + } + + public static async Task PostData(string URL, Dictionary headers, + Dictionary postData) + { + HttpResponseMessage response = await PerformOperation(async () => + { + FormUrlEncodedContent content = new(postData); + + return await Post(URL, headers, content); + }); + + byte[] bytes = await response.Content.ReadAsByteArrayAsync(); + return bytes; + } + + public static async Task GetWebSource(string URL, Dictionary? headers = null) + { + HttpResponseMessage response = await PerformOperation(async () => { return await Get(URL, headers); }); + + byte[] bytes = await response.Content.ReadAsByteArrayAsync(); + return Encoding.UTF8.GetString(bytes); + } + + public static async Task GetBinary(string URL, Dictionary? headers = null) + { + HttpResponseMessage response = await PerformOperation(async () => { return await Get(URL, headers); }); + + byte[] bytes = await response.Content.ReadAsByteArrayAsync(); + return bytes; + } + + public static string GetString(byte[] bytes) => Encoding.UTF8.GetString(bytes); + + private static async Task Get(string URL, Dictionary? headers = null) + { + HttpRequestMessage request = new() { RequestUri = new Uri(URL), Method = HttpMethod.Get }; + + if (headers != null) + { + foreach (KeyValuePair header in headers) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return await Send(request); + } + + private static async Task Post(string URL, Dictionary? headers = null, + HttpContent? content = null) + { + HttpRequestMessage request = new() { RequestUri = new Uri(URL), Method = HttpMethod.Post, Content = content }; + + if (headers != null) + { + foreach (KeyValuePair header in headers) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return await Send(request); + } + + private static async Task Send(HttpRequestMessage request) => await Client.SendAsync(request); + + private static async Task PerformOperation(Func> operation) + { + HttpResponseMessage response = await operation(); + + int retryCount = 0; + + while (retryCount < Constants.WidevineMaxRetries && response.StatusCode == HttpStatusCode.TooManyRequests) + { + // + // We've hit a rate limit, so we should wait before retrying. + // + int retryAfterSeconds = + Constants.WidevineRetryDelay * (retryCount + 1); // Default retry time. Increases with each retry. + if (response.Headers.RetryAfter != null && response.Headers.RetryAfter.Delta.HasValue) + { + if (response.Headers.RetryAfter.Delta.Value.TotalSeconds > 0) + { + retryAfterSeconds = + (int)response.Headers.RetryAfter.Delta.Value.TotalSeconds + + 1; // Add 1 second to ensure we wait a bit longer than the suggested time + } + } + + await Task.Delay(retryAfterSeconds * 1000); // Peform the delay + + response = await operation(); + retryCount++; + } + + response.EnsureSuccessStatusCode(); // Throw an exception if the response is not successful + + return response; + } +} diff --git a/OF DL/Utils/ThrottledStream.cs b/OF DL.Core/Utils/ThrottledStream.cs similarity index 61% rename from OF DL/Utils/ThrottledStream.cs rename to OF DL.Core/Utils/ThrottledStream.cs index 07b8857..6e0ef5e 100644 --- a/OF DL/Utils/ThrottledStream.cs +++ b/OF DL.Core/Utils/ThrottledStream.cs @@ -1,22 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; using System.Reactive.Concurrency; -using System.Text; -using System.Threading.Tasks; namespace OF_DL.Utils; - public class ThrottledStream : Stream { - private readonly Stream parent; private readonly int maxBytesPerSecond; + private readonly Stream parent; private readonly IScheduler scheduler; - private readonly IStopwatch stopwatch; private readonly bool shouldThrottle; + private readonly IStopwatch stopwatch; private long processed; @@ -36,28 +29,53 @@ public class ThrottledStream : Stream } + public override bool CanRead => parent.CanRead; + + + public override bool CanSeek => parent.CanSeek; + + + public override bool CanWrite => parent.CanWrite; + + + public override long Length => parent.Length; + + + public override long Position + { + get => parent.Position; + set => parent.Position = value; + } + + protected void Throttle(int bytes) { - if (!shouldThrottle) return; + if (!shouldThrottle) + { + return; + } + processed += bytes; - var targetTime = TimeSpan.FromSeconds((double)processed / maxBytesPerSecond); - var actualTime = stopwatch.Elapsed; - var sleep = targetTime - actualTime; + TimeSpan targetTime = TimeSpan.FromSeconds((double)processed / maxBytesPerSecond); + TimeSpan actualTime = stopwatch.Elapsed; + TimeSpan sleep = targetTime - actualTime; if (sleep > TimeSpan.Zero) { - using var waitHandle = new AutoResetEvent(initialState: false); - scheduler.Sleep(sleep).GetAwaiter().OnCompleted(() => waitHandle.Set()); - waitHandle.WaitOne(); + scheduler.Sleep(sleep).GetAwaiter().GetResult(); } } - protected async Task ThrottleAsync(int bytes) + private async Task ThrottleAsync(int bytes) { - if (!shouldThrottle) return; + if (!shouldThrottle) + { + return; + } + processed += bytes; - var targetTime = TimeSpan.FromSeconds((double)processed / maxBytesPerSecond); - var actualTime = stopwatch.Elapsed; - var sleep = targetTime - actualTime; + TimeSpan targetTime = TimeSpan.FromSeconds((double)processed / maxBytesPerSecond); + TimeSpan actualTime = stopwatch.Elapsed; + TimeSpan sleep = targetTime - actualTime; if (sleep > TimeSpan.Zero) { @@ -67,7 +85,7 @@ public class ThrottledStream : Stream public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - var read = await parent.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + int read = await parent.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); await ThrottleAsync(read).ConfigureAwait(false); return read; } @@ -85,71 +103,26 @@ public class ThrottledStream : Stream await parent.WriteAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); } - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) { await ThrottleAsync(buffer.Length).ConfigureAwait(false); await parent.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); } - public override bool CanRead - { - get { return parent.CanRead; } - } - - - public override bool CanSeek - { - get { return parent.CanSeek; } - } - - - public override bool CanWrite - { - get { return parent.CanWrite; } - } - - - public override void Flush() - { - parent.Flush(); - } - - - public override long Length - { - get { return parent.Length; } - } - - - public override long Position - { - get - { - return parent.Position; - } - set - { - parent.Position = value; - } - } + public override void Flush() => parent.Flush(); public override int Read(byte[] buffer, int offset, int count) { - var read = parent.Read(buffer, offset, count); + int read = parent.Read(buffer, offset, count); Throttle(read); return read; } - public override long Seek(long offset, SeekOrigin origin) - { - return parent.Seek(offset, origin); - } + public override long Seek(long offset, SeekOrigin origin) => parent.Seek(offset, origin); - public override void SetLength(long value) - { - parent.SetLength(value); - } + public override void SetLength(long value) => parent.SetLength(value); public override void Write(byte[] buffer, int offset, int count) { diff --git a/OF DL.Core/Utils/XmlUtils.cs b/OF DL.Core/Utils/XmlUtils.cs new file mode 100644 index 0000000..3b57c0f --- /dev/null +++ b/OF DL.Core/Utils/XmlUtils.cs @@ -0,0 +1,29 @@ +using System.Xml.Linq; + +namespace OF_DL.Utils; + +internal static class XmlUtils +{ + // When true, return the original text without parsing/stripping. + public static bool Passthrough { get; set; } + + public static string EvaluateInnerText(string xmlValue) + { + if (Passthrough) + { + return xmlValue; + } + + try + { + XElement parsedText = XElement.Parse($"{xmlValue}"); + return parsedText.Value; + } + catch + { + // ignored + } + + return ""; + } +} diff --git a/OF DL.Core/Widevine/CDM.cs b/OF DL.Core/Widevine/CDM.cs new file mode 100644 index 0000000..9328847 --- /dev/null +++ b/OF DL.Core/Widevine/CDM.cs @@ -0,0 +1,576 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using OF_DL.Crypto; +using ProtoBuf; + +namespace OF_DL.Widevine; + +public class CDM +{ + private static Dictionary Devices { get; } = new() + { + [Constants.DEVICE_NAME] = new CDMDevice(Constants.DEVICE_NAME) + }; + + private static Dictionary Sessions { get; } = new(); + + private static byte[] CheckPSSH(string psshB64) + { + byte[] systemID = new byte[] { 237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237 }; + + if (psshB64.Length % 4 != 0) + { + psshB64 = psshB64.PadRight(psshB64.Length + (4 - psshB64.Length % 4), '='); + } + + byte[] pssh = Convert.FromBase64String(psshB64); + + if (pssh.Length < 30) + { + return pssh; + } + + if (!pssh[12..28].SequenceEqual(systemID)) + { + List newPssh = new() { 0, 0, 0 }; + newPssh.Add((byte)(32 + pssh.Length)); + newPssh.AddRange(Encoding.UTF8.GetBytes("pssh")); + newPssh.AddRange(new byte[] { 0, 0, 0, 0 }); + newPssh.AddRange(systemID); + newPssh.AddRange(new byte[] { 0, 0, 0, 0 }); + newPssh[31] = (byte)pssh.Length; + newPssh.AddRange(pssh); + + return newPssh.ToArray(); + } + + return pssh; + } + + public static string? OpenSession(string initDataB64, string deviceName, bool offline = false, bool raw = false) + { + byte[] initData = CheckPSSH(initDataB64); + + CDMDevice device = Devices[deviceName]; + + byte[] sessionId = new byte[16]; + + if (device.IsAndroid) + { + string randHex = ""; + + Random rand = new(); + string choice = "ABCDEF0123456789"; + for (int i = 0; i < 16; i++) + { + randHex += choice[rand.Next(16)]; + } + + string counter = "01"; + string rest = "00000000000000"; + sessionId = Encoding.ASCII.GetBytes(randHex + counter + rest); + } + else + { + Random rand = new(); + rand.NextBytes(sessionId); + } + + Session session; + dynamic? parsedInitData = ParseInitData(initData); + + if (parsedInitData != null) + { + session = new Session(sessionId, parsedInitData, device, offline); + } + else if (raw) + { + session = new Session(sessionId, initData, device, offline); + } + else + { + return null; + } + + Sessions.Add(BytesToHex(sessionId), session); + + return BytesToHex(sessionId); + } + + private static WidevineCencHeader? ParseInitData(byte[] initData) + { + WidevineCencHeader cencHeader; + + try + { + cencHeader = Serializer.Deserialize(new MemoryStream(initData[32..])); + } + catch + { + try + { + //needed for HBO Max + + PsshBox psshBox = PsshBox.FromByteArray(initData); + cencHeader = Serializer.Deserialize(new MemoryStream(psshBox.Data)); + } + catch + { + //Logger.Verbose("Unable to parse, unsupported init data format"); + return null; + } + } + + return cencHeader; + } + + public static bool CloseSession(string sessionId) + { + //Logger.Debug($"CloseSession(session_id={BytesToHex(sessionId)})"); + //Logger.Verbose("Closing CDM session"); + + if (Sessions.ContainsKey(sessionId)) + { + Sessions.Remove(sessionId); + //Logger.Verbose("CDM session closed"); + return true; + } + + //Logger.Info($"Session {sessionId} not found"); + return false; + } + + public static bool SetServiceCertificate(string sessionId, byte[] certData) + { + //Logger.Debug($"SetServiceCertificate(sessionId={BytesToHex(sessionId)}, cert={certB64})"); + //Logger.Verbose($"Setting service certificate"); + + if (!Sessions.ContainsKey(sessionId)) + { + //Logger.Error("Session ID doesn't exist"); + return false; + } + + SignedMessage signedMessage = new(); + + try + { + signedMessage = Serializer.Deserialize(new MemoryStream(certData)); + } + catch + { + //Logger.Warn("Failed to parse cert as SignedMessage"); + } + + SignedDeviceCertificate serviceCertificate; + try + { + try + { + //Logger.Debug("Service cert provided as signedmessage"); + serviceCertificate = + Serializer.Deserialize(new MemoryStream(signedMessage.Msg)); + } + catch + { + //Logger.Debug("Service cert provided as signeddevicecertificate"); + serviceCertificate = Serializer.Deserialize(new MemoryStream(certData)); + } + } + catch + { + //Logger.Error("Failed to parse service certificate"); + return false; + } + + Sessions[sessionId].ServiceCertificate = serviceCertificate; + Sessions[sessionId].PrivacyMode = true; + + return true; + } + + public static byte[]? GetLicenseRequest(string sessionId) + { + //Logger.Debug($"GetLicenseRequest(sessionId={BytesToHex(sessionId)})"); + //Logger.Verbose($"Getting license request"); + + if (!Sessions.ContainsKey(sessionId)) + { + //Logger.Error("Session ID doesn't exist"); + return null; + } + + Session session = Sessions[sessionId]; + + //Logger.Debug("Building license request"); + + dynamic licenseRequest; + + if (session.InitData is WidevineCencHeader) + { + licenseRequest = new SignedLicenseRequest + { + Type = SignedLicenseRequest.MessageType.LicenseRequest, + Msg = new LicenseRequest + { + Type = LicenseRequest.RequestType.New, + KeyControlNonce = 1093602366, + ProtocolVersion = ProtocolVersion.Current, + ContentId = new LicenseRequest.ContentIdentification + { + CencId = new LicenseRequest.ContentIdentification.Cenc + { + LicenseType = session.Offline ? LicenseType.Offline : LicenseType.Default, + RequestId = session.SessionId, + Pssh = session.InitData + } + } + } + }; + } + else + { + licenseRequest = new SignedLicenseRequestRaw + { + Type = SignedLicenseRequestRaw.MessageType.LicenseRequest, + Msg = new LicenseRequestRaw + { + Type = LicenseRequestRaw.RequestType.New, + KeyControlNonce = 1093602366, + ProtocolVersion = ProtocolVersion.Current, + ContentId = new LicenseRequestRaw.ContentIdentification + { + CencId = new LicenseRequestRaw.ContentIdentification.Cenc + { + LicenseType = session.Offline ? LicenseType.Offline : LicenseType.Default, + RequestId = session.SessionId, + Pssh = session.InitData + } + } + } + }; + } + + if (session.PrivacyMode) + { + //Logger.Debug("Privacy mode & serivce certificate loaded, encrypting client id"); + + EncryptedClientIdentification encryptedClientIdProto = new(); + + //Logger.Debug("Unencrypted client id " + Utils.SerializeToString(clientId)); + + using MemoryStream memoryStream = new(); + Serializer.Serialize(memoryStream, session.Device.ClientID); + byte[] data = Padding.AddPKCS7Padding(memoryStream.ToArray(), 16); + + using Aes aes = Aes.Create(); + aes.BlockSize = 128; + aes.Padding = PaddingMode.PKCS7; + aes.Mode = CipherMode.CBC; + + aes.GenerateKey(); + aes.GenerateIV(); + + using MemoryStream mstream = new(); + using CryptoStream cryptoStream = new(mstream, aes.CreateEncryptor(aes.Key, aes.IV), + CryptoStreamMode.Write); + cryptoStream.Write(data, 0, data.Length); + encryptedClientIdProto.EncryptedClientId = mstream.ToArray(); + + using RSACryptoServiceProvider RSA = new(); + RSA.ImportRSAPublicKey(session.ServiceCertificate.DeviceCertificate.PublicKey, out int _); + encryptedClientIdProto.EncryptedPrivacyKey = RSA.Encrypt(aes.Key, RSAEncryptionPadding.OaepSHA1); + encryptedClientIdProto.EncryptedClientIdIv = aes.IV; + encryptedClientIdProto.ServiceId = + Encoding.UTF8.GetString(session.ServiceCertificate.DeviceCertificate.ServiceId); + encryptedClientIdProto.ServiceCertificateSerialNumber = + session.ServiceCertificate.DeviceCertificate.SerialNumber; + + licenseRequest.Msg.EncryptedClientId = encryptedClientIdProto; + } + else + { + licenseRequest.Msg.ClientId = session.Device.ClientID; + } + + //Logger.Debug("Signing license request"); + + using (MemoryStream memoryStream = new()) + { + Serializer.Serialize(memoryStream, licenseRequest.Msg); + byte[] data = memoryStream.ToArray(); + session.LicenseRequest = data; + + licenseRequest.Signature = session.Device.Sign(data); + } + + //Logger.Verbose("License request created"); + + byte[] requestBytes; + using (MemoryStream memoryStream = new()) + { + Serializer.Serialize(memoryStream, licenseRequest); + requestBytes = memoryStream.ToArray(); + } + + Sessions[sessionId] = session; + + //Logger.Debug($"license request b64: {Convert.ToBase64String(requestBytes)}"); + return requestBytes; + } + + public static void ProvideLicense(string sessionId, byte[] license) + { + //Logger.Debug($"ProvideLicense(sessionId={BytesToHex(sessionId)}, licenseB64={licenseB64})"); + //Logger.Verbose("Decrypting provided license"); + + if (!Sessions.ContainsKey(sessionId)) + { + throw new Exception("Session ID doesn't exist"); + } + + Session session = Sessions[sessionId]; + + if (session.LicenseRequest == null) + { + throw new Exception("Generate a license request first"); + } + + SignedLicense signedLicense; + try + { + signedLicense = Serializer.Deserialize(new MemoryStream(license)); + } + catch + { + throw new Exception("Unable to parse license"); + } + + //Logger.Debug("License: " + Utils.SerializeToString(signedLicense)); + + session.License = signedLicense; + + //Logger.Debug($"Deriving keys from session key"); + + try + { + byte[] sessionKey = session.Device.Decrypt(session.License.SessionKey); + + if (sessionKey.Length != 16) + { + throw new Exception("Unable to decrypt session key"); + } + + session.SessionKey = sessionKey; + } + catch + { + throw new Exception("Unable to decrypt session key"); + } + + //Logger.Debug("Session key: " + BytesToHex(session.SessionKey)); + + session.DerivedKeys = DeriveKeys(session.LicenseRequest, session.SessionKey); + + //Logger.Debug("Verifying license signature"); + + byte[] licenseBytes; + using (MemoryStream memoryStream = new()) + { + Serializer.Serialize(memoryStream, signedLicense.Msg); + licenseBytes = memoryStream.ToArray(); + } + + byte[] hmacHash = CryptoUtils.GetHMACSHA256Digest(licenseBytes, session.DerivedKeys.Auth1); + + if (!hmacHash.SequenceEqual(signedLicense.Signature)) + { + throw new Exception("License signature mismatch"); + } + + foreach (License.KeyContainer key in signedLicense.Msg.Keys) + { + string type = key.Type.ToString(); + + if (type == "Signing") + { + continue; + } + + byte[] encryptedKey = key.Key; + byte[] iv = key.Iv; + byte[] keyId = key.Id; + if (keyId == null) + { + keyId = Encoding.ASCII.GetBytes(key.Type.ToString()); + } + + using MemoryStream mstream = new(); + + using Aes aes = Aes.Create(); + aes.Padding = PaddingMode.PKCS7; + aes.Mode = CipherMode.CBC; + + using CryptoStream cryptoStream = new(mstream, aes.CreateDecryptor(session.DerivedKeys.Enc, iv), + CryptoStreamMode.Write); + cryptoStream.Write(encryptedKey, 0, encryptedKey.Length); + byte[] decryptedKey = mstream.ToArray(); + + List permissions = []; + if (type == "OperatorSession") + { + foreach (PropertyInfo perm in key._OperatorSessionKeyPermissions.GetType().GetProperties()) + { + if ((uint?)perm.GetValue(key._OperatorSessionKeyPermissions) == 1) + { + permissions.Add(perm.Name); + } + } + } + + session.ContentKeys.Add(new ContentKey + { + KeyID = keyId, Type = type, Bytes = decryptedKey, Permissions = permissions + }); + } + + //Logger.Debug($"Key count: {session.Keys.Count}"); + + Sessions[sessionId] = session; + + //Logger.Verbose("Decrypted all keys"); + } + + public static DerivedKeys DeriveKeys(byte[] message, byte[] key) + { + byte[] encKeyBase = Encoding.UTF8.GetBytes("ENCRYPTION").Concat(new byte[] { 0x0 }).Concat(message) + .Concat(new byte[] { 0x0, 0x0, 0x0, 0x80 }).ToArray(); + byte[] authKeyBase = Encoding.UTF8.GetBytes("AUTHENTICATION").Concat(new byte[] { 0x0 }).Concat(message) + .Concat(new byte[] { 0x0, 0x0, 0x2, 0x0 }).ToArray(); + + byte[] encKey = new byte[] { 0x01 }.Concat(encKeyBase).ToArray(); + byte[] authKey1 = new byte[] { 0x01 }.Concat(authKeyBase).ToArray(); + byte[] authKey2 = new byte[] { 0x02 }.Concat(authKeyBase).ToArray(); + byte[] authKey3 = new byte[] { 0x03 }.Concat(authKeyBase).ToArray(); + byte[] authKey4 = new byte[] { 0x04 }.Concat(authKeyBase).ToArray(); + + byte[] encCmacKey = CryptoUtils.GetCMACDigest(encKey, key); + byte[] authCmacKey1 = CryptoUtils.GetCMACDigest(authKey1, key); + byte[] authCmacKey2 = CryptoUtils.GetCMACDigest(authKey2, key); + byte[] authCmacKey3 = CryptoUtils.GetCMACDigest(authKey3, key); + byte[] authCmacKey4 = CryptoUtils.GetCMACDigest(authKey4, key); + + byte[] authCmacCombined1 = authCmacKey1.Concat(authCmacKey2).ToArray(); + byte[] authCmacCombined2 = authCmacKey3.Concat(authCmacKey4).ToArray(); + + return new DerivedKeys { Auth1 = authCmacCombined1, Auth2 = authCmacCombined2, Enc = encCmacKey }; + } + + public static List GetKeys(string sessionId) + { + if (Sessions.ContainsKey(sessionId)) + { + return Sessions[sessionId].ContentKeys; + } + + throw new Exception("Session not found"); + } + + private static string BytesToHex(byte[] data) => BitConverter.ToString(data).Replace("-", ""); +} + +/* + public static List ProvideLicense(string requestB64, string licenseB64) + { + byte[] licenseRequest; + + var request = Serializer.Deserialize(new MemoryStream(Convert.FromBase64String(requestB64))); + + using (var ms = new MemoryStream()) + { + Serializer.Serialize(ms, request.Msg); + licenseRequest = ms.ToArray(); + } + + SignedLicense signedLicense; + try + { + signedLicense = Serializer.Deserialize(new MemoryStream(Convert.FromBase64String(licenseB64))); + } + catch + { + return null; + } + + byte[] sessionKey; + try + { + + sessionKey = Controllers.Adapter.OaepDecrypt(Convert.ToBase64String(signedLicense.SessionKey)); + + if (sessionKey.Length != 16) + { + return null; + } + } + catch + { + return null; + } + + byte[] encKeyBase = Encoding.UTF8.GetBytes("ENCRYPTION").Concat(new byte[] { 0x0, }).Concat(licenseRequest).Concat(new byte[] { 0x0, 0x0, 0x0, 0x80 }).ToArray(); + + byte[] encKey = new byte[] { 0x01 }.Concat(encKeyBase).ToArray(); + + byte[] encCmacKey = GetCmacDigest(encKey, sessionKey); + + byte[] encryptionKey = encCmacKey; + + List keys = new List(); + + foreach (License.KeyContainer key in signedLicense.Msg.Keys) + { + string type = key.Type.ToString(); + if (type == "Signing") + { + continue; + } + + byte[] keyId; + byte[] encryptedKey = key.Key; + byte[] iv = key.Iv; + keyId = key.Id; + if (keyId == null) + { + keyId = Encoding.ASCII.GetBytes(key.Type.ToString()); + } + + byte[] decryptedKey; + + using MemoryStream mstream = new MemoryStream(); + using AesCryptoServiceProvider aesProvider = new AesCryptoServiceProvider + { + Mode = CipherMode.CBC, + Padding = PaddingMode.PKCS7 + }; + using CryptoStream cryptoStream = new CryptoStream(mstream, aesProvider.CreateDecryptor(encryptionKey, iv), CryptoStreamMode.Write); + cryptoStream.Write(encryptedKey, 0, encryptedKey.Length); + decryptedKey = mstream.ToArray(); + + List permissions = new List(); + if (type == "OPERATOR_SESSION") + { + foreach (FieldInfo perm in key._OperatorSessionKeyPermissions.GetType().GetFields()) + { + if ((uint)perm.GetValue(key._OperatorSessionKeyPermissions) == 1) + { + permissions.Add(perm.Name); + } + } + } + keys.Add(BitConverter.ToString(keyId).Replace("-","").ToLower() + ":" + BitConverter.ToString(decryptedKey).Replace("-", "").ToLower()); + } + + return keys; + }*/ diff --git a/OF DL.Core/Widevine/CDMApi.cs b/OF DL.Core/Widevine/CDMApi.cs new file mode 100644 index 0000000..ba952dc --- /dev/null +++ b/OF DL.Core/Widevine/CDMApi.cs @@ -0,0 +1,41 @@ +using Serilog; + +namespace OF_DL.Widevine; + +public class CDMApi +{ + private string? SessionId { get; set; } + + public byte[]? GetChallenge(string initDataB64, string certDataB64, bool offline = false, bool raw = false) + { + SessionId = CDM.OpenSession(initDataB64, Constants.DEVICE_NAME, offline, raw); + if (SessionId == null) + { + Log.Debug("CDM.OpenSession returned null, unable to proceed with challenge generation"); + return null; + } + + CDM.SetServiceCertificate(SessionId, Convert.FromBase64String(certDataB64)); + return CDM.GetLicenseRequest(SessionId); + } + + public void ProvideLicense(string licenseB64) + { + if (SessionId == null) + { + throw new Exception("No session ID set. Could not provide license"); + } + + CDM.ProvideLicense(SessionId, Convert.FromBase64String(licenseB64)); + } + + public List GetKeys() + { + if (SessionId == null) + { + throw new Exception("No session ID set. Could not get keys"); + } + + return CDM.GetKeys(SessionId); + } +} diff --git a/OF DL.Core/Widevine/CDMDevice.cs b/OF DL.Core/Widevine/CDMDevice.cs new file mode 100644 index 0000000..c17f5eb --- /dev/null +++ b/OF DL.Core/Widevine/CDMDevice.cs @@ -0,0 +1,96 @@ +using System.Text; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Encodings; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Signers; +using Org.BouncyCastle.OpenSsl; +using ProtoBuf; + +namespace OF_DL.Widevine; + +public class CDMDevice +{ + public CDMDevice(string deviceName, byte[]? clientIdBlobBytes = null, byte[]? privateKeyBytes = null, + byte[]? vmpBytes = null) + { + DeviceName = deviceName; + + string privateKeyPath = Path.Join(Constants.DEVICES_FOLDER, deviceName, "device_private_key"); + string vmpPath = Path.Join(Constants.DEVICES_FOLDER, deviceName, "device_vmp_blob"); + + if (clientIdBlobBytes == null) + { + string clientIDPath = Path.Join(Constants.DEVICES_FOLDER, deviceName, "device_client_id_blob"); + + if (!File.Exists(clientIDPath)) + { + throw new Exception("No client id blob found"); + } + + clientIdBlobBytes = File.ReadAllBytes(clientIDPath); + } + + ClientID = Serializer.Deserialize(new MemoryStream(clientIdBlobBytes)); + + if (privateKeyBytes != null) + { + using StringReader reader = new(Encoding.UTF8.GetString(privateKeyBytes)); + DeviceKeys = (AsymmetricCipherKeyPair)new PemReader(reader).ReadObject(); + } + else if (File.Exists(privateKeyPath)) + { + using StreamReader reader = File.OpenText(privateKeyPath); + DeviceKeys = (AsymmetricCipherKeyPair)new PemReader(reader).ReadObject(); + } + else + { + throw new Exception("No device private key found"); + } + + if (vmpBytes != null) + { + FileHashes? vmp = Serializer.Deserialize(new MemoryStream(vmpBytes)); + ClientID.FileHashes = vmp; + } + else if (File.Exists(vmpPath)) + { + FileHashes? vmp = Serializer.Deserialize(new MemoryStream(File.ReadAllBytes(vmpPath))); + ClientID.FileHashes = vmp; + } + } + + public string DeviceName { get; set; } + public ClientIdentification ClientID { get; set; } + private AsymmetricCipherKeyPair DeviceKeys { get; } + + public virtual bool IsAndroid { get; set; } = true; + + public virtual byte[] Decrypt(byte[] data) + { + OaepEncoding eng = new(new RsaEngine()); + eng.Init(false, DeviceKeys.Private); + + int length = data.Length; + int blockSize = eng.GetInputBlockSize(); + + List plainText = new(); + + for (int chunkPosition = 0; chunkPosition < length; chunkPosition += blockSize) + { + int chunkSize = Math.Min(blockSize, length - chunkPosition); + plainText.AddRange(eng.ProcessBlock(data, chunkPosition, chunkSize)); + } + + return plainText.ToArray(); + } + + public virtual byte[] Sign(byte[] data) + { + PssSigner eng = new(new RsaEngine(), new Sha1Digest()); + + eng.Init(true, DeviceKeys.Private); + eng.BlockUpdate(data, 0, data.Length); + return eng.GenerateSignature(); + } +} diff --git a/OF DL.Core/Widevine/Constants.cs b/OF DL.Core/Widevine/Constants.cs new file mode 100644 index 0000000..280dcaa --- /dev/null +++ b/OF DL.Core/Widevine/Constants.cs @@ -0,0 +1,10 @@ +namespace OF_DL.Widevine; + +public class Constants +{ + public static string WORKING_FOLDER { get; set; } = + Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "cdm")); + + public static string DEVICES_FOLDER { get; set; } = Path.GetFullPath(Path.Join(WORKING_FOLDER, "devices")); + public static string DEVICE_NAME { get; set; } = "chrome_1610"; +} diff --git a/OF DL.Core/Widevine/ContentKey.cs b/OF DL.Core/Widevine/ContentKey.cs new file mode 100644 index 0000000..2487f56 --- /dev/null +++ b/OF DL.Core/Widevine/ContentKey.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace OF_DL.Widevine; + +[Serializable] +public class ContentKey +{ + [JsonPropertyName("key_id")] public byte[] KeyID { get; set; } = []; + + [JsonPropertyName("type")] public string Type { get; set; } = ""; + + [JsonPropertyName("bytes")] public byte[] Bytes { get; set; } = []; + + [NotMapped] + [JsonPropertyName("permissions")] + public List Permissions + { + get => PermissionsString.Split(",").ToList(); + set => PermissionsString = string.Join(",", value); + } + + [JsonIgnore] public string PermissionsString { get; set; } = ""; + + public override string ToString() => + $"{BitConverter.ToString(KeyID).Replace("-", "").ToLower()}:{BitConverter.ToString(Bytes).Replace("-", "").ToLower()}"; +} diff --git a/OF DL.Core/Widevine/DerivedKeys.cs b/OF DL.Core/Widevine/DerivedKeys.cs new file mode 100644 index 0000000..2ca9d1d --- /dev/null +++ b/OF DL.Core/Widevine/DerivedKeys.cs @@ -0,0 +1,8 @@ +namespace OF_DL.Widevine; + +public class DerivedKeys +{ + public byte[] Auth1 { get; set; } = []; + public byte[] Auth2 { get; set; } = []; + public byte[] Enc { get; set; } = []; +} diff --git a/OF DL.Core/Widevine/PsshBox.cs b/OF DL.Core/Widevine/PsshBox.cs new file mode 100644 index 0000000..1291f0a --- /dev/null +++ b/OF DL.Core/Widevine/PsshBox.cs @@ -0,0 +1,68 @@ +namespace OF_DL.Widevine; + +internal class PsshBox +{ + private static readonly byte[] s_psshHeader = [0x70, 0x73, 0x73, 0x68]; + + private PsshBox(List kids, byte[] data) + { + KIDs = kids; + Data = data; + } + + public List KIDs { get; set; } + public byte[] Data { get; set; } + + public static PsshBox FromByteArray(byte[] psshbox) + { + using MemoryStream stream = new(psshbox); + + stream.Seek(4, SeekOrigin.Current); + byte[] header = new byte[4]; + stream.ReadExactly(header, 0, 4); + + if (!header.SequenceEqual(s_psshHeader)) + { + throw new Exception("Not a pssh box"); + } + + stream.Seek(20, SeekOrigin.Current); + byte[] kidCountBytes = new byte[4]; + stream.ReadExactly(kidCountBytes, 0, 4); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(kidCountBytes); + } + + uint kidCount = BitConverter.ToUInt32(kidCountBytes); + + List kids = new(); + for (int i = 0; i < kidCount; i++) + { + byte[] kid = new byte[16]; + stream.ReadExactly(kid); + kids.Add(kid); + } + + byte[] dataLengthBytes = new byte[4]; + stream.ReadExactly(dataLengthBytes); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(dataLengthBytes); + } + + uint dataLength = BitConverter.ToUInt32(dataLengthBytes); + + if (dataLength == 0) + { + return new PsshBox(kids, []); + } + + byte[] data = new byte[dataLength]; + stream.ReadExactly(data); + + return new PsshBox(kids, data); + } +} diff --git a/OF DL.Core/Widevine/Session.cs b/OF DL.Core/Widevine/Session.cs new file mode 100644 index 0000000..d879aa5 --- /dev/null +++ b/OF DL.Core/Widevine/Session.cs @@ -0,0 +1,16 @@ +namespace OF_DL.Widevine; + +internal class Session(byte[] sessionId, dynamic initData, CDMDevice device, bool offline) +{ + public byte[] SessionId { get; set; } = sessionId; + public dynamic InitData { get; set; } = initData; + public bool Offline { get; set; } = offline; + public CDMDevice Device { get; set; } = device; + public byte[] SessionKey { get; set; } = []; + public DerivedKeys DerivedKeys { get; set; } = new(); + public byte[] LicenseRequest { get; set; } = []; + public SignedLicense License { get; set; } = new(); + public SignedDeviceCertificate ServiceCertificate { get; set; } = new(); + public bool PrivacyMode { get; set; } + public List ContentKeys { get; set; } = []; +} diff --git a/OF DL/Widevine/WvProto2.cs b/OF DL.Core/Widevine/WvProto2.cs similarity index 99% rename from OF DL/Widevine/WvProto2.cs rename to OF DL.Core/Widevine/WvProto2.cs index 2a5c0b4..452430d 100644 --- a/OF DL/Widevine/WvProto2.cs +++ b/OF DL.Core/Widevine/WvProto2.cs @@ -1,4 +1,4 @@ -// +// // This file was generated by a tool; you should avoid making direct changes. // Consider using 'partial classes' to extend these types // Input: my.proto diff --git a/OF DL.Tests/Models/Mappers/ArchivedMapperTests.cs b/OF DL.Tests/Models/Mappers/ArchivedMapperTests.cs new file mode 100644 index 0000000..ea32519 --- /dev/null +++ b/OF DL.Tests/Models/Mappers/ArchivedMapperTests.cs @@ -0,0 +1,80 @@ +using OF_DL.Models.Dtos.Archived; +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Entities.Archived; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class ArchivedMapperTests +{ + [Fact] + public void FromDto_ReturnsDefaults_WhenDtoNull() + { + Archived result = ArchivedMapper.FromDto(null); + + Assert.False(result.HasMore); + Assert.Null(result.TailMarker); + Assert.Empty(result.List); + } + + [Fact] + public void FromDto_MapsListItems() + { + DateTime postedAt = new(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc); + + ArchivedDto dto = new() + { + HasMore = true, + TailMarker = "tail", + List = + [ + new ListItemDto + { + Id = 123, + PostedAt = postedAt, + Author = new AuthorDto { Id = 7 }, + Text = "hello", + Price = "9.99", + IsOpened = true, + IsArchived = true, + Preview = ["preview"], + Media = + [ + new MediumDto + { + Id = 456, + Type = "photo", + CanView = true, + Files = new FilesDto { Full = new FullDto { Url = "https://example.com/full.jpg" } } + } + ] + } + ] + }; + + Archived result = ArchivedMapper.FromDto(dto); + + Assert.True(result.HasMore); + Assert.Equal("tail", result.TailMarker); + Assert.Single(result.List); + + ListItem item = result.List[0]; + Assert.Equal(123, item.Id); + Assert.Equal(postedAt, item.PostedAt); + Assert.NotNull(item.Author); + Assert.Equal(7, item.Author.Id); + Assert.Equal("hello", item.Text); + Assert.Equal("9.99", item.Price); + Assert.True(item.IsOpened); + Assert.True(item.IsArchived); + Assert.NotNull(item.Media); + Assert.Single(item.Media); + + Medium media = item.Media[0]; + Assert.Equal(456, media.Id); + Assert.Equal("photo", media.Type); + Assert.NotNull(media.Files); + Assert.NotNull(media.Files.Full); + Assert.Equal("https://example.com/full.jpg", media.Files.Full.Url); + } +} diff --git a/OF DL.Tests/Models/Mappers/CommonMapperTests.cs b/OF DL.Tests/Models/Mappers/CommonMapperTests.cs new file mode 100644 index 0000000..f3e9da0 --- /dev/null +++ b/OF DL.Tests/Models/Mappers/CommonMapperTests.cs @@ -0,0 +1,173 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Entities.Common; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class CommonMapperTests +{ + [Fact] + public void MapAuthor_ReturnsNull_WhenDtoNull() => Assert.Null(CommonMapper.MapAuthor(null)); + + [Fact] + public void MapAuthor_ReturnsNull_WhenIdZero() + { + AuthorDto dto = new() { Id = 0 }; + + Assert.Null(CommonMapper.MapAuthor(dto)); + } + + [Fact] + public void MapAuthor_MapsId_WhenValid() + { + AuthorDto dto = new() { Id = 42 }; + + Author? result = CommonMapper.MapAuthor(dto); + + Assert.NotNull(result); + Assert.Equal(42, result.Id); + } + + [Fact] + public void MapFull_ReturnsNull_WhenUrlEmpty() + { + FullDto dto = new() { Url = "" }; + + Assert.Null(CommonMapper.MapFull(dto)); + } + + [Fact] + public void MapFull_MapsUrl_WhenPresent() + { + FullDto dto = new() { Url = "https://example.com/full.jpg" }; + + Full? result = CommonMapper.MapFull(dto); + + Assert.NotNull(result); + Assert.Equal(dto.Url, result.Url); + } + + [Fact] + public void MapPreview_ReturnsNull_WhenUrlEmpty() + { + PreviewDto dto = new() { Url = "" }; + + Assert.Null(CommonMapper.MapPreview(dto)); + } + + [Fact] + public void MapPreview_MapsUrl_WhenPresent() + { + PreviewDto dto = new() { Url = "https://example.com/preview.jpg" }; + + Preview? result = CommonMapper.MapPreview(dto); + + Assert.NotNull(result); + Assert.Equal(dto.Url, result.Url); + } + + [Fact] + public void MapManifest_ReturnsNull_WhenDashEmpty() + { + ManifestDto dto = new() { Dash = "" }; + + Assert.Null(CommonMapper.MapManifest(dto)); + } + + [Fact] + public void MapManifest_MapsDash_WhenPresent() + { + ManifestDto dto = new() { Dash = "dash.mpd" }; + + Manifest? result = CommonMapper.MapManifest(dto); + + Assert.NotNull(result); + Assert.Equal(dto.Dash, result.Dash); + } + + [Fact] + public void MapSignature_ReturnsNull_WhenDashEmpty() + { + SignatureDto dto = new() { Dash = new DashDto() }; + + Assert.Null(CommonMapper.MapSignature(dto)); + } + + [Fact] + public void MapSignature_MapsDash_WhenAnyDashFieldPresent() + { + SignatureDto dto = new() { Dash = new DashDto { CloudFrontPolicy = "policy" } }; + + Signature? result = CommonMapper.MapSignature(dto); + + Assert.NotNull(result); + Assert.NotNull(result.Dash); + Assert.Equal("policy", result.Dash.CloudFrontPolicy); + } + + [Fact] + public void MapDrm_ReturnsNull_WhenDtoNull() => Assert.Null(CommonMapper.MapDrm(null)); + + [Fact] + public void MapDrm_MapsManifestAndSignature_WhenPresent() + { + DrmDto dto = new() + { + Manifest = new ManifestDto { Dash = "dash.mpd" }, + Signature = new SignatureDto { Dash = new DashDto { CloudFrontSignature = "signature" } } + }; + + Drm? result = CommonMapper.MapDrm(dto); + + Assert.NotNull(result); + Assert.NotNull(result.Manifest); + Assert.NotNull(result.Signature); + Assert.Equal("dash.mpd", result.Manifest.Dash); + Assert.Equal("signature", result.Signature.Dash?.CloudFrontSignature); + } + + [Fact] + public void MapFiles_ReturnsNull_WhenAllPartsNull() + { + FilesDto dto = new() { Full = { Url = "" }, Preview = { Url = "" }, Drm = null }; + + Assert.Null(CommonMapper.MapFiles(dto)); + } + + [Fact] + public void MapFiles_MapsParts_WhenPresent() + { + FilesDto dto = new() + { + Full = { Url = "https://example.com/full.jpg" }, Preview = { Url = "https://example.com/preview.jpg" } + }; + + Files? result = CommonMapper.MapFiles(dto); + + Assert.NotNull(result); + Assert.NotNull(result.Full); + Assert.NotNull(result.Preview); + Assert.Equal(dto.Full.Url, result.Full.Url); + Assert.Equal(dto.Preview.Url, result.Preview.Url); + } + + [Fact] + public void MapVideoSources_ReturnsNull_WhenAllEmpty() + { + VideoSourcesDto dto = new() { _240 = "", _720 = "" }; + + Assert.Null(CommonMapper.MapVideoSources(dto)); + } + + [Fact] + public void MapVideoSources_MapsFields_WhenPresent() + { + VideoSourcesDto dto = new() { _240 = "240.mp4", _720 = "720.mp4" }; + + VideoSources? result = CommonMapper.MapVideoSources(dto); + + Assert.NotNull(result); + Assert.Equal("240.mp4", result._240); + Assert.Equal("720.mp4", result._720); + } +} diff --git a/OF DL.Tests/Models/Mappers/HighlightsMapperTests.cs b/OF DL.Tests/Models/Mappers/HighlightsMapperTests.cs new file mode 100644 index 0000000..2b577b9 --- /dev/null +++ b/OF DL.Tests/Models/Mappers/HighlightsMapperTests.cs @@ -0,0 +1,75 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Highlights; +using OF_DL.Models.Entities.Highlights; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class HighlightsMapperTests +{ + [Fact] + public void FromDto_ReturnsDefaults_WhenDtoNull() + { + Highlights result = HighlightsMapper.FromDto((HighlightsDto?)null); + + Assert.False(result.HasMore); + Assert.Empty(result.List); + } + + [Fact] + public void FromDto_MapsListItems() + { + HighlightsDto dto = new() { HasMore = true, List = [new ListItemDto { Id = 99 }] }; + + Highlights result = HighlightsMapper.FromDto(dto); + + Assert.True(result.HasMore); + Assert.Single(result.List); + Assert.Equal(99, result.List[0].Id); + } + + [Fact] + public void FromDto_MapsHighlightMediaStories() + { + DateTime createdAt = new(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc); + + HighlightMediaDto dto = new() + { + Stories = + [ + new StoryDto + { + Id = 5, + CreatedAt = createdAt, + Media = + [ + new MediumDto + { + Id = 8, + Type = "video", + CanView = true, + CreatedAt = createdAt, + Files = new FilesDto { Full = new FullDto { Url = "https://example.com/full.mp4" } } + } + ] + } + ] + }; + + HighlightMedia result = HighlightsMapper.FromDto(dto); + + Assert.Single(result.Stories); + + Story story = result.Stories[0]; + Assert.Equal(5, story.Id); + Assert.Equal(createdAt, story.CreatedAt); + Assert.NotNull(story.Media); + Assert.Single(story.Media); + + Medium media = story.Media[0]; + Assert.Equal(8, media.Id); + Assert.NotNull(media.Files); + Assert.NotNull(media.Files.Full); + Assert.Equal("https://example.com/full.mp4", media.Files.Full.Url); + } +} diff --git a/OF DL.Tests/Models/Mappers/MessagesMapperTests.cs b/OF DL.Tests/Models/Mappers/MessagesMapperTests.cs new file mode 100644 index 0000000..456cb4c --- /dev/null +++ b/OF DL.Tests/Models/Mappers/MessagesMapperTests.cs @@ -0,0 +1,113 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Messages; +using OF_DL.Models.Entities.Messages; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class MessagesMapperTests +{ + [Fact] + public void FromDto_ReturnsDefaults_WhenDtoNull() + { + Messages result = MessagesMapper.FromDto(null as MessagesDto); + + Assert.False(result.HasMore); + Assert.Empty(result.List); + } + + [Fact] + public void FromDto_MapsListItems() + { + DateTime createdAt = new(2024, 3, 4, 5, 6, 7, DateTimeKind.Utc); + + MessagesDto dto = new() + { + HasMore = true, + List = + [ + new ListItemDto + { + Id = 11, + Text = "message", + Price = "4.99", + CreatedAt = createdAt, + FromUser = new FromUserDto { Id = 77 }, + Media = + [ + new MediumDto + { + Id = 22, + Type = "photo", + CanView = true, + Files = new FilesDto { Full = new FullDto { Url = "https://example.com/full.jpg" } } + } + ] + } + ] + }; + + Messages result = MessagesMapper.FromDto(dto); + + Assert.True(result.HasMore); + Assert.Single(result.List); + + ListItem item = result.List[0]; + Assert.Equal(11, item.Id); + Assert.Equal("message", item.Text); + Assert.Equal("4.99", item.Price); + Assert.Equal(createdAt, item.CreatedAt); + Assert.NotNull(item.FromUser); + Assert.Equal(77, item.FromUser.Id); + Assert.NotNull(item.Media); + Assert.Single(item.Media); + Assert.Equal(22, item.Media[0].Id); + } + + [Fact] + public void FromDto_MapsSingleMessage() + { + DateTime createdAt = new(2024, 4, 5, 6, 7, 8, DateTimeKind.Utc); + + SingleMessageDto dto = new() + { + Id = 99, + Text = "single", + Price = 1.23, + CreatedAt = createdAt, + FromUser = new FromUserDto { Id = 55 }, + Media = + [ + new MediumDto + { + Id = 33, + Type = "video", + CanView = true, + Files = new FilesDto { Full = new FullDto { Url = "https://example.com/full.mp4" } } + } + ] + }; + + SingleMessage result = MessagesMapper.FromDto(dto); + + Assert.Equal(99, result.Id); + Assert.Equal("single", result.Text); + Assert.Equal(1.23, result.Price); + Assert.Equal(createdAt, result.CreatedAt); + Assert.NotNull(result.FromUser); + Assert.Equal(55, result.FromUser.Id); + Assert.NotNull(result.Media); + Assert.Single(result.Media); + Assert.Equal(33, result.Media[0].Id); + } + + [Fact] + public void FromDto_SingleMessage_ReturnsNullFromUser_WhenIdMissing() + { + SingleMessageDto dto = new() { Id = 1, FromUser = new FromUserDto { Id = null } }; + + SingleMessage result = MessagesMapper.FromDto(dto); + + Assert.Null(result.FromUser); + } +} diff --git a/OF DL.Tests/Models/Mappers/PostMapperTests.cs b/OF DL.Tests/Models/Mappers/PostMapperTests.cs new file mode 100644 index 0000000..0c2a941 --- /dev/null +++ b/OF DL.Tests/Models/Mappers/PostMapperTests.cs @@ -0,0 +1,136 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Posts; +using OF_DL.Models.Entities.Posts; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class PostMapperTests +{ + [Fact] + public void PostFromDto_ReturnsDefaults_WhenDtoNull() + { + Post result = PostMapper.FromDto((PostDto?)null); + + Assert.False(result.HasMore); + Assert.Null(result.TailMarker); + Assert.Empty(result.List); + } + + [Fact] + public void FromDto_MapsListItems() + { + DateTime postedAt = new(2024, 5, 6, 7, 8, 9, DateTimeKind.Utc); + + PostDto dto = new() + { + HasMore = true, + TailMarker = "tail", + List = + [ + new ListItemDto + { + Id = 10, + PostedAt = postedAt, + Author = new AuthorDto { Id = 3 }, + Text = "post", + RawText = "post", + IsOpened = true, + Price = "2.50", + IsArchived = true, + Media = + [ + new MediumDto + { + Id = 20, + Type = "photo", + CanView = true, + Preview = "preview", + Files = new FilesDto + { + Full = new FullDto { Url = "https://example.com/full.jpg" }, + Preview = new PreviewDto { Url = "https://example.com/preview.jpg" } + } + } + ] + } + ] + }; + + Post result = PostMapper.FromDto(dto); + + Assert.True(result.HasMore); + Assert.Equal("tail", result.TailMarker); + Assert.Single(result.List); + + ListItem item = result.List[0]; + Assert.Equal(10, item.Id); + Assert.Equal(postedAt, item.PostedAt); + Assert.NotNull(item.Author); + Assert.Equal(3, item.Author.Id); + Assert.Equal("post", item.Text); + Assert.True(item.IsOpened); + Assert.NotNull(item.Media); + Assert.Single(item.Media); + + Medium media = item.Media[0]; + Assert.Equal(20, media.Id); + Assert.Equal("photo", media.Type); + Assert.NotNull(media.Files); + Assert.NotNull(media.Files.Full); + Assert.Equal("https://example.com/full.jpg", media.Files.Full.Url); + Assert.NotNull(media.Files.Preview); + Assert.Equal("https://example.com/preview.jpg", media.Files.Preview.Url); + } + + [Fact] + public void FromDto_MapsSinglePost() + { + DateTime postedAt = new(2024, 6, 7, 8, 9, 10, DateTimeKind.Utc); + + SinglePostDto dto = new() + { + Id = 77, + PostedAt = postedAt, + Author = new AuthorDto { Id = 8 }, + Text = "single", + RawText = "single", + IsOpened = true, + Price = "4.00", + IsArchived = false, + Preview = ["preview"], + Media = + [ + new MediumDto + { + Id = 88, + Type = "video", + CanView = true, + Preview = "preview", + Files = new FilesDto { Full = new FullDto { Url = "https://example.com/full.mp4" } }, + VideoSources = new VideoSourcesDto { _240 = "240.mp4", _720 = "720.mp4" } + } + ] + }; + + SinglePost result = PostMapper.FromDto(dto); + + Assert.Equal(77, result.Id); + Assert.Equal(postedAt, result.PostedAt); + Assert.NotNull(result.Author); + Assert.Equal(8, result.Author.Id); + Assert.Equal("single", result.Text); + Assert.True(result.IsOpened); + Assert.NotNull(result.Media); + Assert.Single(result.Media); + + Medium media = result.Media[0]; + Assert.Equal(88, media.Id); + Assert.NotNull(media.Files); + Assert.NotNull(media.Files.Full); + Assert.Equal("https://example.com/full.mp4", media.Files.Full.Url); + Assert.NotNull(media.VideoSources); + Assert.Equal("240.mp4", media.VideoSources._240); + Assert.Equal("720.mp4", media.VideoSources._720); + } +} diff --git a/OF DL.Tests/Models/Mappers/PurchasedMapperTests.cs b/OF DL.Tests/Models/Mappers/PurchasedMapperTests.cs new file mode 100644 index 0000000..e58f4b9 --- /dev/null +++ b/OF DL.Tests/Models/Mappers/PurchasedMapperTests.cs @@ -0,0 +1,77 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Messages; +using OF_DL.Models.Dtos.Purchased; +using OF_DL.Models.Entities.Purchased; +using OF_DL.Models.Mappers; +using FromUserDto = OF_DL.Models.Dtos.Purchased.FromUserDto; +using ListItemDto = OF_DL.Models.Dtos.Purchased.ListItemDto; + +namespace OF_DL.Tests.Models.Mappers; + +public class PurchasedMapperTests +{ + [Fact] + public void FromDto_ReturnsDefaults_WhenDtoNull() + { + Purchased result = PurchasedMapper.FromDto(null); + + Assert.False(result.HasMore); + Assert.Empty(result.List); + } + + [Fact] + public void FromDto_MapsListItems() + { + DateTime createdAt = new(2024, 7, 8, 9, 10, 11, DateTimeKind.Utc); + + PurchasedDto dto = new() + { + HasMore = true, + List = + [ + new ListItemDto + { + Id = 111, + Text = "purchased", + Price = "12.34", + IsOpened = true, + IsArchived = true, + CreatedAt = createdAt, + PostedAt = createdAt, + FromUser = new FromUserDto { Id = 0 }, + Author = new AuthorDto { Id = 5 }, + Media = + [ + new MediumDto + { + Id = 222, + Type = "video", + CanView = true, + Files = new FilesDto { Full = new FullDto { Url = "https://example.com/full.mp4" } } + } + ] + } + ] + }; + + Purchased result = PurchasedMapper.FromDto(dto); + + Assert.True(result.HasMore); + Assert.Single(result.List); + + ListItem item = result.List[0]; + Assert.Equal(111, item.Id); + Assert.Equal("purchased", item.Text); + Assert.Equal("12.34", item.Price); + Assert.True(item.IsOpened); + Assert.True(item.IsArchived); + Assert.Equal(createdAt, item.CreatedAt); + Assert.Equal(createdAt, item.PostedAt); + Assert.NotNull(item.Author); + Assert.Equal(5, item.Author.Id); + Assert.Null(item.FromUser); + Assert.NotNull(item.Media); + Assert.Single(item.Media); + Assert.Equal(222, item.Media[0].Id); + } +} diff --git a/OF DL.Tests/Models/Mappers/StoriesMapperTests.cs b/OF DL.Tests/Models/Mappers/StoriesMapperTests.cs new file mode 100644 index 0000000..ecbfe76 --- /dev/null +++ b/OF DL.Tests/Models/Mappers/StoriesMapperTests.cs @@ -0,0 +1,58 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Stories; +using OF_DL.Models.Entities.Stories; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class StoriesMapperTests +{ + [Fact] + public void FromDto_ReturnsEmptyList_WhenDtoNull() + { + List result = StoriesMapper.FromDto(null); + + Assert.Empty(result); + } + + [Fact] + public void FromDto_MapsStories() + { + DateTime createdAt = new(2024, 9, 10, 11, 12, 13, DateTimeKind.Utc); + + List dto = + [ + new() + { + Id = 12, + CreatedAt = createdAt, + Media = + [ + new MediumDto + { + Id = 34, + Type = "photo", + CanView = true, + CreatedAt = createdAt, + Files = new FilesDto { Full = new FullDto { Url = "https://example.com/full.jpg" } } + } + ] + } + ]; + + List result = StoriesMapper.FromDto(dto); + + Assert.Single(result); + + Stories story = result[0]; + Assert.Equal(12, story.Id); + Assert.Equal(createdAt, story.CreatedAt); + Assert.Single(story.Media); + + Medium media = story.Media[0]; + Assert.Equal(34, media.Id); + Assert.NotNull(media.Files); + Assert.NotNull(media.Files.Full); + Assert.Equal("https://example.com/full.jpg", media.Files.Full.Url); + } +} diff --git a/OF DL.Tests/Models/Mappers/StreamsMapperTests.cs b/OF DL.Tests/Models/Mappers/StreamsMapperTests.cs new file mode 100644 index 0000000..d2434c3 --- /dev/null +++ b/OF DL.Tests/Models/Mappers/StreamsMapperTests.cs @@ -0,0 +1,91 @@ +using OF_DL.Models.Dtos.Common; +using OF_DL.Models.Dtos.Streams; +using OF_DL.Models.Entities.Streams; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class StreamsMapperTests +{ + [Fact] + public void FromDto_ReturnsDefaults_WhenDtoNull() + { + Streams result = StreamsMapper.FromDto(null); + + Assert.False(result.HasMore); + Assert.Null(result.TailMarker); + Assert.Empty(result.List); + } + + [Fact] + public void FromDto_MapsListItems() + { + DateTime postedAt = new(2024, 8, 9, 10, 11, 12, DateTimeKind.Utc); + + StreamsDto dto = new() + { + HasMore = true, + TailMarker = "tail", + List = + [ + new ListItemDto + { + Id = 333, + PostedAt = postedAt, + Author = new AuthorDto { Id = 9 }, + Text = "stream", + RawText = "stream", + Price = "1.00", + IsOpened = null, + IsArchived = null, + Media = + [ + new MediumDto + { + Id = 444, + Type = "video", + CanView = true, + Files = new FilesDto + { + Full = new FullDto { Url = "https://example.com/full.mp4" }, + Drm = new DrmDto + { + Manifest = new ManifestDto { Dash = "dash.mpd" }, + Signature = new SignatureDto + { + Dash = new DashDto { CloudFrontPolicy = "policy" } + } + } + } + } + ] + } + ] + }; + + Streams result = StreamsMapper.FromDto(dto); + + Assert.True(result.HasMore); + Assert.Equal("tail", result.TailMarker); + Assert.Single(result.List); + + ListItem item = result.List[0]; + Assert.Equal(333, item.Id); + Assert.Equal(postedAt, item.PostedAt); + Assert.NotNull(item.Author); + Assert.Equal(9, item.Author.Id); + Assert.Equal("stream", item.Text); + Assert.False(item.IsOpened); + Assert.False(item.IsArchived); + Assert.NotNull(item.Media); + Assert.Single(item.Media); + + Medium media = item.Media[0]; + Assert.Equal(444, media.Id); + Assert.NotNull(media.Files); + Assert.NotNull(media.Files.Full); + Assert.Equal("https://example.com/full.mp4", media.Files.Full.Url); + Assert.NotNull(media.Files.Drm?.Manifest); + Assert.Equal("dash.mpd", media.Files.Drm.Manifest.Dash); + } +} diff --git a/OF DL.Tests/Models/Mappers/SubscriptionsMapperTests.cs b/OF DL.Tests/Models/Mappers/SubscriptionsMapperTests.cs new file mode 100644 index 0000000..cf4527f --- /dev/null +++ b/OF DL.Tests/Models/Mappers/SubscriptionsMapperTests.cs @@ -0,0 +1,34 @@ +using OF_DL.Models.Dtos.Subscriptions; +using OF_DL.Models.Entities.Subscriptions; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class SubscriptionsMapperTests +{ + [Fact] + public void FromDto_ReturnsDefaults_WhenDtoNull() + { + Subscriptions result = SubscriptionsMapper.FromDto(null); + + Assert.False(result.HasMore); + Assert.Empty(result.List); + } + + [Fact] + public void FromDto_MapsListItems() + { + SubscriptionsDto dto = new() + { + HasMore = true, List = [new ListItemDto { Id = 55, Username = null, IsRestricted = true }] + }; + + Subscriptions result = SubscriptionsMapper.FromDto(dto); + + Assert.True(result.HasMore); + Assert.Single(result.List); + Assert.Equal(55, result.List[0].Id); + Assert.Equal(string.Empty, result.List[0].Username); + Assert.True(result.List[0].IsRestricted); + } +} diff --git a/OF DL.Tests/Models/Mappers/UserListsMapperTests.cs b/OF DL.Tests/Models/Mappers/UserListsMapperTests.cs new file mode 100644 index 0000000..da08044 --- /dev/null +++ b/OF DL.Tests/Models/Mappers/UserListsMapperTests.cs @@ -0,0 +1,49 @@ +using OF_DL.Models.Dtos.Lists; +using OF_DL.Models.Entities.Lists; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class UserListsMapperTests +{ + [Fact] + public void FromDto_ReturnsDefaults_WhenDtoNull() + { + UserList result = UserListsMapper.FromDto(null as UserListDto); + + Assert.False(result.HasMore); + Assert.Empty(result.List); + } + + [Fact] + public void FromDto_MapsUserListItems() + { + UserListDto dto = new() { HasMore = true, List = [new UserListItemDto { Id = "1", Name = "Favorites" }] }; + + UserList result = UserListsMapper.FromDto(dto); + + Assert.True(result.HasMore); + Assert.Single(result.List); + Assert.Equal("1", result.List[0].Id); + Assert.Equal("Favorites", result.List[0].Name); + } + + [Fact] + public void FromDto_UsersList_ReturnsEmptyList_WhenDtoNull() + { + List result = UserListsMapper.FromDto((List?)null); + + Assert.Empty(result); + } + + [Fact] + public void FromDto_UsersList_MapsItems() + { + List dto = [new() { Username = "creator" }]; + + List result = UserListsMapper.FromDto(dto); + + Assert.Single(result); + Assert.Equal("creator", result[0].Username); + } +} diff --git a/OF DL.Tests/Models/Mappers/UserMapperTests.cs b/OF DL.Tests/Models/Mappers/UserMapperTests.cs new file mode 100644 index 0000000..75cd1c0 --- /dev/null +++ b/OF DL.Tests/Models/Mappers/UserMapperTests.cs @@ -0,0 +1,25 @@ +using OF_DL.Models.Dtos.Users; +using OF_DL.Models.Entities.Users; +using OF_DL.Models.Mappers; + +namespace OF_DL.Tests.Models.Mappers; + +public class UserMapperTests +{ + [Fact] + public void FromDto_ReturnsNull_WhenDtoNull() => Assert.Null(UserMapper.FromDto(null)); + + [Fact] + public void FromDto_MapsFields() + { + UserDto dto = new() { Avatar = "avatar", Header = "header", Name = "Name", Username = "user" }; + + User? result = UserMapper.FromDto(dto); + + Assert.NotNull(result); + Assert.Equal("avatar", result.Avatar); + Assert.Equal("header", result.Header); + Assert.Equal("Name", result.Name); + Assert.Equal("user", result.Username); + } +} diff --git a/OF DL.Tests/OF DL.Tests.csproj b/OF DL.Tests/OF DL.Tests.csproj new file mode 100644 index 0000000..1689e24 --- /dev/null +++ b/OF DL.Tests/OF DL.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + OF_DL.Tests + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/OF DL.Tests/Services/ApiServiceTests.cs b/OF DL.Tests/Services/ApiServiceTests.cs new file mode 100644 index 0000000..54b90dc --- /dev/null +++ b/OF DL.Tests/Services/ApiServiceTests.cs @@ -0,0 +1,523 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OF_DL.Enumerations; +using OF_DL.Models; +using OF_DL.Models.Config; +using OF_DL.Models.Entities.Common; +using OF_DL.Models.OfdlApi; +using OF_DL.Services; +using UserEntities = OF_DL.Models.Entities.Users; + +namespace OF_DL.Tests.Services; + +public class ApiServiceTests +{ + [Fact] + public void GetDynamicHeaders_ReturnsSignedHeaders() + { + FakeAuthService authService = new() + { + CurrentAuth = new Auth + { + UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_cookie=abc;" + } + }; + ApiService service = CreateService(authService); + DynamicRules rules = new() + { + AppToken = "app-token", + StaticParam = "static", + Prefix = "prefix", + Suffix = "suffix", + ChecksumConstant = 7, + ChecksumIndexes = [0, 5, 10, 15] + }; + + using DynamicRulesCacheScope _ = new(rules); + + Dictionary headers = service.GetDynamicHeaders("/api2/v2/users", "?limit=1"); + + Assert.Equal("application/json, text/plain", headers["accept"]); + Assert.Equal("app-token", headers["app-token"]); + Assert.Equal("auth_cookie=abc;", headers["cookie"]); + Assert.Equal("unit-test-agent", headers["user-agent"]); + Assert.Equal("xbc-token", headers["x-bc"]); + Assert.Equal("123", headers["user-id"]); + Assert.True(long.TryParse(headers["time"], out long timestamp)); + + string expectedSign = BuildSign(rules, timestamp, "/api2/v2/users?limit=1", "123"); + Assert.Equal(expectedSign, headers["sign"]); + } + + [Fact] + public void GetDynamicHeaders_ThrowsWhenRulesInvalid() + { + FakeAuthService authService = new() + { + CurrentAuth = new Auth + { + UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_cookie=abc;" + } + }; + ApiService service = CreateService(authService); + DynamicRules rules = new() + { + AppToken = null, + StaticParam = "static", + Prefix = null, + Suffix = "suffix", + ChecksumConstant = null, + ChecksumIndexes = [] + }; + + using DynamicRulesCacheScope _ = new(rules); + + Exception ex = Assert.Throws(() => service.GetDynamicHeaders("/api2/v2/users", "?limit=1")); + Assert.Contains("Invalid dynamic rules", ex.Message); + } + + [Fact] + public void GetDynamicHeaders_ThrowsWhenAuthMissingFields() + { + FakeAuthService authService = new() + { + CurrentAuth = new Auth + { + UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = null + } + }; + ApiService service = CreateService(authService); + DynamicRules rules = new() + { + AppToken = "app-token", + StaticParam = "static", + Prefix = "prefix", + Suffix = "suffix", + ChecksumConstant = 1, + ChecksumIndexes = [0] + }; + + using DynamicRulesCacheScope _ = new(rules); + + Exception ex = Assert.Throws(() => service.GetDynamicHeaders("/api2/v2/users", "?limit=1")); + Assert.Contains("Auth service is missing required fields", ex.Message); + } + + [Fact] + public async Task BuildHttpRequestMessage_BuildsUrlAndAddsHeaders() + { + FakeAuthService authService = new() + { + CurrentAuth = new Auth + { + UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_cookie=abc;" + } + }; + ApiService service = CreateService(authService); + DynamicRules rules = new() + { + AppToken = "app-token", + StaticParam = "static", + Prefix = "prefix", + Suffix = "suffix", + ChecksumConstant = 7, + ChecksumIndexes = [0] + }; + + using DynamicRulesCacheScope _ = new(rules); + + Dictionary getParams = new() { { "limit", "10" }, { "offset", "5" } }; + HttpRequestMessage request = await InvokeBuildHttpRequestMessage(service, getParams, "/users"); + + Assert.Equal("https://onlyfans.com/api2/v2/users?limit=10&offset=5", request.RequestUri?.ToString()); + Assert.True(request.Headers.Contains("app-token")); + Assert.True(request.Headers.Contains("sign")); + Assert.True(request.Headers.Contains("user-id")); + } + + [Fact] + public void DeserializeJson_ReturnsDefaultForWhitespace() + { + object? result = InvokeDeserializeJson(typeof(Dictionary), " ", null); + Assert.Null(result); + } + + [Fact] + public void DeserializeJson_ParsesValidJson() + { + object? result = InvokeDeserializeJson(typeof(Dictionary), "{\"a\":1}", null); + Assert.NotNull(result); + Dictionary dict = Assert.IsType>(result); + Assert.Equal(1, dict["a"]); + } + + [Fact] + public void UpdateGetParamsForDateSelection_BeforeAddsBeforePublishTime() + { + Dictionary getParams = new(); + + InvokeUpdateGetParamsForDateSelection(DownloadDateSelection.before, getParams, "123.000000"); + + Assert.Equal("123.000000", getParams["beforePublishTime"]); + } + + [Fact] + public void UpdateGetParamsForDateSelection_AfterAddsAfterPublishTimeAndOrder() + { + Dictionary getParams = new(); + + InvokeUpdateGetParamsForDateSelection(DownloadDateSelection.after, getParams, "456.000000"); + + Assert.Equal("publish_date_asc", getParams["order"]); + Assert.Equal("456.000000", getParams["afterPublishTime"]); + } + + [Theory] + [InlineData("photo", "Images")] + [InlineData("video", "Videos")] + [InlineData("gif", "Videos")] + [InlineData("audio", "Audios")] + [InlineData("unknown", null)] + public void ResolveMediaType_ReturnsExpectedValue(string input, string? expected) + { + string? result = InvokeResolveMediaType(input); + + Assert.Equal(expected, result); + } + + [Fact] + public void IsMediaTypeDownloadEnabled_RespectsConfigFlags() + { + Config config = new() { DownloadImages = false, DownloadVideos = false, DownloadAudios = false }; + ApiService service = new(new FakeAuthService(), new FakeConfigService(config), new MediaTrackingDbService()); + + Assert.False(InvokeIsMediaTypeDownloadEnabled(service, "photo")); + Assert.False(InvokeIsMediaTypeDownloadEnabled(service, "video")); + Assert.False(InvokeIsMediaTypeDownloadEnabled(service, "audio")); + Assert.True(InvokeIsMediaTypeDownloadEnabled(service, "other")); + } + + [Fact] + public void TryGetDrmInfo_ReturnsTrueWhenComplete() + { + Files files = new() + { + Drm = new Drm + { + Manifest = new Manifest { Dash = "dash" }, + Signature = new Signature + { + Dash = new Dash + { + CloudFrontPolicy = "policy", + CloudFrontSignature = "signature", + CloudFrontKeyPairId = "kvp" + } + } + } + }; + + bool result = InvokeTryGetDrmInfo(files, out string manifestDash, out string policy, + out string signature, out string kvp); + + Assert.True(result); + Assert.Equal("dash", manifestDash); + Assert.Equal("policy", policy); + Assert.Equal("signature", signature); + Assert.Equal("kvp", kvp); + } + + [Fact] + public void TryGetDrmInfo_ReturnsFalseWhenMissingFields() + { + Files files = new() + { + Drm = new Drm + { + Manifest = new Manifest { Dash = null }, + Signature = new Signature { Dash = new Dash { CloudFrontPolicy = "policy" } } + } + }; + + bool result = InvokeTryGetDrmInfo(files, out _, out _, out _, out _); + + Assert.False(result); + } + + [Fact] + public void GetCurrentUserIdOrDefault_ReturnsMinValueWhenMissingOrInvalid() + { + ApiService serviceMissing = + new(new FakeAuthService(), new FakeConfigService(new Config()), new MediaTrackingDbService()); + Assert.Equal(int.MinValue, InvokeGetCurrentUserIdOrDefault(serviceMissing)); + + FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "not-a-number" } }; + ApiService serviceInvalid = new(authService, new FakeConfigService(new Config()), new MediaTrackingDbService()); + Assert.Equal(int.MinValue, InvokeGetCurrentUserIdOrDefault(serviceInvalid)); + } + + [Fact] + public void GetCurrentUserIdOrDefault_ReturnsParsedUserId() + { + FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "42" } }; + ApiService service = new(authService, new FakeConfigService(new Config()), new MediaTrackingDbService()); + + Assert.Equal(42, InvokeGetCurrentUserIdOrDefault(service)); + } + + [Fact] + public void ConvertToUnixTimestampWithMicrosecondPrecision_ReturnsExpectedSeconds() + { + double epoch = InvokeConvertToUnixTimestampWithMicrosecondPrecision( + new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + double oneSecond = InvokeConvertToUnixTimestampWithMicrosecondPrecision( + new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc)); + + Assert.Equal(0, epoch, 6); + Assert.Equal(1, oneSecond, 6); + } + + [Fact] + public async Task GetDrmMpdPssh_ReturnsSecondPssh() + { + string mpd = """ + + + + + FIRST + SECOND + + + + """; + using SimpleHttpServer server = new(mpd); + FakeAuthService authService = new() + { + CurrentAuth = new Auth + { + UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_id=1; sess=2;" + } + }; + ApiService service = CreateService(authService); + + string pssh = await service.GetDrmMpdPssh(server.Url.ToString(), "policy", "signature", "kvp"); + await server.Completion; + + Assert.Equal("SECOND", pssh); + } + + [Fact] + public async Task GetDrmMpdLastModified_ReturnsLastModifiedHeader() + { + DateTime lastModifiedUtc = new(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc); + using SimpleHttpServer server = new("", lastModifiedUtc); + FakeAuthService authService = new() + { + CurrentAuth = new Auth + { + UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_id=1; sess=2;" + } + }; + ApiService service = CreateService(authService); + + DateTime result = + await service.GetDrmMpdLastModified(server.Url.ToString(), "policy", "signature", "kvp"); + await server.Completion; + + DateTime expectedLocal = lastModifiedUtc.ToLocalTime(); + Assert.True((result - expectedLocal).Duration() < TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task GetUserInfo_ReturnsNullWhenAuthMissing() + { + ApiService service = CreateService(new FakeAuthService()); + using DynamicRulesCacheScope _ = new(BuildTestRules()); + + UserEntities.User? user = await service.GetUserInfo("/users/me"); + + Assert.Null(user); + } + + [Fact] + public async Task GetUserInfoById_ReturnsNullWhenAuthMissing() + { + ApiService service = CreateService(new FakeAuthService()); + using DynamicRulesCacheScope _ = new(BuildTestRules()); + + JObject? user = await service.GetUserInfoById("/users/list?x[]=1"); + + Assert.Null(user); + } + + [Fact] + public async Task GetActiveSubscriptions_ReturnsNullWhenAuthMissing() + { + ApiService service = CreateService(new FakeAuthService()); + using DynamicRulesCacheScope _ = new(BuildTestRules()); + + Dictionary? result = await service.GetActiveSubscriptions("/subscriptions", false); + + Assert.Null(result); + } + + [Fact] + public async Task GetExpiredSubscriptions_ReturnsNullWhenAuthMissing() + { + ApiService service = CreateService(new FakeAuthService()); + using DynamicRulesCacheScope _ = new(BuildTestRules()); + + Dictionary? result = await service.GetExpiredSubscriptions("/subscriptions", false); + + Assert.Null(result); + } + + [Fact] + public async Task GetLists_ReturnsNullWhenAuthMissing() + { + ApiService service = CreateService(new FakeAuthService()); + using DynamicRulesCacheScope _ = new(BuildTestRules()); + + Dictionary? result = await service.GetLists("/lists"); + + Assert.Null(result); + } + + [Fact] + public async Task GetListUsers_ReturnsNullWhenAuthMissing() + { + ApiService service = CreateService(new FakeAuthService()); + using DynamicRulesCacheScope _ = new(BuildTestRules()); + + List? result = await service.GetListUsers("/lists/1/users"); + + Assert.Null(result); + } + + [Fact] + public async Task GetMedia_ReturnsNullWhenAuthMissing() + { + ApiService service = CreateService(new FakeAuthService()); + using DynamicRulesCacheScope _ = new(BuildTestRules()); + + Dictionary? result = await service.GetMedia(MediaType.Stories, "/users/1/stories", null, "/tmp"); + + Assert.Null(result); + } + + private static ApiService CreateService(FakeAuthService authService) => + new(authService, new FakeConfigService(new Config()), new MediaTrackingDbService()); + + private static DynamicRules BuildTestRules() => + new() + { + AppToken = "app-token", + StaticParam = "static", + Prefix = "prefix", + Suffix = "suffix", + ChecksumConstant = 7, + ChecksumIndexes = [0] + }; + + private static async Task InvokeBuildHttpRequestMessage(ApiService service, + Dictionary getParams, string endpoint) + { + MethodInfo method = typeof(ApiService).GetMethod("BuildHttpRequestMessage", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("BuildHttpRequestMessage not found."); + Task task = (Task)method.Invoke(service, + [getParams, endpoint])!; + return await task; + } + + private static object? InvokeDeserializeJson(Type type, string? body, JsonSerializerSettings? settings) + { + MethodInfo method = typeof(ApiService).GetMethod("DeserializeJson", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("DeserializeJson not found."); + MethodInfo generic = method.MakeGenericMethod(type); + return generic.Invoke(null, [body, settings]); + } + + private static void InvokeUpdateGetParamsForDateSelection(DownloadDateSelection selection, + Dictionary getParams, string? timestamp) + { + MethodInfo method = typeof(ApiService).GetMethod("UpdateGetParamsForDateSelection", + BindingFlags.NonPublic | BindingFlags.Static, null, + [ + typeof(DownloadDateSelection), typeof(Dictionary).MakeByRefType(), + typeof(string) + ], + null) + ?? throw new InvalidOperationException("UpdateGetParamsForDateSelection not found."); + object?[] args = { selection, getParams, timestamp }; + method.Invoke(null, args); + } + + private static string? InvokeResolveMediaType(string? type) + { + MethodInfo method = typeof(ApiService).GetMethod("ResolveMediaType", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("ResolveMediaType not found."); + return (string?)method.Invoke(null, new object?[] { type }); + } + + private static bool InvokeIsMediaTypeDownloadEnabled(ApiService service, string? type) + { + MethodInfo method = typeof(ApiService).GetMethod("IsMediaTypeDownloadEnabled", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("IsMediaTypeDownloadEnabled not found."); + return (bool)method.Invoke(service, [type])!; + } + + private static bool InvokeTryGetDrmInfo(Files files, out string manifestDash, out string cloudFrontPolicy, + out string cloudFrontSignature, out string cloudFrontKeyPairId) + { + MethodInfo method = typeof(ApiService).GetMethod("TryGetDrmInfo", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("TryGetDrmInfo not found."); + object?[] args = { files, null, null, null, null }; + bool result = (bool)method.Invoke(null, args)!; + manifestDash = (string)args[1]!; + cloudFrontPolicy = (string)args[2]!; + cloudFrontSignature = (string)args[3]!; + cloudFrontKeyPairId = (string)args[4]!; + return result; + } + + private static int InvokeGetCurrentUserIdOrDefault(ApiService service) + { + MethodInfo method = typeof(ApiService).GetMethod("GetCurrentUserIdOrDefault", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("GetCurrentUserIdOrDefault not found."); + return (int)method.Invoke(service, null)!; + } + + private static double InvokeConvertToUnixTimestampWithMicrosecondPrecision(DateTime dateTime) + { + MethodInfo method = typeof(ApiService).GetMethod("ConvertToUnixTimestampWithMicrosecondPrecision", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException( + "ConvertToUnixTimestampWithMicrosecondPrecision not found."); + return (double)method.Invoke(null, [dateTime])!; + } + + private static string BuildSign(DynamicRules rules, long timestamp, string pathWithQuery, string userId) + { + string input = $"{rules.StaticParam}\n{timestamp}\n{pathWithQuery}\n{userId}"; + byte[] hashBytes = SHA1.HashData(Encoding.UTF8.GetBytes(input)); + string hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + + Assert.NotNull(rules.ChecksumConstant); + + int checksum = rules.ChecksumIndexes.Aggregate(0, (current, index) => current + hashString[index]) + + rules.ChecksumConstant.Value; + string checksumHex = checksum.ToString("X").ToLowerInvariant(); + + return $"{rules.Prefix}:{hashString}:{checksumHex}:{rules.Suffix}"; + } +} diff --git a/OF DL.Tests/Services/AuthServiceTests.cs b/OF DL.Tests/Services/AuthServiceTests.cs new file mode 100644 index 0000000..b25a576 --- /dev/null +++ b/OF DL.Tests/Services/AuthServiceTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using OF_DL.Models; +using OF_DL.Services; + +namespace OF_DL.Tests.Services; + +[Collection("NonParallel")] +public class AuthServiceTests +{ + [Fact] + public async Task LoadFromFileAsync_ReturnsFalseWhenMissing() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + AuthService service = CreateService(); + + bool result = await service.LoadFromFileAsync(); + + Assert.False(result); + Assert.Null(service.CurrentAuth); + } + + [Fact] + public async Task SaveToFileAsync_WritesAuthFile() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + AuthService service = CreateService(); + service.CurrentAuth = new Auth + { + UserId = "123", + UserAgent = "agent", + XBc = "xbc", + Cookie = "auth_id=123; sess=abc;" + }; + + await service.SaveToFileAsync(); + + Assert.True(File.Exists("auth.json")); + string json = await File.ReadAllTextAsync("auth.json"); + Auth? saved = JsonConvert.DeserializeObject(json); + Assert.NotNull(saved); + Assert.Equal("123", saved.UserId); + Assert.Equal("agent", saved.UserAgent); + Assert.Equal("xbc", saved.XBc); + Assert.Equal("auth_id=123; sess=abc;", saved.Cookie); + } + + [Fact] + public void ValidateCookieString_NormalizesAndPersists() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + AuthService service = CreateService(); + service.CurrentAuth = new Auth + { + Cookie = "auth_id=123; other=1; sess=abc" + }; + + service.ValidateCookieString(); + + Assert.Equal("auth_id=123; sess=abc;", service.CurrentAuth.Cookie); + Assert.True(File.Exists("auth.json")); + string json = File.ReadAllText("auth.json"); + Auth? saved = JsonConvert.DeserializeObject(json); + Assert.NotNull(saved); + Assert.Equal("auth_id=123; sess=abc;", saved.Cookie); + } + + [Fact] + public void Logout_DeletesAuthAndChromeData() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + AuthService service = CreateService(); + Directory.CreateDirectory("chrome-data"); + File.WriteAllText("chrome-data/test.txt", "x"); + File.WriteAllText("auth.json", "{}"); + + service.Logout(); + + Assert.False(Directory.Exists("chrome-data")); + Assert.False(File.Exists("auth.json")); + } + + private static AuthService CreateService() => + new(new ServiceCollection().BuildServiceProvider()); +} diff --git a/OF DL.Tests/Services/ConfigServiceTests.cs b/OF DL.Tests/Services/ConfigServiceTests.cs new file mode 100644 index 0000000..7157ee9 --- /dev/null +++ b/OF DL.Tests/Services/ConfigServiceTests.cs @@ -0,0 +1,84 @@ +using OF_DL.Enumerations; +using OF_DL.Models.Config; +using OF_DL.Services; + +namespace OF_DL.Tests.Services; + +[Collection("NonParallel")] +public class ConfigServiceTests +{ + [Fact] + public async Task LoadConfigurationAsync_CreatesDefaultConfigWhenMissing() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + FakeLoggingService loggingService = new(); + ConfigService service = new(loggingService); + + bool result = await service.LoadConfigurationAsync([]); + + Assert.True(result); + Assert.True(File.Exists("config.conf")); + Assert.True(loggingService.UpdateCount > 0); + Assert.Equal(service.CurrentConfig.LoggingLevel, loggingService.LastLevel); + } + + [Fact] + public async Task LoadConfigurationAsync_OverridesNonInteractiveFromCli() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + FakeLoggingService loggingService = new(); + ConfigService service = new(loggingService); + await service.SaveConfigurationAsync(); + + bool result = await service.LoadConfigurationAsync(["--non-interactive"]); + + Assert.True(result); + Assert.True(service.IsCliNonInteractive); + Assert.True(service.CurrentConfig.NonInteractiveMode); + } + + [Fact] + public async Task LoadConfigurationAsync_ReturnsFalseWhenInvalidFilenameFormat() + { + 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("PaidPostFileNameFormat = \"\"", + "PaidPostFileNameFormat = \"invalid-format\""); + await File.WriteAllTextAsync("config.conf", hocon); + + bool result = await service.LoadConfigurationAsync([]); + + Assert.False(result); + } + + [Fact] + public void ApplyToggleableSelections_UpdatesConfigAndReturnsChange() + { + FakeLoggingService loggingService = new(); + ConfigService service = new(loggingService); + Config initialConfig = new() + { + DownloadPosts = true, + DownloadMessages = true, + DownloadPath = "/downloads", + LoggingLevel = LoggingLevel.Warning + }; + service.UpdateConfig(initialConfig); + + bool changed = service.ApplyToggleableSelections(["DownloadPosts"]); + + Assert.True(changed); + Assert.True(service.CurrentConfig.DownloadPosts); + Assert.False(service.CurrentConfig.DownloadMessages); + Assert.Equal("/downloads", service.CurrentConfig.DownloadPath); + Assert.Equal(LoggingLevel.Warning, loggingService.LastLevel); + } + +} diff --git a/OF DL.Tests/Services/DownloadOrchestrationServiceTests.cs b/OF DL.Tests/Services/DownloadOrchestrationServiceTests.cs new file mode 100644 index 0000000..9167b52 --- /dev/null +++ b/OF DL.Tests/Services/DownloadOrchestrationServiceTests.cs @@ -0,0 +1,302 @@ +using Newtonsoft.Json.Linq; +using OF_DL.Models.Config; +using OF_DL.Models.Downloads; +using OF_DL.Services; +using MessageEntities = OF_DL.Models.Entities.Messages; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; + +namespace OF_DL.Tests.Services; + +public class DownloadOrchestrationServiceTests +{ + [Fact] + public async Task GetAvailableUsersAsync_FiltersIgnoredUsers() + { + Config config = CreateConfig(c => + { + c.IncludeExpiredSubscriptions = true; + c.IgnoredUsersListName = "ignored"; + }); + FakeConfigService configService = new(config); + ConfigurableApiService apiService = new() + { + ActiveSubscriptionsHandler = (_, _) => + Task.FromResult?>(new Dictionary { { "alice", 1 } }), + ExpiredSubscriptionsHandler = (_, _) => + Task.FromResult?>(new Dictionary { { "bob", 2 } }), + ListsHandler = + _ => Task.FromResult?>(new Dictionary { { "ignored", 10 } }), + ListUsersHandler = _ => Task.FromResult?>(["alice"]) + }; + UserTrackingDbService dbService = new(); + DownloadOrchestrationService service = + new(apiService, configService, new OrchestrationDownloadServiceStub(), dbService); + + UserListResult result = await service.GetAvailableUsersAsync(); + + Assert.Single(result.Users); + Assert.True(result.Users.ContainsKey("bob")); + Assert.Null(result.IgnoredListError); + Assert.NotNull(dbService.CreatedUsers); + Assert.True(dbService.CreatedUsers.ContainsKey("bob")); + Assert.False(dbService.CreatedUsers.ContainsKey("alice")); + } + + [Fact] + public async Task GetAvailableUsersAsync_SetsIgnoredListErrorWhenMissing() + { + Config config = CreateConfig(c => + { + c.IncludeExpiredSubscriptions = false; + c.IgnoredUsersListName = "missing"; + }); + FakeConfigService configService = new(config); + ConfigurableApiService apiService = new() + { + ActiveSubscriptionsHandler = (_, _) => + Task.FromResult?>(new Dictionary { { "alice", 1 } }), + ListsHandler = _ => Task.FromResult?>(new Dictionary()) + }; + UserTrackingDbService dbService = new(); + DownloadOrchestrationService service = + new(apiService, configService, new OrchestrationDownloadServiceStub(), dbService); + + UserListResult result = await service.GetAvailableUsersAsync(); + + Assert.NotNull(result.IgnoredListError); + Assert.Single(result.Users); + Assert.True(result.Users.ContainsKey("alice")); + } + + [Fact] + public async Task GetUsersForListAsync_ReturnsUsersInList() + { + FakeConfigService configService = new(CreateConfig()); + ConfigurableApiService apiService = new() { ListUsersHandler = _ => Task.FromResult?>(["bob"]) }; + DownloadOrchestrationService service = + new(apiService, configService, new OrchestrationDownloadServiceStub(), new UserTrackingDbService()); + Dictionary allUsers = new() { { "alice", 1 }, { "bob", 2 } }; + Dictionary lists = new() { { "mylist", 5 } }; + + Dictionary result = await service.GetUsersForListAsync("mylist", allUsers, lists); + + Assert.Single(result); + Assert.Equal(2, result["bob"]); + } + + [Fact] + public void ResolveDownloadPath_UsesConfiguredPathWhenSet() + { + Config config = CreateConfig(c => c.DownloadPath = "C:\\Downloads"); + DownloadOrchestrationService service = + new(new ConfigurableApiService(), new FakeConfigService(config), new OrchestrationDownloadServiceStub(), + new UserTrackingDbService()); + + string path = service.ResolveDownloadPath("creator"); + + Assert.Equal(Path.Combine("C:\\Downloads", "creator"), path); + } + + [Fact] + public void ResolveDownloadPath_UsesDefaultWhenBlank() + { + Config config = CreateConfig(c => c.DownloadPath = ""); + DownloadOrchestrationService service = + new(new ConfigurableApiService(), new FakeConfigService(config), new OrchestrationDownloadServiceStub(), + new UserTrackingDbService()); + + string path = service.ResolveDownloadPath("creator"); + + Assert.Equal("__user_data__/sites/OnlyFans/creator", path); + } + + [Fact] + public async Task PrepareUserFolderAsync_CreatesFolderAndDb() + { + using TempFolder temp = new(); + string userPath = Path.Combine(temp.Path, "creator"); + UserTrackingDbService dbService = new(); + DownloadOrchestrationService service = + new(new ConfigurableApiService(), new FakeConfigService(CreateConfig()), + new OrchestrationDownloadServiceStub(), dbService); + + await service.PrepareUserFolderAsync("creator", 99, userPath); + + Assert.True(Directory.Exists(userPath)); + Assert.True(dbService.CheckedUser.HasValue); + Assert.Equal("creator", dbService.CheckedUser.Value.user.Key); + Assert.Equal(99, dbService.CheckedUser.Value.user.Value); + Assert.Equal(userPath, dbService.CheckedUser.Value.path); + Assert.Contains(userPath, dbService.CreatedDbs); + } + + [Fact] + public async Task DownloadSinglePostAsync_WhenMissingPost_SendsMessage() + { + ConfigurableApiService apiService = new() + { + PostHandler = (_, _) => Task.FromResult(new PostEntities.SinglePostCollection()) + }; + OrchestrationDownloadServiceStub downloadService = new(); + RecordingDownloadEventHandler eventHandler = new(); + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), downloadService, new UserTrackingDbService()); + + await service.DownloadSinglePostAsync("creator", 42, "/tmp", new Dictionary(), + true, true, eventHandler); + + Assert.Contains("Getting Post", eventHandler.Messages); + Assert.Contains("Couldn't find post", eventHandler.Messages); + Assert.False(downloadService.SinglePostCalled); + } + + [Fact] + public async Task DownloadSinglePostAsync_WhenDownloaded_SendsDownloadedMessage() + { + PostEntities.SinglePostCollection collection = new() + { + SinglePosts = new Dictionary { { 1, "https://example.com/post.jpg" } } + }; + ConfigurableApiService apiService = new() { PostHandler = (_, _) => Task.FromResult(collection) }; + OrchestrationDownloadServiceStub downloadService = new() + { + SinglePostResult = new DownloadResult { NewDownloads = 1, TotalCount = 1 } + }; + RecordingDownloadEventHandler eventHandler = new(); + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), downloadService, new UserTrackingDbService()); + + await service.DownloadSinglePostAsync("creator", 99, "/tmp", new Dictionary(), + true, true, eventHandler); + + Assert.Contains("Post 99 downloaded", eventHandler.Messages); + Assert.True(downloadService.SinglePostCalled); + Assert.True(eventHandler.ProgressCalls.Count > 0); + } + + [Fact] + public async Task DownloadSinglePaidMessageAsync_WithPreviewDownloads() + { + PurchasedEntities.SinglePaidMessageCollection collection = new() + { + PreviewSingleMessages = new Dictionary { { 1, "https://example.com/preview.jpg" } }, + SingleMessages = new Dictionary { { 2, "https://example.com/full.jpg" } }, + SingleMessageObjects = [new MessageEntities.SingleMessage()] + }; + ConfigurableApiService apiService = new() { PaidMessageHandler = (_, _) => Task.FromResult(collection) }; + OrchestrationDownloadServiceStub downloadService = new() + { + SinglePaidMessageResult = new DownloadResult { TotalCount = 1, NewDownloads = 1 } + }; + RecordingDownloadEventHandler eventHandler = new(); + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), downloadService, new UserTrackingDbService()); + + await service.DownloadSinglePaidMessageAsync("creator", 5, "/tmp", new Dictionary(), + true, true, eventHandler); + + Assert.Contains(eventHandler.ContentFound, entry => entry.contentType == "Preview Paid Messages"); + Assert.True(downloadService.SinglePaidMessageCalled); + } + + [Fact] + public async Task DownloadCreatorContentAsync_DownloadsStoriesWhenEnabled() + { + using TempFolder temp = new(); + string path = Path.Combine(temp.Path, "creator"); + Config config = CreateConfig(c => + { + c.DownloadStories = true; + c.ShowScrapeSize = false; + }); + FakeConfigService configService = new(config); + ConfigurableApiService apiService = new() + { + MediaHandler = (_, _, _, _) => Task.FromResult?>( + new Dictionary + { + { 1, "https://example.com/one.jpg" }, { 2, "https://example.com/two.jpg" } + }) + }; + OrchestrationDownloadServiceStub downloadService = new() + { + StoriesResult = new DownloadResult { TotalCount = 2, NewDownloads = 2 } + }; + RecordingDownloadEventHandler eventHandler = new(); + DownloadOrchestrationService service = + new(apiService, configService, downloadService, new UserTrackingDbService()); + + CreatorDownloadResult result = await service.DownloadCreatorContentAsync("creator", 1, path, + new Dictionary(), true, true, eventHandler); + + Assert.Equal(2, result.StoriesCount); + Assert.Contains(eventHandler.ContentFound, entry => entry.contentType == "Stories"); + Assert.Contains(eventHandler.DownloadCompletes, entry => entry.contentType == "Stories"); + } + + [Fact] + public async Task ResolveUsernameAsync_ReturnsDeletedPlaceholderWhenMissing() + { + ConfigurableApiService apiService = new() { UserInfoByIdHandler = _ => Task.FromResult(null) }; + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), new OrchestrationDownloadServiceStub(), + new UserTrackingDbService()); + + string? result = await service.ResolveUsernameAsync(123); + + Assert.Equal("Deleted User - 123", result); + } + + [Fact] + public async Task ResolveUsernameAsync_ReturnsUsernameWhenPresent() + { + JObject payload = new() { ["5"] = new JObject { ["username"] = "creator" } }; + ConfigurableApiService apiService = new() { UserInfoByIdHandler = _ => Task.FromResult(payload) }; + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), new OrchestrationDownloadServiceStub(), + new UserTrackingDbService()); + + string? result = await service.ResolveUsernameAsync(5); + + Assert.Equal("creator", result); + } + + private static Config CreateConfig(Action? configure = null) + { + Config config = new() + { + DownloadAvatarHeaderPhoto = false, + DownloadPaidPosts = false, + DownloadPosts = false, + DownloadArchived = false, + DownloadStreams = false, + DownloadStories = false, + DownloadHighlights = false, + DownloadMessages = false, + DownloadPaidMessages = false, + DownloadImages = false, + DownloadVideos = false, + DownloadAudios = false, + IncludeExpiredSubscriptions = false, + IncludeRestrictedSubscriptions = false, + SkipAds = false, + IgnoreOwnMessages = false, + DownloadPostsIncrementally = false, + BypassContentForCreatorsWhoNoLongerExist = false, + DownloadDuplicatedMedia = false, + DownloadOnlySpecificDates = false, + NonInteractiveModePurchasedTab = false, + LimitDownloadRate = false, + FolderPerPaidPost = false, + FolderPerPost = false, + FolderPerPaidMessage = false, + FolderPerMessage = false, + ShowScrapeSize = false, + DisableBrowserAuth = false + }; + + configure?.Invoke(config); + return config; + } +} diff --git a/OF DL.Tests/Services/DownloadServiceTests.cs b/OF DL.Tests/Services/DownloadServiceTests.cs new file mode 100644 index 0000000..e9ee291 --- /dev/null +++ b/OF DL.Tests/Services/DownloadServiceTests.cs @@ -0,0 +1,160 @@ +using OF_DL.Models.Config; +using OF_DL.Models.Downloads; +using OF_DL.Services; + +namespace OF_DL.Tests.Services; + +public class DownloadServiceTests +{ + [Fact] + public async Task ProcessMediaDownload_RenamesServerFileAndUpdatesDb_WhenNotDownloadedButServerFileExists() + { + using TempFolder temp = new(); + string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); + string path = "/Posts/Free"; + string url = "https://example.com/image.jpg"; + string serverFilename = "server"; + string resolvedFilename = "custom"; + string serverFilePath = $"{folder}{path}/{serverFilename}.jpg"; + Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException()); + await File.WriteAllTextAsync(serverFilePath, "abc"); + + MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; + FakeConfigService configService = new(new Config { ShowScrapeSize = false }); + DownloadService service = CreateService(configService, dbService); + ProgressRecorder progress = new(); + + bool isNew = await service.ProcessMediaDownload(folder, 1, "Posts", url, path, serverFilename, + resolvedFilename, ".jpg", progress); + + string renamedPath = $"{folder}{path}/{resolvedFilename}.jpg"; + Assert.False(isNew); + Assert.False(File.Exists(serverFilePath)); + Assert.True(File.Exists(renamedPath)); + Assert.NotNull(dbService.LastUpdateMedia); + Assert.Equal($"{folder}{path}", dbService.LastUpdateMedia.Value.directory); + Assert.Equal("custom.jpg", dbService.LastUpdateMedia.Value.filename); + Assert.Equal(new FileInfo(renamedPath).Length, dbService.LastUpdateMedia.Value.size); + Assert.Equal(1, progress.Total); + } + + [Fact] + public async Task ProcessMediaDownload_RenamesExistingFile_WhenDownloadedAndCustomFormatEnabled() + { + using TempFolder temp = new(); + string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); + string path = "/Posts/Free"; + string url = "https://example.com/image.jpg"; + string serverFilename = "server"; + string resolvedFilename = "custom"; + string serverFilePath = $"{folder}{path}/{serverFilename}.jpg"; + Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException()); + await File.WriteAllTextAsync(serverFilePath, "abc"); + + MediaTrackingDbService dbService = new() { CheckDownloadedResult = true, StoredFileSize = 123 }; + FakeConfigService configService = + new(new Config { ShowScrapeSize = false, RenameExistingFilesWhenCustomFormatIsSelected = true }); + DownloadService service = CreateService(configService, dbService); + ProgressRecorder progress = new(); + + bool isNew = await service.ProcessMediaDownload(folder, 1, "Posts", url, path, serverFilename, + resolvedFilename, ".jpg", progress); + + string renamedPath = $"{folder}{path}/{resolvedFilename}.jpg"; + Assert.False(isNew); + Assert.False(File.Exists(serverFilePath)); + Assert.True(File.Exists(renamedPath)); + Assert.NotNull(dbService.LastUpdateMedia); + Assert.Equal("custom.jpg", dbService.LastUpdateMedia.Value.filename); + Assert.Equal(123, dbService.LastUpdateMedia.Value.size); + Assert.Equal(1, progress.Total); + } + + [Fact] + public async Task GetDecryptionInfo_UsesOfdlWhenCdmMissing() + { + StaticApiService apiService = new(); + DownloadService service = + CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); + + (string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo( + "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", + true, false); + + Assert.NotNull(result); + Assert.Equal("ofdl-key", result.Value.decryptionKey); + Assert.Equal(apiService.LastModifiedToReturn, result.Value.lastModified); + Assert.True(apiService.OfdlCalled); + Assert.False(apiService.CdmCalled); + } + + [Fact] + public async Task GetDecryptionInfo_UsesCdmWhenAvailable() + { + StaticApiService apiService = new(); + DownloadService service = + CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); + + (string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo( + "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", + false, false); + + Assert.NotNull(result); + Assert.Equal("cdm-key", result.Value.decryptionKey); + Assert.Equal(apiService.LastModifiedToReturn, result.Value.lastModified); + Assert.True(apiService.CdmCalled); + Assert.False(apiService.OfdlCalled); + } + + [Fact] + public async Task DownloadHighlights_ReturnsZeroWhenNoMedia() + { + StaticApiService apiService = new() { MediaToReturn = new Dictionary() }; + DownloadService service = + CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); + + DownloadResult result = await service.DownloadHighlights("user", 1, "/tmp/creator", new HashSet(), + new ProgressRecorder()); + + Assert.Equal(0, result.TotalCount); + Assert.Equal(0, result.NewDownloads); + Assert.Equal(0, result.ExistingDownloads); + Assert.Equal("Highlights", result.MediaType); + Assert.True(result.Success); + } + + [Fact] + public async Task DownloadHighlights_CountsExistingWhenAlreadyDownloaded() + { + using TempFolder temp = new(); + string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); + StaticApiService apiService = new() + { + MediaToReturn = new Dictionary + { + { 1, "https://example.com/one.jpg" }, { 2, "https://example.com/two.jpg" } + } + }; + MediaTrackingDbService dbService = new() { CheckDownloadedResult = true }; + FakeConfigService configService = new(new Config { ShowScrapeSize = false }); + ProgressRecorder progress = new(); + DownloadService service = CreateService(configService, dbService, apiService); + + DownloadResult result = await service.DownloadHighlights("user", 1, folder, new HashSet(), progress); + + Assert.Equal(2, result.TotalCount); + Assert.Equal(0, result.NewDownloads); + Assert.Equal(2, result.ExistingDownloads); + Assert.Equal("Highlights", result.MediaType); + Assert.True(result.Success); + Assert.Equal(2, progress.Total); + } + + private static DownloadService CreateService(FakeConfigService configService, MediaTrackingDbService dbService, + StaticApiService? apiService = null) => + new(new FakeAuthService(), configService, dbService, new FakeFileNameService(), + apiService ?? new StaticApiService()); + + private static string NormalizeFolder(string folder) => folder.Replace("\\", "/"); +} + diff --git a/OF DL.Tests/Services/FileNameServiceTestModels.cs b/OF DL.Tests/Services/FileNameServiceTestModels.cs new file mode 100644 index 0000000..9d1098b --- /dev/null +++ b/OF DL.Tests/Services/FileNameServiceTestModels.cs @@ -0,0 +1,30 @@ +namespace OF_DL.Tests.Services; + +internal sealed class TestInfo +{ + public long Id { get; set; } + public string? Text { get; set; } + public DateTime CreatedAt { get; set; } +} + +internal sealed class TestAuthor +{ + public long Id { get; set; } +} + +internal sealed class TestMedia +{ + public long Id { get; set; } + public TestMediaFiles Files { get; set; } = new(); +} + +internal sealed class TestMediaFiles +{ + public TestMediaFull Full { get; set; } = new(); + public object? Drm { get; set; } +} + +internal sealed class TestMediaFull +{ + public string? Url { get; set; } +} diff --git a/OF DL.Tests/Services/FileNameServiceTests.cs b/OF DL.Tests/Services/FileNameServiceTests.cs new file mode 100644 index 0000000..3d8805d --- /dev/null +++ b/OF DL.Tests/Services/FileNameServiceTests.cs @@ -0,0 +1,79 @@ +using OF_DL.Services; + +namespace OF_DL.Tests.Services; + +public class FileNameServiceTests +{ + [Fact] + public async Task GetFilename_ReturnsExpectedValues() + { + TestInfo info = new() { Id = 7, Text = "
hello world
", CreatedAt = new DateTime(2024, 1, 2) }; + TestMedia media = new() + { + Id = 99, + Files = new TestMediaFiles + { + Full = new TestMediaFull { Url = "https://cdn.test/file-name.jpg" }, Drm = new object() + } + }; + TestAuthor author = new() { Id = 123 }; + FileNameService service = new(new FakeAuthService()); + + List selectedProperties = ["mediaId", "filename", "username", "text", "createdAt", "id"]; + Dictionary values = + await service.GetFilename(info, media, author, selectedProperties, "creator"); + + Assert.Equal("99", values["mediaId"]); + Assert.Equal("file-name", values["filename"]); + Assert.Equal("creator", values["username"]); + Assert.Equal("hello world", values["text"]); + Assert.Equal("2024-01-02", values["createdAt"]); + Assert.Equal("7", values["id"]); + } + + [Fact] + public async Task GetFilename_TruncatesTextTo100Chars() + { + string longText = new('a', 120); + TestInfo info = new() { Text = $"

{longText}

" }; + TestMedia media = new() + { + Id = 1, + Files = new TestMediaFiles + { + Full = new TestMediaFull { Url = "https://cdn.test/short.jpg" }, Drm = null + } + }; + FileNameService service = new(new FakeAuthService()); + + Dictionary values = + await service.GetFilename(info, media, new TestAuthor(), ["text"], "creator"); + + Assert.Equal(100, values["text"].Length); + Assert.Equal(new string('a', 100), values["text"]); + } + + [Fact] + public async Task GetFilename_UsesUserLookupWhenUsernameMissing() + { + TestAuthor author = new() { Id = 55 }; + Dictionary users = new() { { "mapped", 55 } }; + FileNameService service = new(new FakeAuthService()); + + Dictionary values = + await service.GetFilename(new TestInfo(), new TestMedia(), author, ["username"], "", users); + + Assert.Equal("mapped", values["username"]); + } + + [Fact] + public async Task BuildFilename_ReplacesTokensAndRemovesInvalidChars() + { + FileNameService service = new(new FakeAuthService()); + Dictionary values = new() { { "username", "creator" }, { "mediaId", "99" } }; + + string result = await service.BuildFilename("{username}_{mediaId}:*?", values); + + Assert.Equal("creator_99", result); + } +} diff --git a/OF DL.Tests/Services/SimpleHttpServer.cs b/OF DL.Tests/Services/SimpleHttpServer.cs new file mode 100644 index 0000000..f6a3396 --- /dev/null +++ b/OF DL.Tests/Services/SimpleHttpServer.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace OF_DL.Tests.Services; + +internal sealed class SimpleHttpServer : IDisposable +{ + private readonly TcpListener _listener; + private readonly Task _handlerTask; + private readonly byte[] _responseBytes; + + public SimpleHttpServer(string body, DateTime? lastModifiedUtc = null) + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + int port = ((IPEndPoint)_listener.LocalEndpoint).Port; + Url = new Uri($"http://127.0.0.1:{port}/"); + _responseBytes = BuildResponse(body, lastModifiedUtc); + _handlerTask = Task.Run(HandleOnceAsync); + } + + public Uri Url { get; } + + public Task Completion => _handlerTask; + + public void Dispose() + { + _listener.Stop(); + try + { + _handlerTask.Wait(TimeSpan.FromSeconds(1)); + } + catch + { + // ignored + } + } + + private async Task HandleOnceAsync() + { + using TcpClient client = await _listener.AcceptTcpClientAsync(); + await using NetworkStream stream = client.GetStream(); + await ReadHeadersAsync(stream); + await stream.WriteAsync(_responseBytes); + } + + private static async Task ReadHeadersAsync(NetworkStream stream) + { + byte[] buffer = new byte[1024]; + int read; + string data = ""; + while ((read = await stream.ReadAsync(buffer)) > 0) + { + data += Encoding.ASCII.GetString(buffer, 0, read); + if (data.Contains("\r\n\r\n", StringComparison.Ordinal)) + { + break; + } + } + } + + private static byte[] BuildResponse(string body, DateTime? lastModifiedUtc) + { + byte[] bodyBytes = Encoding.UTF8.GetBytes(body); + StringBuilder header = new(); + header.Append("HTTP/1.1 200 OK\r\n"); + header.Append("Content-Type: application/xml\r\n"); + header.Append($"Content-Length: {bodyBytes.Length}\r\n"); + if (lastModifiedUtc.HasValue) + { + header.Append($"Last-Modified: {lastModifiedUtc.Value.ToUniversalTime():R}\r\n"); + } + + header.Append("Connection: close\r\n\r\n"); + byte[] headerBytes = Encoding.ASCII.GetBytes(header.ToString()); + + byte[] response = new byte[headerBytes.Length + bodyBytes.Length]; + Buffer.BlockCopy(headerBytes, 0, response, 0, headerBytes.Length); + Buffer.BlockCopy(bodyBytes, 0, response, headerBytes.Length, bodyBytes.Length); + return response; + } +} diff --git a/OF DL.Tests/Services/TestDoubles.cs b/OF DL.Tests/Services/TestDoubles.cs new file mode 100644 index 0000000..ba8797c --- /dev/null +++ b/OF DL.Tests/Services/TestDoubles.cs @@ -0,0 +1,516 @@ +using Newtonsoft.Json.Linq; +using OF_DL.Enumerations; +using OF_DL.Models; +using OF_DL.Models.Config; +using OF_DL.Models.Downloads; +using OF_DL.Services; +using Serilog.Core; +using Serilog.Events; +using ArchivedEntities = OF_DL.Models.Entities.Archived; +using MessageEntities = OF_DL.Models.Entities.Messages; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; +using StreamEntities = OF_DL.Models.Entities.Streams; +using UserEntities = OF_DL.Models.Entities.Users; + +namespace OF_DL.Tests.Services; + +internal sealed class TempFolder : IDisposable +{ + public TempFolder() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ofdl-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, true); + } + catch + { + // ignored + } + } +} + +internal sealed class ProgressRecorder : IProgressReporter +{ + public long Total { get; private set; } + + public void ReportProgress(long increment) => Total += increment; +} + +internal sealed class FakeConfigService(Config config) : IConfigService +{ + public Config CurrentConfig { get; private set; } = config; + + public bool IsCliNonInteractive { get; } = config.NonInteractiveMode; + + public Task LoadConfigurationAsync(string[] args) => Task.FromResult(true); + + public Task SaveConfigurationAsync(string filePath = "config.conf") => Task.CompletedTask; + + public void UpdateConfig(Config newConfig) => CurrentConfig = newConfig; + + public List<(string Name, bool Value)> GetToggleableProperties() => []; + + public bool ApplyToggleableSelections(List selectedNames) => false; +} + +internal sealed class MediaTrackingDbService : IDbService +{ + public bool CheckDownloadedResult { get; init; } + + public long StoredFileSize { get; init; } + + public (string folder, long mediaId, string apiType, string directory, string filename, long size, + bool downloaded, DateTime createdAt)? LastUpdateMedia { get; private set; } + + public Task UpdateMedia(string folder, long mediaId, string apiType, string directory, string filename, + long size, bool downloaded, DateTime createdAt) + { + LastUpdateMedia = (folder, mediaId, apiType, directory, filename, size, downloaded, createdAt); + return Task.CompletedTask; + } + + public Task GetStoredFileSize(string folder, long mediaId, string apiType) => + Task.FromResult(StoredFileSize); + + public Task CheckDownloaded(string folder, long mediaId, string apiType) => + Task.FromResult(CheckDownloadedResult); + + public Task AddMessage(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt, long userId) => throw new NotImplementedException(); + + public Task AddPost(string folder, long postId, string messageText, string price, bool isPaid, bool isArchived, + DateTime createdAt) => throw new NotImplementedException(); + + public Task AddStory(string folder, long postId, string messageText, string price, bool isPaid, bool isArchived, + DateTime createdAt) => throw new NotImplementedException(); + + public Task CreateDb(string folder) => throw new NotImplementedException(); + + public Task CreateUsersDb(Dictionary users) => throw new NotImplementedException(); + + public Task CheckUsername(KeyValuePair user, string path) => throw new NotImplementedException(); + + public Task AddMedia(string folder, long mediaId, long postId, string link, string? directory, + string? filename, long? size, string apiType, string mediaType, bool preview, bool downloaded, + DateTime? createdAt) => throw new NotImplementedException(); + + public Task GetMostRecentPostDate(string folder) => throw new NotImplementedException(); +} + +internal sealed class StaticApiService : IApiService +{ + public Dictionary? MediaToReturn { get; init; } + + public DateTime LastModifiedToReturn { get; set; } = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + public bool OfdlCalled { get; private set; } + + public bool CdmCalled { get; private set; } + + public Task GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp) => + Task.FromResult("pssh"); + + public Task GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp) => + Task.FromResult(LastModifiedToReturn); + + public Task GetDecryptionKeyOfdl(Dictionary drmHeaders, string licenceUrl, string pssh) + { + OfdlCalled = true; + return Task.FromResult("ofdl-key"); + } + + public Task GetDecryptionKeyCdm(Dictionary drmHeaders, string licenceUrl, string pssh) + { + CdmCalled = true; + return Task.FromResult("cdm-key"); + } + + public Dictionary GetDynamicHeaders(string path, string queryParam) => + new() { { "X-Test", "value" } }; + + public Task?> GetMedia(MediaType mediaType, string endpoint, string? username, + string folder) => Task.FromResult(MediaToReturn); + + public Task?> GetLists(string endpoint) => throw new NotImplementedException(); + + public Task?> GetListUsers(string endpoint) => throw new NotImplementedException(); + + public Task GetPaidPosts(string endpoint, string folder, + string username, List paidPostIds, IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetPosts(string endpoint, string folder, + List paidPostIds, IStatusReporter statusReporter) => throw new NotImplementedException(); + + public Task GetPost(string endpoint, string folder) => + throw new NotImplementedException(); + + public Task GetStreams(string endpoint, string folder, + List paidPostIds, IStatusReporter statusReporter) => throw new NotImplementedException(); + + public Task GetArchived(string endpoint, string folder, + IStatusReporter statusReporter) => throw new NotImplementedException(); + + public Task GetMessages(string endpoint, string folder, + IStatusReporter statusReporter) => throw new NotImplementedException(); + + public Task GetPaidMessages(string endpoint, + string folder, string username, IStatusReporter statusReporter) => throw new NotImplementedException(); + + public Task GetPaidMessage(string endpoint, + string folder) => throw new NotImplementedException(); + + public Task> GetPurchasedTabUsers(string endpoint, Dictionary users) => + throw new NotImplementedException(); + + public Task> GetPurchasedTab(string endpoint, + string folder, Dictionary users) => throw new NotImplementedException(); + + public Task GetUserInfo(string endpoint) => + throw new NotImplementedException(); + + public Task GetUserInfoById(string endpoint) => + throw new NotImplementedException(); + + public Task?> GetActiveSubscriptions(string endpoint, + bool includeRestrictedSubscriptions) => throw new NotImplementedException(); + + public Task?> GetExpiredSubscriptions(string endpoint, + bool includeRestrictedSubscriptions) => throw new NotImplementedException(); +} + +internal sealed class ConfigurableApiService : IApiService +{ + public Func?>>? ActiveSubscriptionsHandler { get; init; } + public Func?>>? ExpiredSubscriptionsHandler { get; init; } + public Func?>>? ListsHandler { get; init; } + public Func?>>? ListUsersHandler { get; init; } + public Func?>>? MediaHandler { get; init; } + public Func>? PostHandler { get; init; } + public Func>? PaidMessageHandler { get; init; } + public Func>? UserInfoHandler { get; init; } + public Func>? UserInfoByIdHandler { get; init; } + + public Task?> GetActiveSubscriptions(string endpoint, + bool includeRestrictedSubscriptions) => + ActiveSubscriptionsHandler?.Invoke(endpoint, includeRestrictedSubscriptions) ?? + Task.FromResult?>(null); + + public Task?> GetExpiredSubscriptions(string endpoint, + bool includeRestrictedSubscriptions) => + ExpiredSubscriptionsHandler?.Invoke(endpoint, includeRestrictedSubscriptions) ?? + Task.FromResult?>(null); + + public Task?> GetLists(string endpoint) => + ListsHandler?.Invoke(endpoint) ?? Task.FromResult?>(null); + + public Task?> GetListUsers(string endpoint) => + ListUsersHandler?.Invoke(endpoint) ?? Task.FromResult?>(null); + + public Task?> GetMedia(MediaType mediaType, string endpoint, string? username, + string folder) => + MediaHandler?.Invoke(mediaType, endpoint, username, folder) ?? + Task.FromResult?>(null); + + public Task GetPost(string endpoint, string folder) => + PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection()); + + public Task GetPaidMessage(string endpoint, string folder) => + PaidMessageHandler?.Invoke(endpoint, folder) ?? + Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection()); + + public Task GetUserInfo(string endpoint) => + UserInfoHandler?.Invoke(endpoint) ?? Task.FromResult(null); + + public Task GetUserInfoById(string endpoint) => + UserInfoByIdHandler?.Invoke(endpoint) ?? Task.FromResult(null); + + public Task GetPaidPosts(string endpoint, string folder, string username, + List paidPostIds, IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetPosts(string endpoint, string folder, List paidPostIds, + IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetStreams(string endpoint, string folder, List paidPostIds, + IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetArchived(string endpoint, string folder, + IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetMessages(string endpoint, string folder, + IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetPaidMessages(string endpoint, string folder, + string username, IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task> GetPurchasedTabUsers(string endpoint, Dictionary users) => + throw new NotImplementedException(); + + public Task> GetPurchasedTab(string endpoint, string folder, + Dictionary users) => + throw new NotImplementedException(); + + public Dictionary GetDynamicHeaders(string path, string queryParam) => + throw new NotImplementedException(); + + public Task GetDecryptionKeyCdm(Dictionary drmHeaders, string licenceUrl, string pssh) => + throw new NotImplementedException(); + + public Task GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp) => + throw new NotImplementedException(); + + public Task GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp) => + throw new NotImplementedException(); + + public Task GetDecryptionKeyOfdl(Dictionary drmHeaders, string licenceUrl, string pssh) => + throw new NotImplementedException(); +} + +internal sealed class OrchestrationDownloadServiceStub : IDownloadService +{ + public bool SinglePostCalled { get; private set; } + public bool SinglePaidMessageCalled { get; private set; } + + public DownloadResult? SinglePostResult { get; init; } + public DownloadResult? SinglePaidMessageResult { get; init; } + public DownloadResult? StoriesResult { get; init; } + + public Task CalculateTotalFileSize(List urls) => Task.FromResult((long)urls.Count); + + public Task ProcessMediaDownload(string folder, long mediaId, string apiType, string url, string path, + string serverFileName, string resolvedFileName, string extension, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task<(string decryptionKey, DateTime lastModified)?> GetDecryptionInfo(string mpdUrl, string policy, + string signature, string kvp, string mediaId, string contentId, string drmType, bool clientIdBlobMissing, + bool devicePrivateKeyMissing) => + throw new NotImplementedException(); + + public Task DownloadAvatarHeader(string? avatarUrl, string? headerUrl, string folder, string username) => + Task.CompletedTask; + + public Task DownloadHighlights(string username, long userId, string path, + HashSet paidPostIds, IProgressReporter progressReporter) => + Task.FromResult(new DownloadResult()); + + public Task DownloadStories(string username, long userId, string path, + HashSet paidPostIds, IProgressReporter progressReporter) => + Task.FromResult(StoriesResult ?? new DownloadResult()); + + public Task DownloadArchived(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + ArchivedEntities.ArchivedCollection archived, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadMessages(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + MessageEntities.MessageCollection messages, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadPaidMessages(string username, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidMessageCollection paidMessageCollection, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadStreams(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + StreamEntities.StreamsCollection streams, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadFreePosts(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PostEntities.PostCollection posts, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadPaidPosts(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidPostCollection purchasedPosts, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadPaidPostsPurchasedTab(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidPostCollection purchasedPosts, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadPaidMessagesPurchasedTab(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidMessageCollection paidMessageCollection, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadSinglePost(string username, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, PostEntities.SinglePostCollection post, + IProgressReporter progressReporter) + { + SinglePostCalled = true; + return Task.FromResult(SinglePostResult ?? new DownloadResult()); + } + + public Task DownloadSinglePaidMessage(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.SinglePaidMessageCollection singlePaidMessageCollection, + IProgressReporter progressReporter) + { + SinglePaidMessageCalled = true; + return Task.FromResult(SinglePaidMessageResult ?? new DownloadResult()); + } +} + +internal sealed class UserTrackingDbService : IDbService +{ + public Dictionary? CreatedUsers { get; private set; } + public List CreatedDbs { get; } = []; + public (KeyValuePair user, string path)? CheckedUser { get; private set; } + + public Task CreateDb(string folder) + { + CreatedDbs.Add(folder); + return Task.CompletedTask; + } + + public Task CreateUsersDb(Dictionary users) + { + CreatedUsers = new Dictionary(users); + return Task.CompletedTask; + } + + public Task CheckUsername(KeyValuePair user, string path) + { + CheckedUser = (user, path); + return Task.CompletedTask; + } + + public Task AddMessage(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt, long userId) => throw new NotImplementedException(); + + public Task AddPost(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt) => throw new NotImplementedException(); + + public Task AddStory(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt) => throw new NotImplementedException(); + + public Task AddMedia(string folder, long mediaId, long postId, string link, string? directory, + string? filename, long? size, string apiType, string mediaType, bool preview, bool downloaded, + DateTime? createdAt) => throw new NotImplementedException(); + + public Task UpdateMedia(string folder, long mediaId, string apiType, string directory, string filename, + long size, bool downloaded, DateTime createdAt) => throw new NotImplementedException(); + + public Task GetStoredFileSize(string folder, long mediaId, string apiType) => + throw new NotImplementedException(); + + public Task CheckDownloaded(string folder, long mediaId, string apiType) => + throw new NotImplementedException(); + + public Task GetMostRecentPostDate(string folder) => throw new NotImplementedException(); +} + +internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler +{ + public List Messages { get; } = []; + public List<(string contentType, int mediaCount, int objectCount)> ContentFound { get; } = []; + public List NoContent { get; } = []; + public List<(string contentType, DownloadResult result)> DownloadCompletes { get; } = []; + public List<(string description, long maxValue, bool showSize)> ProgressCalls { get; } = []; + + public Task WithStatusAsync(string statusMessage, Func> work) => + work(new RecordingStatusReporter(statusMessage)); + + public Task WithProgressAsync(string description, long maxValue, bool showSize, + Func> work) + { + ProgressCalls.Add((description, maxValue, showSize)); + return work(new ProgressRecorder()); + } + + public void OnContentFound(string contentType, int mediaCount, int objectCount) => + ContentFound.Add((contentType, mediaCount, objectCount)); + + public void OnNoContentFound(string contentType) => NoContent.Add(contentType); + + public void OnDownloadComplete(string contentType, DownloadResult result) => + DownloadCompletes.Add((contentType, result)); + + public void OnUserStarting(string username) => Messages.Add($"Starting {username}"); + + public void OnUserComplete(string username, CreatorDownloadResult result) => + Messages.Add($"Completed {username}"); + + public void OnPurchasedTabUserComplete(string username, int paidPostCount, int paidMessagesCount) => + Messages.Add($"Purchased {username}"); + + public void OnScrapeComplete(TimeSpan elapsed) => Messages.Add("Scrape complete"); + + public void OnMessage(string message) => Messages.Add(message); +} + +internal sealed class RecordingStatusReporter : IStatusReporter +{ + private readonly List _statuses; + + public RecordingStatusReporter(string initialStatus) + { + _statuses = [initialStatus]; + } + + public IReadOnlyList Statuses => _statuses; + + public void ReportStatus(string message) => _statuses.Add(message); +} + +internal sealed class FakeFileNameService : IFileNameService +{ + public Task BuildFilename(string fileFormat, Dictionary values) => + throw new NotImplementedException(); + + public Task> GetFilename(object info, object media, object author, + List selectedProperties, string username, Dictionary? users = null) => + throw new NotImplementedException(); +} + +internal sealed class FakeAuthService : IAuthService +{ + public Auth? CurrentAuth { get; set; } + + public Task LoadFromFileAsync(string filePath = "auth.json") => throw new NotImplementedException(); + + public Task LoadFromBrowserAsync() => throw new NotImplementedException(); + + public Task SaveToFileAsync(string filePath = "auth.json") => throw new NotImplementedException(); + + public void ValidateCookieString() => throw new NotImplementedException(); + + public Task ValidateAuthAsync() => throw new NotImplementedException(); + + public void Logout() => throw new NotImplementedException(); +} + +internal sealed class FakeLoggingService : ILoggingService +{ + public LoggingLevelSwitch LevelSwitch { get; } = new(); + public LoggingLevel LastLevel { get; private set; } = LoggingLevel.Error; + public int UpdateCount { get; private set; } + + public void UpdateLoggingLevel(LoggingLevel newLevel) + { + UpdateCount++; + LastLevel = newLevel; + LevelSwitch.MinimumLevel = (LogEventLevel)newLevel; + } + + public LoggingLevel GetCurrentLoggingLevel() => (LoggingLevel)LevelSwitch.MinimumLevel; +} diff --git a/OF DL.Tests/Services/TestScopes.cs b/OF DL.Tests/Services/TestScopes.cs new file mode 100644 index 0000000..ea96707 --- /dev/null +++ b/OF DL.Tests/Services/TestScopes.cs @@ -0,0 +1,53 @@ +using System.Reflection; +using OF_DL.Models.OfdlApi; +using OF_DL.Services; + +namespace OF_DL.Tests.Services; + +[CollectionDefinition("NonParallel", DisableParallelization = true)] +public class NonParallelCollection +{ +} + +internal sealed class DynamicRulesCacheScope : IDisposable +{ + private static readonly FieldInfo s_rulesField = + typeof(ApiService).GetField("s_cachedDynamicRules", BindingFlags.NonPublic | BindingFlags.Static) ?? + throw new InvalidOperationException("Unable to access cached rules field."); + + private static readonly FieldInfo s_expirationField = + typeof(ApiService).GetField("s_cachedDynamicRulesExpiration", + BindingFlags.NonPublic | BindingFlags.Static) ?? + throw new InvalidOperationException("Unable to access cached rules expiration field."); + + private readonly object? _priorRules; + private readonly DateTime? _priorExpiration; + + public DynamicRulesCacheScope(DynamicRules rules) + { + _priorRules = s_rulesField.GetValue(null); + _priorExpiration = (DateTime?)s_expirationField.GetValue(null); + + s_rulesField.SetValue(null, rules); + s_expirationField.SetValue(null, DateTime.UtcNow.AddHours(1)); + } + + public void Dispose() + { + s_rulesField.SetValue(null, _priorRules); + s_expirationField.SetValue(null, _priorExpiration); + } +} + +internal sealed class CurrentDirectoryScope : IDisposable +{ + private readonly string _original; + + public CurrentDirectoryScope(string path) + { + _original = Environment.CurrentDirectory; + Environment.CurrentDirectory = path; + } + + public void Dispose() => Environment.CurrentDirectory = _original; +} diff --git a/OF DL.sln b/OF DL.sln index 2434220..6beeff9 100644 --- a/OF DL.sln +++ b/OF DL.sln @@ -5,6 +5,10 @@ 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}" 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +19,14 @@ Global {318B2CE3-D046-4276-A2CF-89E6DF32F1B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {318B2CE3-D046-4276-A2CF-89E6DF32F1B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {318B2CE3-D046-4276-A2CF-89E6DF32F1B3}.Release|Any CPU.Build.0 = Release|Any CPU + {7B8B6A26-6732-4B3A-AE62-1CE589DFF8F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B8B6A26-6732-4B3A-AE62-1CE589DFF8F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B8B6A26-6732-4B3A-AE62-1CE589DFF8F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B8B6A26-6732-4B3A-AE62-1CE589DFF8F2}.Release|Any CPU.Build.0 = Release|Any CPU + {FF5EC4D7-6369-4A78-8C02-E370343E797C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF5EC4D7-6369-4A78-8C02-E370343E797C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/OF DL/CDMApi.cs b/OF DL/CDMApi.cs deleted file mode 100644 index e01eae2..0000000 --- a/OF DL/CDMApi.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace WidevineClient.Widevine -{ - public class CDMApi - { - string SessionId { get; set; } - - public byte[] GetChallenge(string initDataB64, string certDataB64, bool offline = false, bool raw = false) - { - SessionId = CDM.OpenSession(initDataB64, Constants.DEVICE_NAME, offline, raw); - CDM.SetServiceCertificate(SessionId, Convert.FromBase64String(certDataB64)); - return CDM.GetLicenseRequest(SessionId); - } - - public bool ProvideLicense(string licenseB64) - { - CDM.ProvideLicense(SessionId, Convert.FromBase64String(licenseB64)); - return true; - } - - public List GetKeys() - { - return CDM.GetKeys(SessionId); - } - } -} diff --git a/OF DL/CLI/SpectreDownloadEventHandler.cs b/OF DL/CLI/SpectreDownloadEventHandler.cs new file mode 100644 index 0000000..245bf0a --- /dev/null +++ b/OF DL/CLI/SpectreDownloadEventHandler.cs @@ -0,0 +1,134 @@ +using OF_DL.Models.Downloads; +using OF_DL.Services; +using Spectre.Console; + +namespace OF_DL.CLI; + +/// +/// Spectre.Console implementation of IDownloadEventHandler. +/// Handles all CLI-specific display logic for downloads. +/// +public class SpectreDownloadEventHandler : IDownloadEventHandler +{ + public async Task WithStatusAsync(string statusMessage, Func> work) + { + TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + await AnsiConsole.Status() + .StartAsync($"[red]{Markup.Escape(statusMessage)}[/]", + async ctx => + { + try + { + SpectreStatusReporter reporter = new(ctx); + T result = await work(reporter); + tcs.TrySetResult(result); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + + return await tcs.Task; + } + + public async Task WithProgressAsync(string description, long maxValue, bool showSize, + Func> work) + { + TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + await AnsiConsole.Progress() + .Columns(GetProgressColumns(showSize)) + .StartAsync(async ctx => + { + try + { + ProgressTask task = ctx.AddTask($"[red]{Markup.Escape(description)}[/]", false); + task.MaxValue = maxValue; + task.StartTask(); + + SpectreProgressReporter progressReporter = new(task); + T result = await work(progressReporter); + tcs.TrySetResult(result); + + task.StopTask(); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + + return await tcs.Task; + } + + public void OnContentFound(string contentType, int mediaCount, int objectCount) => + AnsiConsole.Markup($"[red]Found {mediaCount} Media from {objectCount} {Markup.Escape(contentType)}\n[/]"); + + public void OnNoContentFound(string contentType) => + AnsiConsole.Markup($"[red]Found 0 {Markup.Escape(contentType)}\n[/]"); + + public void OnDownloadComplete(string contentType, DownloadResult result) => + AnsiConsole.Markup( + $"[red]{Markup.Escape(contentType)} Already Downloaded: {result.ExistingDownloads} New {Markup.Escape(contentType)} Downloaded: {result.NewDownloads}[/]\n"); + + public void OnUserStarting(string username) => + AnsiConsole.Markup($"[red]\nScraping Data for {Markup.Escape(username)}\n[/]"); + + public void OnUserComplete(string username, CreatorDownloadResult result) + { + AnsiConsole.Markup("\n"); + AnsiConsole.Write(new BreakdownChart() + .FullSize() + .AddItem("Paid Posts", result.PaidPostCount, Color.Red) + .AddItem("Posts", result.PostCount, Color.Blue) + .AddItem("Archived", result.ArchivedCount, Color.Green) + .AddItem("Streams", result.StreamsCount, Color.Purple) + .AddItem("Stories", result.StoriesCount, Color.Yellow) + .AddItem("Highlights", result.HighlightsCount, Color.Orange1) + .AddItem("Messages", result.MessagesCount, Color.LightGreen) + .AddItem("Paid Messages", result.PaidMessagesCount, Color.Aqua)); + AnsiConsole.Markup("\n"); + } + + public void OnPurchasedTabUserComplete(string username, int paidPostCount, int paidMessagesCount) + { + AnsiConsole.Markup("\n"); + AnsiConsole.Write(new BreakdownChart() + .FullSize() + .AddItem("Paid Posts", paidPostCount, Color.Red) + .AddItem("Paid Messages", paidMessagesCount, Color.Aqua)); + AnsiConsole.Markup("\n"); + } + + public void OnScrapeComplete(TimeSpan elapsed) => + AnsiConsole.Markup($"[green]Scrape Completed in {elapsed.TotalMinutes:0.00} minutes\n[/]"); + + public void OnMessage(string message) => AnsiConsole.Markup($"[red]{Markup.Escape(message)}\n[/]"); + + private static ProgressColumn[] GetProgressColumns(bool showScrapeSize) + { + List progressColumns; + if (showScrapeSize) + { + progressColumns = + [ + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new PercentageColumn(), + new DownloadedColumn(), + new RemainingTimeColumn() + ]; + } + else + { + progressColumns = + [ + new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn() + ]; + } + + return progressColumns.ToArray(); + } +} diff --git a/OF DL/CLI/SpectreProgressReporter.cs b/OF DL/CLI/SpectreProgressReporter.cs new file mode 100644 index 0000000..3a334d6 --- /dev/null +++ b/OF DL/CLI/SpectreProgressReporter.cs @@ -0,0 +1,14 @@ +using OF_DL.Services; +using Spectre.Console; + +namespace OF_DL.CLI; + +/// +/// Implementation of IProgressReporter that uses Spectre.Console's ProgressTask for CLI output. +/// +public class SpectreProgressReporter(ProgressTask task) : IProgressReporter +{ + private readonly ProgressTask _task = task ?? throw new ArgumentNullException(nameof(task)); + + public void ReportProgress(long increment) => _task.Increment(increment); +} diff --git a/OF DL/CLI/SpectreStatusReporter.cs b/OF DL/CLI/SpectreStatusReporter.cs new file mode 100644 index 0000000..7c965fd --- /dev/null +++ b/OF DL/CLI/SpectreStatusReporter.cs @@ -0,0 +1,17 @@ +using OF_DL.Services; +using Spectre.Console; + +namespace OF_DL.CLI; + +/// +/// Implementation of IStatusReporter that uses Spectre.Console's StatusContext for CLI output. +/// +public class SpectreStatusReporter(StatusContext ctx) : IStatusReporter +{ + public void ReportStatus(string message) + { + ctx.Status($"[red]{message}[/]"); + ctx.Spinner(Spinner.Known.Dots); + ctx.SpinnerStyle(Style.Parse("blue")); + } +} diff --git a/OF DL/Crypto/CryptoUtils.cs b/OF DL/Crypto/CryptoUtils.cs deleted file mode 100644 index d433133..0000000 --- a/OF DL/Crypto/CryptoUtils.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Org.BouncyCastle.Crypto; -using Org.BouncyCastle.Crypto.Engines; -using Org.BouncyCastle.Crypto.Macs; -using Org.BouncyCastle.Crypto.Parameters; -using System.Security.Cryptography; - -namespace WidevineClient.Crypto -{ - public class CryptoUtils - { - public static byte[] GetHMACSHA256Digest(byte[] data, byte[] key) - { - return new HMACSHA256(key).ComputeHash(data); - } - - public static byte[] GetCMACDigest(byte[] data, byte[] key) - { - IBlockCipher cipher = new AesEngine(); - IMac mac = new CMac(cipher, 128); - - KeyParameter keyParam = new KeyParameter(key); - - mac.Init(keyParam); - - mac.BlockUpdate(data, 0, data.Length); - - byte[] outBytes = new byte[16]; - - mac.DoFinal(outBytes, 0); - return outBytes; - } - } -} diff --git a/OF DL/Crypto/Padding.cs b/OF DL/Crypto/Padding.cs deleted file mode 100644 index 4701c1c..0000000 --- a/OF DL/Crypto/Padding.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; - -namespace WidevineClient.Crypto -{ - public class Padding - { - public static byte[] AddPKCS7Padding(byte[] data, int k) - { - int m = k - (data.Length % k); - - byte[] padding = new byte[m]; - Array.Fill(padding, (byte)m); - - byte[] paddedBytes = new byte[data.Length + padding.Length]; - Buffer.BlockCopy(data, 0, paddedBytes, 0, data.Length); - Buffer.BlockCopy(padding, 0, paddedBytes, data.Length, padding.Length); - - return paddedBytes; - } - - public static byte[] RemovePKCS7Padding(byte[] paddedByteArray) - { - var last = paddedByteArray[^1]; - if (paddedByteArray.Length <= last) - { - return paddedByteArray; - } - - return SubArray(paddedByteArray, 0, (paddedByteArray.Length - last)); - } - - public static T[] SubArray(T[] arr, int start, int length) - { - var result = new T[length]; - Buffer.BlockCopy(arr, start, result, 0, length); - - return result; - } - - public static byte[] AddPSSPadding(byte[] hash) - { - int modBits = 2048; - int hLen = 20; - int emLen = 256; - - int lmask = 0; - for (int i = 0; i < 8 * emLen - (modBits - 1); i++) - lmask = lmask >> 1 | 0x80; - - if (emLen < hLen + hLen + 2) - { - return null; - } - - byte[] salt = new byte[hLen]; - new Random().NextBytes(salt); - - byte[] m_prime = Enumerable.Repeat((byte)0, 8).ToArray().Concat(hash).Concat(salt).ToArray(); - byte[] h = SHA1.Create().ComputeHash(m_prime); - - byte[] ps = Enumerable.Repeat((byte)0, emLen - hLen - hLen - 2).ToArray(); - byte[] db = ps.Concat(new byte[] { 0x01 }).Concat(salt).ToArray(); - - byte[] dbMask = MGF1(h, emLen - hLen - 1); - - byte[] maskedDb = new byte[dbMask.Length]; - for (int i = 0; i < dbMask.Length; i++) - maskedDb[i] = (byte)(db[i] ^ dbMask[i]); - - maskedDb[0] = (byte)(maskedDb[0] & ~lmask); - - byte[] padded = maskedDb.Concat(h).Concat(new byte[] { 0xBC }).ToArray(); - - return padded; - } - - public static byte[] RemoveOAEPPadding(byte[] data) - { - int k = 256; - int hLen = 20; - - byte[] maskedSeed = data[1..(hLen + 1)]; - byte[] maskedDB = data[(hLen + 1)..]; - - byte[] seedMask = MGF1(maskedDB, hLen); - - byte[] seed = new byte[maskedSeed.Length]; - for (int i = 0; i < maskedSeed.Length; i++) - seed[i] = (byte)(maskedSeed[i] ^ seedMask[i]); - - byte[] dbMask = MGF1(seed, k - hLen - 1); - - byte[] db = new byte[maskedDB.Length]; - for (int i = 0; i < maskedDB.Length; i++) - db[i] = (byte)(maskedDB[i] ^ dbMask[i]); - - int onePos = BitConverter.ToString(db[hLen..]).Replace("-", "").IndexOf("01") / 2; - byte[] unpadded = db[(hLen + onePos + 1)..]; - - return unpadded; - } - - static byte[] MGF1(byte[] seed, int maskLen) - { - SHA1 hobj = SHA1.Create(); - int hLen = hobj.HashSize / 8; - List T = new List(); - for (int i = 0; i < (int)Math.Ceiling(((double)maskLen / (double)hLen)); i++) - { - byte[] c = BitConverter.GetBytes(i); - Array.Reverse(c); - byte[] digest = hobj.ComputeHash(seed.Concat(c).ToArray()); - T.AddRange(digest); - } - return T.GetRange(0, maskLen).ToArray(); - } - } -} diff --git a/OF DL/Entities/Archived/Archived.cs b/OF DL/Entities/Archived/Archived.cs deleted file mode 100644 index 7f132e2..0000000 --- a/OF DL/Entities/Archived/Archived.cs +++ /dev/null @@ -1,275 +0,0 @@ -using Newtonsoft.Json; -using OF_DL.Utils; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Archived -{ - public class Archived - { - public List list { get; set; } - public bool hasMore { get; set; } - public string headMarker { get; set; } - public string tailMarker { get; set; } - public Counters counters { get; set; } - public class Author - { - public long id { get; set; } - public string _view { get; set; } - } - - public class Counters - { - public int? audiosCount { get; set; } - public int? photosCount { get; set; } - public int? videosCount { get; set; } - public int? mediasCount { get; set; } - public int? postsCount { get; set; } - public int? streamsCount { get; set; } - public int? archivedPostsCount { get; set; } - } - - public class Dash - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - - public class Drm - { - public Manifest manifest { get; set; } - public Signature signature { get; set; } - } - - public class Files - { - public Full full { get; set; } - public Thumb thumb { get; set; } - public Preview preview { get; set; } - public SquarePreview squarePreview { get; set; } - public Drm drm { get; set; } - } - - public class Full - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public List sources { get; set; } - } - - public class SquarePreview - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class Thumb - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class Hls - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - - public class Info - { - public Source source { get; set; } - public Preview preview { get; set; } - } - - public class LinkedPost - { - public string responseType { get; set; } - public long? id { get; set; } - public DateTime? postedAt { get; set; } - public string postedAtPrecise { get; set; } - public object expiredAt { get; set; } - public Author author { get; set; } - public string text { get; set; } - private string _rawText; - public string rawText - { - get - { - if (string.IsNullOrEmpty(_rawText)) - { - _rawText = XmlUtils.EvaluateInnerText(text); - } - - return _rawText; - } - set - { - _rawText = value; - } - } - public bool? lockedText { get; set; } - public bool? isFavorite { get; set; } - public bool? canReport { get; set; } - public bool? canDelete { get; set; } - public bool? canComment { get; set; } - public bool? canEdit { get; set; } - public bool? isPinned { get; set; } - public int? favoritesCount { get; set; } - public int? mediaCount { get; set; } - public bool? isMediaReady { get; set; } - public object voting { get; set; } - public bool? isOpened { get; set; } - public bool? canToggleFavorite { get; set; } - public object streamId { get; set; } - public string? price { get; set; } - public bool? hasVoting { get; set; } - public bool? isAddedToBookmarks { get; set; } - public bool? isArchived { get; set; } - public bool? isPrivateArchived { get; set; } - public bool? isDeleted { get; set; } - public bool? hasUrl { get; set; } - public bool? isCouplePeopleMedia { get; set; } - public string cantCommentReason { get; set; } - public int? commentsCount { get; set; } - public List mentionedUsers { get; set; } - public List linkedUsers { get; set; } - public List media { get; set; } - public bool? canViewMedia { get; set; } - public List preview { get; set; } - } - - public class List - { - public string responseType { get; set; } - public long id { get; set; } - public DateTime postedAt { get; set; } - public string postedAtPrecise { get; set; } - public object expiredAt { get; set; } - public Author author { get; set; } - public string text { get; set; } - private string _rawText; - public string rawText - { - get - { - if (string.IsNullOrEmpty(_rawText)) - { - _rawText = XmlUtils.EvaluateInnerText(text); - } - - return _rawText; - } - set - { - _rawText = value; - } - } - public bool? lockedText { get; set; } - public bool? isFavorite { get; set; } - public bool? canReport { get; set; } - public bool? canDelete { get; set; } - public bool? canComment { get; set; } - public bool? canEdit { get; set; } - public bool? isPinned { get; set; } - public int? favoritesCount { get; set; } - public int? mediaCount { get; set; } - public bool? isMediaReady { get; set; } - public object voting { get; set; } - public bool isOpened { get; set; } - public bool? canToggleFavorite { get; set; } - public object streamId { get; set; } - public string price { get; set; } - public bool? hasVoting { get; set; } - public bool? isAddedToBookmarks { get; set; } - public bool isArchived { get; set; } - public bool? isPrivateArchived { get; set; } - public bool? isDeleted { get; set; } - public bool? hasUrl { get; set; } - public bool? isCouplePeopleMedia { get; set; } - public int? commentsCount { get; set; } - public List mentionedUsers { get; set; } - public List linkedUsers { get; set; } - public List media { get; set; } - public bool? canViewMedia { get; set; } - public List preview { get; set; } - public string cantCommentReason { get; set; } - } - - public class Manifest - { - public string hls { get; set; } - public string dash { get; set; } - } - - public class Medium - { - public long id { get; set; } - public string type { get; set; } - public bool? convertedToVideo { get; set; } - public bool canView { get; set; } - public bool? hasError { get; set; } - public DateTime? createdAt { get; set; } - public Info info { get; set; } - public Source source { get; set; } - public string squarePreview { get; set; } - public string full { get; set; } - public string preview { get; set; } - public string thumb { get; set; } - public Files files { get; set; } - public VideoSources videoSources { get; set; } - } - - public class Preview - { - public int? width { get; set; } - public int? height { get; set; } - public int? size { get; set; } - public string url { get; set; } - } - - public class Signature - { - public Hls hls { get; set; } - public Dash dash { get; set; } - } - - public class Source - { - public string source { get; set; } - public int? width { get; set; } - public int? height { get; set; } - public int? size { get; set; } - public int? duration { get; set; } - } - - public class VideoSources - { - [JsonProperty("720")] - public string _720 { get; set; } - - [JsonProperty("240")] - public string _240 { get; set; } - } - } -} diff --git a/OF DL/Entities/Archived/ArchivedCollection.cs b/OF DL/Entities/Archived/ArchivedCollection.cs deleted file mode 100644 index 2eb56fb..0000000 --- a/OF DL/Entities/Archived/ArchivedCollection.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Archived -{ - public class ArchivedCollection - { - public Dictionary ArchivedPosts = new Dictionary(); - public List ArchivedPostObjects = new List(); - public List ArchivedPostMedia = new List(); - } -} diff --git a/OF DL/Entities/Auth.cs b/OF DL/Entities/Auth.cs deleted file mode 100644 index 86d9f83..0000000 --- a/OF DL/Entities/Auth.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Newtonsoft.Json; - -namespace OF_DL.Entities -{ - public class Auth - { - public string? USER_ID { get; set; } = string.Empty; - public string? USER_AGENT { get; set; } = string.Empty; - public string? X_BC { get; set; } = string.Empty; - public string? COOKIE { get; set; } = string.Empty; - [JsonIgnore] - public string? FFMPEG_PATH { get; set; } = string.Empty; - } -} diff --git a/OF DL/Entities/CDRMProjectRequest.cs b/OF DL/Entities/CDRMProjectRequest.cs deleted file mode 100644 index b48279b..0000000 --- a/OF DL/Entities/CDRMProjectRequest.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities -{ - public class CDRMProjectRequest - { - [JsonProperty("pssh")] - public string PSSH { get; set; } = ""; - - [JsonProperty("licurl")] - public string LicenseURL { get; set; } = ""; - - [JsonProperty("headers")] - public string Headers { get; set; } = ""; - - [JsonProperty("cookies")] - public string Cookies { get; set; } = ""; - - [JsonProperty("data")] - public string Data { get; set; } = ""; - } -} diff --git a/OF DL/Entities/Config.cs b/OF DL/Entities/Config.cs deleted file mode 100644 index 0934422..0000000 --- a/OF DL/Entities/Config.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using OF_DL.Enumerations; - -namespace OF_DL.Entities -{ - - public class Config : IDownloadConfig, IFileNameFormatConfig - { - [ToggleableConfig] - public bool DownloadAvatarHeaderPhoto { get; set; } = true; - [ToggleableConfig] - public bool DownloadPaidPosts { get; set; } = true; - [ToggleableConfig] - public bool DownloadPosts { get; set; } = true; - [ToggleableConfig] - public bool DownloadArchived { get; set; } = true; - [ToggleableConfig] - public bool DownloadStreams { get; set; } = true; - [ToggleableConfig] - public bool DownloadStories { get; set; } = true; - [ToggleableConfig] - public bool DownloadHighlights { get; set; } = true; - [ToggleableConfig] - public bool DownloadMessages { get; set; } = true; - [ToggleableConfig] - public bool DownloadPaidMessages { get; set; } = true; - [ToggleableConfig] - public bool DownloadImages { get; set; } = true; - [ToggleableConfig] - public bool DownloadVideos { get; set; } = true; - [ToggleableConfig] - public bool DownloadAudios { get; set; } = true; - [ToggleableConfig] - public bool IncludeExpiredSubscriptions { get; set; } = false; - [ToggleableConfig] - public bool IncludeRestrictedSubscriptions { get; set; } = false; - [ToggleableConfig] - public bool SkipAds { get; set; } = false; - - public string? DownloadPath { get; set; } = string.Empty; - public string? PaidPostFileNameFormat { get; set; } = string.Empty; - public string? PostFileNameFormat { get; set; } = string.Empty; - public string? PaidMessageFileNameFormat { get; set; } = string.Empty; - public string? MessageFileNameFormat { get; set; } = string.Empty; - [ToggleableConfig] - public bool RenameExistingFilesWhenCustomFormatIsSelected { get; set; } = false; - public int? Timeout { get; set; } = -1; - [ToggleableConfig] - public bool FolderPerPaidPost { get; set; } = false; - [ToggleableConfig] - public bool FolderPerPost { get; set; } = false; - [ToggleableConfig] - public bool FolderPerPaidMessage { get; set; } = false; - [ToggleableConfig] - public bool FolderPerMessage { get; set; } = false; - [ToggleableConfig] - public bool LimitDownloadRate { get; set; } = false; - public int DownloadLimitInMbPerSec { get; set; } = 4; - - // Indicates if you want to download only on specific dates. - [ToggleableConfig] - public bool DownloadOnlySpecificDates { get; set; } = false; - - // This enum will define if we want data from before or after the CustomDate. - [JsonConverter(typeof(StringEnumConverter))] - public DownloadDateSelection DownloadDateSelection { get; set; } = DownloadDateSelection.before; - // This is the specific date used in combination with the above enum. - - [JsonConverter(typeof(ShortDateConverter))] - public DateTime? CustomDate { get; set; } = null; - - [ToggleableConfig] - public bool ShowScrapeSize { get; set; } = false; - - [ToggleableConfig] - public bool DownloadPostsIncrementally { get; set; } = false; - - public bool NonInteractiveMode { get; set; } = false; - public string NonInteractiveModeListName { get; set; } = string.Empty; - [ToggleableConfig] - public bool NonInteractiveModePurchasedTab { get; set; } = false; - public string? FFmpegPath { get; set; } = string.Empty; - - [ToggleableConfig] - public bool BypassContentForCreatorsWhoNoLongerExist { get; set; } = false; - - public Dictionary CreatorConfigs { get; set; } = new Dictionary(); - - [ToggleableConfig] - public bool DownloadDuplicatedMedia { get; set; } = false; - - public string IgnoredUsersListName { get; set; } = string.Empty; - - [JsonConverter(typeof(StringEnumConverter))] - public LoggingLevel LoggingLevel { get; set; } = LoggingLevel.Error; - - [ToggleableConfig] - public bool IgnoreOwnMessages { get; set; } = false; - - [ToggleableConfig] - public bool DisableBrowserAuth { get; set; } = false; - - [JsonConverter(typeof(StringEnumConverter))] - public VideoResolution DownloadVideoResolution { get; set; } = VideoResolution.source; - - // When enabled, post/message text is stored as-is without XML stripping. - [ToggleableConfig] - public bool DisableTextSanitization { get; set; } = false; - } - - public class CreatorConfig : IFileNameFormatConfig - { - public string? PaidPostFileNameFormat { get; set; } - public string? PostFileNameFormat { get; set; } - public string? PaidMessageFileNameFormat { get; set; } - public string? MessageFileNameFormat { get; set; } - } - -} diff --git a/OF DL/Entities/DynamicRules.cs b/OF DL/Entities/DynamicRules.cs deleted file mode 100644 index eb41a7c..0000000 --- a/OF DL/Entities/DynamicRules.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Newtonsoft.Json; - -namespace OF_DL.Entities -{ - public class DynamicRules - { - [JsonProperty(PropertyName="app-token")] - public string? AppToken { get; set; } - - [JsonProperty(PropertyName="app_token")] - private string AppToken2 { set { AppToken = value; } } - - [JsonProperty(PropertyName="static_param")] - public string? StaticParam { get; set; } - - [JsonProperty(PropertyName="prefix")] - public string? Prefix { get; set; } - - [JsonProperty(PropertyName="suffix")] - public string? Suffix { get; set; } - - [JsonProperty(PropertyName="checksum_constant")] - public int? ChecksumConstant { get; set; } - - [JsonProperty(PropertyName = "checksum_indexes")] - public List ChecksumIndexes { get; set; } - } -} diff --git a/OF DL/Entities/FileNameFormatConfig.cs b/OF DL/Entities/FileNameFormatConfig.cs deleted file mode 100644 index 73b7b73..0000000 --- a/OF DL/Entities/FileNameFormatConfig.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace OF_DL.Entities -{ - public class FileNameFormatConfig : IFileNameFormatConfig - { - public string? PaidPostFileNameFormat { get; set; } - public string? PostFileNameFormat { get; set; } - public string? PaidMessageFileNameFormat { get; set; } - public string? MessageFileNameFormat { get; set; } - } - -} diff --git a/OF DL/Entities/Highlights/HighlightMedia.cs b/OF DL/Entities/Highlights/HighlightMedia.cs deleted file mode 100644 index af51136..0000000 --- a/OF DL/Entities/Highlights/HighlightMedia.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Highlights -{ - public class HighlightMedia - { - public long id { get; set; } - public long userId { get; set; } - public string title { get; set; } - public long coverStoryId { get; set; } - public string cover { get; set; } - public int storiesCount { get; set; } - public DateTime? createdAt { get; set; } - public List stories { get; set; } - public class Files - { - public Full full { get; set; } - public Thumb thumb { get; set; } - public Preview preview { get; set; } - public SquarePreview squarePreview { get; set; } - } - - public class Full - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public List sources { get; set; } - } - - public class Medium - { - public long id { get; set; } - public string type { get; set; } - public bool convertedToVideo { get; set; } - public bool canView { get; set; } - public bool hasError { get; set; } - public DateTime? createdAt { get; set; } - public Files files { get; set; } - } - - public class Preview - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public Sources sources { get; set; } - } - - public class Source - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public int duration { get; set; } - public long size { get; set; } - public Sources sources { get; set; } - } - - public class Sources - { - [JsonProperty("720")] - public string _720 { get; set; } - - [JsonProperty("240")] - public string _240 { get; set; } - public string w150 { get; set; } - public string w480 { get; set; } - } - - public class SquarePreview - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public Sources sources { get; set; } - } - - public class Story - { - public long id { get; set; } - public long userId { get; set; } - public bool isWatched { get; set; } - public bool isReady { get; set; } - public List media { get; set; } - public DateTime? createdAt { get; set; } - public object question { get; set; } - public bool canLike { get; set; } - public bool isLiked { get; set; } - } - - public class Thumb - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - } -} diff --git a/OF DL/Entities/Highlights/Highlights.cs b/OF DL/Entities/Highlights/Highlights.cs deleted file mode 100644 index fbcd4dc..0000000 --- a/OF DL/Entities/Highlights/Highlights.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Highlights -{ - public class Highlights - { - public List list { get; set; } - public bool hasMore { get; set; } - public class List - { - public long id { get; set; } - public long userId { get; set; } - public string title { get; set; } - public long coverStoryId { get; set; } - public string cover { get; set; } - public int storiesCount { get; set; } - public DateTime? createdAt { get; set; } - } - } -} diff --git a/OF DL/Entities/IDownloadConfig.cs b/OF DL/Entities/IDownloadConfig.cs deleted file mode 100644 index 21565d0..0000000 --- a/OF DL/Entities/IDownloadConfig.cs +++ /dev/null @@ -1,56 +0,0 @@ -using OF_DL.Enumerations; - -namespace OF_DL.Entities -{ - public interface IDownloadConfig - { - bool DownloadAvatarHeaderPhoto { get; set; } - bool DownloadPaidPosts { get; set; } - bool DownloadPosts { get; set; } - bool DownloadArchived { get; set; } - bool DownloadStreams { get; set; } - bool DownloadStories { get; set; } - bool DownloadHighlights { get; set; } - bool DownloadMessages { get; set; } - bool DownloadPaidMessages { get; set; } - bool DownloadImages { get; set; } - bool DownloadVideos { get; set; } - bool DownloadAudios { get; set; } - - VideoResolution DownloadVideoResolution { get; set; } - - int? Timeout { get; set; } - bool FolderPerPaidPost { get; set; } - bool FolderPerPost { get; set; } - bool FolderPerPaidMessage { get; set; } - bool FolderPerMessage { get; set; } - - bool RenameExistingFilesWhenCustomFormatIsSelected { get; set; } - bool ShowScrapeSize { get; set; } - bool LimitDownloadRate { get; set; } - int DownloadLimitInMbPerSec { get; set; } - string? FFmpegPath { get; set; } - - bool SkipAds { get; set; } - bool BypassContentForCreatorsWhoNoLongerExist { get; set; } - - #region Download Date Configurations - - bool DownloadOnlySpecificDates { get; set; } - - // This enum will define if we want data from before or after the CustomDate. - DownloadDateSelection DownloadDateSelection { get; set; } - - // This is the specific date used in combination with the above enum. - DateTime? CustomDate { get; set; } - #endregion - - bool DownloadPostsIncrementally { get; set; } - - bool DownloadDuplicatedMedia { get; set; } - public LoggingLevel LoggingLevel { get; set; } - - bool IgnoreOwnMessages { get; set; } - } - -} diff --git a/OF DL/Entities/IFileNameFormatConfig.cs b/OF DL/Entities/IFileNameFormatConfig.cs deleted file mode 100644 index 1c3bc3f..0000000 --- a/OF DL/Entities/IFileNameFormatConfig.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace OF_DL.Entities -{ - public interface IFileNameFormatConfig - { - string? PaidPostFileNameFormat { get; set; } - string? PostFileNameFormat { get; set; } - string? PaidMessageFileNameFormat { get; set; } - string? MessageFileNameFormat { get; set; } - } - -} diff --git a/OF DL/Entities/Lists/UserList.cs b/OF DL/Entities/Lists/UserList.cs deleted file mode 100644 index cc42563..0000000 --- a/OF DL/Entities/Lists/UserList.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Lists -{ - public class UserList - { - public List list { get; set; } - public bool? hasMore { get; set; } - public class List - { - public string id { get; set; } - public string type { get; set; } - public string name { get; set; } - public int? usersCount { get; set; } - public int? postsCount { get; set; } - public bool? canUpdate { get; set; } - public bool? canDelete { get; set; } - public bool? canManageUsers { get; set; } - public bool? canAddUsers { get; set; } - public bool? canPinnedToFeed { get; set; } - public bool? isPinnedToFeed { get; set; } - public bool? canPinnedToChat { get; set; } - public bool? isPinnedToChat { get; set; } - public string order { get; set; } - public string direction { get; set; } - public List users { get; set; } - public List customOrderUsersIds { get; set; } - public List posts { get; set; } - } - - public class User - { - public long? id { get; set; } - public string _view { get; set; } - } - } -} diff --git a/OF DL/Entities/Lists/UsersList.cs b/OF DL/Entities/Lists/UsersList.cs deleted file mode 100644 index 010db51..0000000 --- a/OF DL/Entities/Lists/UsersList.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Lists -{ - public class UsersList - { - public string view { get; set; } - public string avatar { get; set; } - public AvatarThumbs avatarThumbs { get; set; } - public string header { get; set; } - public HeaderSize headerSize { get; set; } - public HeaderThumbs headerThumbs { get; set; } - public long? id { get; set; } - public string name { get; set; } - public string username { get; set; } - public bool? canLookStory { get; set; } - public bool? canCommentStory { get; set; } - public bool? hasNotViewedStory { get; set; } - public bool? isVerified { get; set; } - public bool? canPayInternal { get; set; } - public bool? hasScheduledStream { get; set; } - public bool? hasStream { get; set; } - public bool? hasStories { get; set; } - public bool? tipsEnabled { get; set; } - public bool? tipsTextEnabled { get; set; } - public int? tipsMin { get; set; } - public int? tipsMinInternal { get; set; } - public int? tipsMax { get; set; } - public bool? canEarn { get; set; } - public bool? canAddSubscriber { get; set; } - public string? subscribePrice { get; set; } - public List subscriptionBundles { get; set; } - public string displayName { get; set; } - public string notice { get; set; } - public bool? isPaywallRequired { get; set; } - public bool? unprofitable { get; set; } - public List listsStates { get; set; } - public bool? isMuted { get; set; } - public bool? isRestricted { get; set; } - public bool? canRestrict { get; set; } - public bool? subscribedBy { get; set; } - public bool? subscribedByExpire { get; set; } - public DateTime? subscribedByExpireDate { get; set; } - public bool? subscribedByAutoprolong { get; set; } - public bool? subscribedIsExpiredNow { get; set; } - public string? currentSubscribePrice { get; set; } - public bool? subscribedOn { get; set; } - public bool? subscribedOnExpiredNow { get; set; } - public string subscribedOnDuration { get; set; } - public bool? canReport { get; set; } - public bool? canReceiveChatMessage { get; set; } - public bool? hideChat { get; set; } - public DateTime? lastSeen { get; set; } - public bool? isPerformer { get; set; } - public bool? isRealPerformer { get; set; } - public SubscribedByData subscribedByData { get; set; } - public SubscribedOnData subscribedOnData { get; set; } - public bool? canTrialSend { get; set; } - public bool? isBlocked { get; set; } - public List promoOffers { get; set; } - public class AvatarThumbs - { - public string c50 { get; set; } - public string c144 { get; set; } - } - - public class HeaderSize - { - public int? width { get; set; } - public int? height { get; set; } - } - - public class HeaderThumbs - { - public string w480 { get; set; } - public string w760 { get; set; } - } - - public class ListsState - { - public string id { get; set; } - public string type { get; set; } - public string name { get; set; } - public bool hasUser { get; set; } - public bool canAddUser { get; set; } - } - - public class Subscribe - { - public object id { get; set; } - public long? userId { get; set; } - public int? subscriberId { get; set; } - public DateTime? date { get; set; } - public int? duration { get; set; } - public DateTime? startDate { get; set; } - public DateTime? expireDate { get; set; } - public object cancelDate { get; set; } - public string? price { get; set; } - public string? regularPrice { get; set; } - public string? discount { get; set; } - public string action { get; set; } - public string type { get; set; } - public object offerStart { get; set; } - public object offerEnd { get; set; } - public bool? isCurrent { get; set; } - } - - public class SubscribedByData - { - public string? price { get; set; } - public string? newPrice { get; set; } - public string? regularPrice { get; set; } - public string? subscribePrice { get; set; } - public string? discountPercent { get; set; } - public string? discountPeriod { get; set; } - public DateTime? subscribeAt { get; set; } - public DateTime? expiredAt { get; set; } - public object renewedAt { get; set; } - public object discountFinishedAt { get; set; } - public object discountStartedAt { get; set; } - public string status { get; set; } - public bool? isMuted { get; set; } - public string unsubscribeReason { get; set; } - public string duration { get; set; } - public bool? showPostsInFeed { get; set; } - public List subscribes { get; set; } - } - - public class SubscribedOnData - { - public string? price { get; set; } - public string? newPrice { get; set; } - public string? regularPrice { get; set; } - public string? subscribePrice { get; set; } - public string? discountPercent { get; set; } - public string? discountPeriod { get; set; } - public DateTime? subscribeAt { get; set; } - public DateTime? expiredAt { get; set; } - public object renewedAt { get; set; } - public object discountFinishedAt { get; set; } - public object discountStartedAt { get; set; } - public object status { get; set; } - public bool? isMuted { get; set; } - public string unsubscribeReason { get; set; } - public string duration { get; set; } - public string? tipsSumm { get; set; } - public string? subscribesSumm { get; set; } - public string? messagesSumm { get; set; } - public string? postsSumm { get; set; } - public string? streamsSumm { get; set; } - public string? totalSumm { get; set; } - public DateTime? lastActivity { get; set; } - public int? recommendations { get; set; } - public List subscribes { get; set; } - } - - public class SubscriptionBundle - { - public long? id { get; set; } - public string? discount { get; set; } - public string? duration { get; set; } - public string? price { get; set; } - public bool? canBuy { get; set; } - } - - } -} diff --git a/OF DL/Entities/Messages/MessageCollection.cs b/OF DL/Entities/Messages/MessageCollection.cs deleted file mode 100644 index dd01461..0000000 --- a/OF DL/Entities/Messages/MessageCollection.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Messages -{ - public class MessageCollection - { - public Dictionary Messages = new Dictionary(); - public List MessageObjects = new List(); - public List MessageMedia = new List(); - } -} diff --git a/OF DL/Entities/Messages/Messages.cs b/OF DL/Entities/Messages/Messages.cs deleted file mode 100644 index c211368..0000000 --- a/OF DL/Entities/Messages/Messages.cs +++ /dev/null @@ -1,184 +0,0 @@ -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Messages -{ - public class Messages - { - public List list { get; set; } - public bool hasMore { get; set; } - public class Dash - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - - public class Drm - { - public Manifest manifest { get; set; } - public Signature signature { get; set; } - } - - public class Files - { - public Full full { get; set; } - public Thumb thumb { get; set; } - public Preview preview { get; set; } - public SquarePreview squarePreview { get; set; } - public Drm drm { get; set; } - } - - public class Full - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public List sources { get; set; } - } - - public class SquarePreview - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class Thumb - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class FromUser - { - public long? id { get; set; } - public string _view { get; set; } - } - - public class Hls - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - - public class Info - { - public Source source { get; set; } - public Preview preview { get; set; } - } - - public class List - { - public string responseType { get; set; } - public string text { get; set; } - public object giphyId { get; set; } - public bool? lockedText { get; set; } - public bool? isFree { get; set; } - public string? price { get; set; } - public bool? isMediaReady { get; set; } - public int? mediaCount { get; set; } - public List media { get; set; } - public List previews { get; set; } - public bool? isTip { get; set; } - public bool? isReportedByMe { get; set; } - public bool? isCouplePeopleMedia { get; set; } - public object queueId { get; set; } - public FromUser fromUser { get; set; } - public bool? isFromQueue { get; set; } - public bool? canUnsendQueue { get; set; } - public int? unsendSecondsQueue { get; set; } - public long id { get; set; } - public bool? isOpened { get; set; } - public bool? isNew { get; set; } - public DateTime? createdAt { get; set; } - public DateTime? changedAt { get; set; } - public int? cancelSeconds { get; set; } - public bool? isLiked { get; set; } - public bool? canPurchase { get; set; } - public string canPurchaseReason { get; set; } - public bool? canReport { get; set; } - public bool? canBePinned { get; set; } - public bool? isPinned { get; set; } - } - - public class Manifest - { - public string hls { get; set; } - public string dash { get; set; } - } - - public class Medium - { - public long id { get; set; } - public bool canView { get; set; } - public string type { get; set; } - public string src { get; set; } - public string preview { get; set; } - public string thumb { get; set; } - public object locked { get; set; } - public int? duration { get; set; } - public bool? hasError { get; set; } - public string squarePreview { get; set; } - public Video video { get; set; } - public VideoSources videoSources { get; set; } - public Source source { get; set; } - public Info info { get; set; } - public Files files { get; set; } - } - - public class Preview - { - public int? width { get; set; } - public int? height { get; set; } - public int? size { get; set; } - } - - public class Signature - { - public Hls hls { get; set; } - public Dash dash { get; set; } - } - - public class Source - { - public string source { get; set; } - public int? width { get; set; } - public int? height { get; set; } - public int? size { get; set; } - } - - public class Video - { - public string mp4 { get; set; } - } - - public class VideoSources - { - [JsonProperty("720")] - public string _720 { get; set; } - - [JsonProperty("240")] - public string _240 { get; set; } - } - } -} diff --git a/OF DL/Entities/Messages/SingleMessage.cs b/OF DL/Entities/Messages/SingleMessage.cs deleted file mode 100644 index 2e013f4..0000000 --- a/OF DL/Entities/Messages/SingleMessage.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Newtonsoft.Json; -using static OF_DL.Entities.Messages.Messages; - -namespace OF_DL.Entities.Messages -{ - public class AvatarThumbs - { - public string c50 { get; set; } - public string c144 { get; set; } - } - - public class FromUser - { - public string view { get; set; } - public string avatar { get; set; } - public AvatarThumbs avatarThumbs { get; set; } - public string header { get; set; } - public HeaderSize headerSize { get; set; } - public HeaderThumbs headerThumbs { get; set; } - public long? id { get; set; } - public string name { get; set; } - public string username { get; set; } - public bool canLookStory { get; set; } - public bool canCommentStory { get; set; } - public bool hasNotViewedStory { get; set; } - public bool isVerified { get; set; } - public bool canPayInternal { get; set; } - public bool hasScheduledStream { get; set; } - public bool hasStream { get; set; } - public bool hasStories { get; set; } - public bool tipsEnabled { get; set; } - public bool tipsTextEnabled { get; set; } - public int tipsMin { get; set; } - public int tipsMinInternal { get; set; } - public int tipsMax { get; set; } - public bool canEarn { get; set; } - public bool canAddSubscriber { get; set; } - public string? subscribePrice { get; set; } - public List subscriptionBundles { get; set; } - public bool isPaywallRequired { get; set; } - public List listsStates { get; set; } - public bool isRestricted { get; set; } - public bool canRestrict { get; set; } - public object subscribedBy { get; set; } - public object subscribedByExpire { get; set; } - public DateTime subscribedByExpireDate { get; set; } - public object subscribedByAutoprolong { get; set; } - public bool subscribedIsExpiredNow { get; set; } - public object currentSubscribePrice { get; set; } - public object subscribedOn { get; set; } - public object subscribedOnExpiredNow { get; set; } - public object subscribedOnDuration { get; set; } - public int callPrice { get; set; } - public DateTime? lastSeen { get; set; } - public bool canReport { get; set; } - } - - public class HeaderSize - { - public int width { get; set; } - public int height { get; set; } - } - - public class HeaderThumbs - { - public string w480 { get; set; } - public string w760 { get; set; } - } - - public class ListsState - { - public string id { get; set; } - public string type { get; set; } - public string name { get; set; } - public bool hasUser { get; set; } - public bool canAddUser { get; set; } - public string cannotAddUserReason { get; set; } - } - - public class Preview - { - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class SingleMessage - { - public string responseType { get; set; } - public string text { get; set; } - public object giphyId { get; set; } - public bool lockedText { get; set; } - public bool isFree { get; set; } - public double price { get; set; } - public bool isMediaReady { get; set; } - public int mediaCount { get; set; } - public List media { get; set; } - public List previews { get; set; } - public bool isTip { get; set; } - public bool isReportedByMe { get; set; } - public bool isCouplePeopleMedia { get; set; } - public long queueId { get; set; } - public FromUser fromUser { get; set; } - public bool isFromQueue { get; set; } - public bool canUnsendQueue { get; set; } - public int unsendSecondsQueue { get; set; } - public long id { get; set; } - public bool isOpened { get; set; } - public bool isNew { get; set; } - public DateTime? createdAt { get; set; } - public DateTime? changedAt { get; set; } - public int cancelSeconds { get; set; } - public bool isLiked { get; set; } - public bool canPurchase { get; set; } - public bool canReport { get; set; } - } - -} - diff --git a/OF DL/Entities/OFDLRequest.cs b/OF DL/Entities/OFDLRequest.cs deleted file mode 100644 index 56060b9..0000000 --- a/OF DL/Entities/OFDLRequest.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities -{ - public class OFDLRequest - { - [JsonProperty("pssh")] - public string PSSH { get; set; } = ""; - - [JsonProperty("licenceURL")] - public string LicenseURL { get; set; } = ""; - - [JsonProperty("headers")] - public string Headers { get; set; } = ""; - } -} diff --git a/OF DL/Entities/Post/Post.cs b/OF DL/Entities/Post/Post.cs deleted file mode 100644 index d7b661b..0000000 --- a/OF DL/Entities/Post/Post.cs +++ /dev/null @@ -1,213 +0,0 @@ -using Newtonsoft.Json; -using OF_DL.Utils; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using static OF_DL.Entities.Messages.Messages; - -namespace OF_DL.Entities.Post; - -#pragma warning disable IDE1006 // Naming Styles -public class Post -{ - public List list { get; set; } - public bool hasMore { get; set; } - - public string headMarker { get; set; } - - public string tailMarker { get; set; } - public class Author - { - public long id { get; set; } - public string _view { get; set; } - } - - public class Dash - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - - public class Drm - { - public Manifest manifest { get; set; } - public Signature signature { get; set; } - } - - public class Files - { - public Full full { get; set; } - public Thumb thumb { get; set; } - public Preview preview { get; set; } - public SquarePreview squarePreview { get; set; } - public Drm drm { get; set; } - } - - public class Full - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public List sources { get; set; } - } - - public class SquarePreview - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class Thumb - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class Hls - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - - public class Info - { - public Source source { get; set; } - public Preview preview { get; set; } - } - - public class List - { - public string responseType { get; set; } - public long id { get; set; } - public DateTime postedAt { get; set; } - public string postedAtPrecise { get; set; } - public object expiredAt { get; set; } - public Author author { get; set; } - public string text { get; set; } - - private string _rawText; - public string rawText - { - get - { - if(string.IsNullOrEmpty(_rawText)) - { - _rawText = XmlUtils.EvaluateInnerText(text); - } - - return _rawText; - } - set - { - _rawText = value; - } - } - public bool? lockedText { get; set; } - public bool? isFavorite { get; set; } - public bool? canReport { get; set; } - public bool? canDelete { get; set; } - public bool? canComment { get; set; } - public bool? canEdit { get; set; } - public bool? isPinned { get; set; } - public int? favoritesCount { get; set; } - public int? mediaCount { get; set; } - public bool? isMediaReady { get; set; } - public object voting { get; set; } - public bool isOpened { get; set; } - public bool? canToggleFavorite { get; set; } - public object streamId { get; set; } - public string? price { get; set; } - public bool? hasVoting { get; set; } - public bool? isAddedToBookmarks { get; set; } - public bool isArchived { get; set; } - public bool? isPrivateArchived { get; set; } - public bool? isDeleted { get; set; } - public bool? hasUrl { get; set; } - public bool? isCouplePeopleMedia { get; set; } - public string cantCommentReason { get; set; } - public int? votingType { get; set; } - public int? commentsCount { get; set; } - public List mentionedUsers { get; set; } - public List linkedUsers { get; set; } - public bool? canVote { get; set; } - public List media { get; set; } - public bool? canViewMedia { get; set; } - public List preview { get; set; } - } - - public class Manifest - { - public string? hls { get; set; } - public string? dash { get; set; } - } - - public class Medium - { - public long id { get; set; } - public string type { get; set; } - public bool? convertedToVideo { get; set; } - public bool canView { get; set; } - public bool? hasError { get; set; } - public DateTime? createdAt { get; set; } - public Info info { get; set; } - public Source source { get; set; } - public string squarePreview { get; set; } - public string full { get; set; } - public string preview { get; set; } - public string thumb { get; set; } - public Files files { get; set; } - public VideoSources videoSources { get; set; } - } - - public class Preview - { - public int? width { get; set; } - public int? height { get; set; } - public int? size { get; set; } - public string url { get; set; } - } - - public class Signature - { - public Hls hls { get; set; } - public Dash dash { get; set; } - } - - public class Source - { - public string? source { get; set; } - public int? width { get; set; } - public int? height { get; set; } - public int? size { get; set; } - public int? duration { get; set; } - } - - public class VideoSources - { - [JsonProperty("720")] - public object _720 { get; set; } - - [JsonProperty("240")] - public object _240 { get; set; } - } -#pragma warning restore IDE1006 // Naming Styles -} diff --git a/OF DL/Entities/Post/PostCollection.cs b/OF DL/Entities/Post/PostCollection.cs deleted file mode 100644 index 1ed0478..0000000 --- a/OF DL/Entities/Post/PostCollection.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Post -{ - public class PostCollection - { - public Dictionary Posts = new Dictionary(); - public List PostObjects = new List(); - public List PostMedia = new List(); - } -} diff --git a/OF DL/Entities/Post/SinglePost.cs b/OF DL/Entities/Post/SinglePost.cs deleted file mode 100644 index ef37c40..0000000 --- a/OF DL/Entities/Post/SinglePost.cs +++ /dev/null @@ -1,197 +0,0 @@ -using Newtonsoft.Json; -using OF_DL.Utils; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using static OF_DL.Entities.Post.Post; - -namespace OF_DL.Entities.Post -{ - public class SinglePost - { - public string responseType { get; set; } - public long id { get; set; } - public DateTime postedAt { get; set; } - public string postedAtPrecise { get; set; } - public object expiredAt { get; set; } - public Author author { get; set; } - public string text { get; set; } - private string _rawText; - public string rawText - { - get - { - if (string.IsNullOrEmpty(_rawText)) - { - _rawText = XmlUtils.EvaluateInnerText(text); - } - - return _rawText; - } - set - { - _rawText = value; - } - } - public bool lockedText { get; set; } - public bool isFavorite { get; set; } - public bool canReport { get; set; } - public bool canDelete { get; set; } - public bool canComment { get; set; } - public bool canEdit { get; set; } - public bool isPinned { get; set; } - public int favoritesCount { get; set; } - public int mediaCount { get; set; } - public bool isMediaReady { get; set; } - public object voting { get; set; } - public bool isOpened { get; set; } - public bool canToggleFavorite { get; set; } - public string streamId { get; set; } - public string price { get; set; } - public bool hasVoting { get; set; } - public bool isAddedToBookmarks { get; set; } - public bool isArchived { get; set; } - public bool isPrivateArchived { get; set; } - public bool isDeleted { get; set; } - public bool hasUrl { get; set; } - public bool isCouplePeopleMedia { get; set; } - public int commentsCount { get; set; } - public List mentionedUsers { get; set; } - public List linkedUsers { get; set; } - public string tipsAmount { get; set; } - public string tipsAmountRaw { get; set; } - public List media { get; set; } - public bool canViewMedia { get; set; } - public List preview { get; set; } - public class Author - { - public long id { get; set; } - public string _view { get; set; } - } - - public class Files - { - public Full full { get; set; } - public Thumb thumb { get; set; } - public Preview preview { get; set; } - public SquarePreview squarePreview { get; set; } - public Drm drm { get; set; } - } - - public class Full - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public List sources { get; set; } - } - - public class SquarePreview - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class Thumb - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class Info - { - public Source source { get; set; } - public Preview preview { get; set; } - } - - public class Medium - { - public long id { get; set; } - public string type { get; set; } - public bool convertedToVideo { get; set; } - public bool canView { get; set; } - public bool hasError { get; set; } - public DateTime? createdAt { get; set; } - public Info info { get; set; } - public Source source { get; set; } - public string squarePreview { get; set; } - public string full { get; set; } - public string preview { get; set; } - public string thumb { get; set; } - public bool hasCustomPreview { get; set; } - public Files files { get; set; } - public VideoSources videoSources { get; set; } - } - - public class Preview - { - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public string url { get; set; } - } - - public class Source - { - public string source { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public int duration { get; set; } - } - - public class VideoSources - { - [JsonProperty("720")] - public string _720 { get; set; } - - [JsonProperty("240")] - public string _240 { get; set; } - } - public class Dash - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - - public class Drm - { - public Manifest manifest { get; set; } - public Signature signature { get; set; } - } - public class Hls - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - public class Manifest - { - public string? hls { get; set; } - public string? dash { get; set; } - } - public class Signature - { - public Hls hls { get; set; } - public Dash dash { get; set; } - } - } -} diff --git a/OF DL/Entities/Post/SinglePostCollection.cs b/OF DL/Entities/Post/SinglePostCollection.cs deleted file mode 100644 index aceedb5..0000000 --- a/OF DL/Entities/Post/SinglePostCollection.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Post -{ - public class SinglePostCollection - { - public Dictionary SinglePosts = new Dictionary(); - public List SinglePostObjects = new List(); - public List SinglePostMedia = new List(); - } -} diff --git a/OF DL/Entities/Purchased/PaidMessageCollection.cs b/OF DL/Entities/Purchased/PaidMessageCollection.cs deleted file mode 100644 index 1222ba0..0000000 --- a/OF DL/Entities/Purchased/PaidMessageCollection.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using static OF_DL.Entities.Messages.Messages; - -namespace OF_DL.Entities.Purchased -{ - public class PaidMessageCollection - { - public Dictionary PaidMessages = new Dictionary(); - public List PaidMessageObjects = new List(); - public List PaidMessageMedia = new List(); - } -} diff --git a/OF DL/Entities/Purchased/PaidPostCollection.cs b/OF DL/Entities/Purchased/PaidPostCollection.cs deleted file mode 100644 index 2e86d97..0000000 --- a/OF DL/Entities/Purchased/PaidPostCollection.cs +++ /dev/null @@ -1,11 +0,0 @@ -using static OF_DL.Entities.Messages.Messages; - -namespace OF_DL.Entities.Purchased -{ - public class PaidPostCollection - { - public Dictionary PaidPosts = new Dictionary(); - public List PaidPostObjects = new List(); - public List PaidPostMedia = new List(); - } -} diff --git a/OF DL/Entities/Purchased/Purchased.cs b/OF DL/Entities/Purchased/Purchased.cs deleted file mode 100644 index 64ae348..0000000 --- a/OF DL/Entities/Purchased/Purchased.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using static OF_DL.Entities.Messages.Messages; - -namespace OF_DL.Entities.Purchased -{ - public class Purchased - { - public List list { get; set; } - public bool hasMore { get; set; } - - public class FromUser - { - public long id { get; set; } - public string _view { get; set; } - } - public class Author - { - public long id { get; set; } - public string _view { get; set; } - } - - public class Hls - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - - - public class List - { - public string responseType { get; set; } - public string text { get; set; } - public object giphyId { get; set; } - public bool? lockedText { get; set; } - public bool? isFree { get; set; } - public string? price { get; set; } - public bool? isMediaReady { get; set; } - public int? mediaCount { get; set; } - public List media { get; set; } - public List previews { get; set; } - public List preview { get; set; } - public bool? isTip { get; set; } - public bool? isReportedByMe { get; set; } - public bool? isCouplePeopleMedia { get; set; } - public object queueId { get; set; } - public FromUser fromUser { get; set; } - public Author author { get; set; } - public bool? isFromQueue { get; set; } - public bool? canUnsendQueue { get; set; } - public int? unsendSecondsQueue { get; set; } - public long id { get; set; } - public bool isOpened { get; set; } - public bool? isNew { get; set; } - public DateTime? createdAt { get; set; } - public DateTime? postedAt { get; set; } - public DateTime? changedAt { get; set; } - public int? cancelSeconds { get; set; } - public bool? isLiked { get; set; } - public bool? canPurchase { get; set; } - public bool? canReport { get; set; } - public bool? isCanceled { get; set; } - public bool? isArchived { get; set; } - } - - public class Manifest - { - public string hls { get; set; } - public string dash { get; set; } - } - } -} diff --git a/OF DL/Entities/Purchased/PurchasedTabCollection.cs b/OF DL/Entities/Purchased/PurchasedTabCollection.cs deleted file mode 100644 index f3af333..0000000 --- a/OF DL/Entities/Purchased/PurchasedTabCollection.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Purchased -{ - public class PurchasedTabCollection - { - public long UserId { get; set; } - public string Username { get; set; } = string.Empty; - public PaidPostCollection PaidPosts { get; set; } = new PaidPostCollection(); - public PaidMessageCollection PaidMessages { get; set; } = new PaidMessageCollection(); - } -} diff --git a/OF DL/Entities/Purchased/SinglePaidMessageCollection.cs b/OF DL/Entities/Purchased/SinglePaidMessageCollection.cs deleted file mode 100644 index 625b6ff..0000000 --- a/OF DL/Entities/Purchased/SinglePaidMessageCollection.cs +++ /dev/null @@ -1,21 +0,0 @@ -using OF_DL.Entities.Messages; -using OF_DL.Entities.Post; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using static OF_DL.Entities.Messages.Messages; - -namespace OF_DL.Entities.Purchased -{ - public class SinglePaidMessageCollection - { - public Dictionary SingleMessages = new Dictionary(); - public List SingleMessageObjects = new List(); - public List SingleMessageMedia = new List(); - - public Dictionary PreviewSingleMessages = new Dictionary(); - public List PreviewSingleMessageMedia = new List(); - } -} diff --git a/OF DL/Entities/ShortDateConverter.cs b/OF DL/Entities/ShortDateConverter.cs deleted file mode 100644 index 2ba2cbd..0000000 --- a/OF DL/Entities/ShortDateConverter.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Newtonsoft.Json.Converters; -using System.Runtime.Serialization; - -namespace OF_DL.Entities -{ - public class ShortDateConverter : IsoDateTimeConverter - { - public ShortDateConverter() - { - DateTimeFormat = "yyyy-MM-dd"; - } - } -} diff --git a/OF DL/Entities/Stories/Stories.cs b/OF DL/Entities/Stories/Stories.cs deleted file mode 100644 index 1f2efcd..0000000 --- a/OF DL/Entities/Stories/Stories.cs +++ /dev/null @@ -1,96 +0,0 @@ -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Stories -{ - public class Stories - { - public long id { get; set; } - public long userId { get; set; } - public bool isWatched { get; set; } - public bool isReady { get; set; } - public List media { get; set; } - public DateTime? createdAt { get; set; } - public object question { get; set; } - public bool canLike { get; set; } - public bool isLiked { get; set; } - public class Files - { - public Full full { get; set; } - public Thumb thumb { get; set; } - public Preview preview { get; set; } - public SquarePreview squarePreview { get; set; } - } - - public class Full - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public List sources { get; set; } - } - - public class Medium - { - public long id { get; set; } - public string type { get; set; } - public bool convertedToVideo { get; set; } - public bool canView { get; set; } - public bool hasError { get; set; } - public DateTime? createdAt { get; set; } - public Files files { get; set; } - } - - public class Preview - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public Sources sources { get; set; } - } - - public class Source - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public int duration { get; set; } - public long size { get; set; } - public Sources sources { get; set; } - } - - public class Sources - { - [JsonProperty("720")] - public object _720 { get; set; } - - [JsonProperty("240")] - public object _240 { get; set; } - public string w150 { get; set; } - public string w480 { get; set; } - } - - public class SquarePreview - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public Sources sources { get; set; } - } - - public class Thumb - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - } -} diff --git a/OF DL/Entities/Streams/Streams.cs b/OF DL/Entities/Streams/Streams.cs deleted file mode 100644 index 2ad51e3..0000000 --- a/OF DL/Entities/Streams/Streams.cs +++ /dev/null @@ -1,216 +0,0 @@ -using Newtonsoft.Json; -using OF_DL.Utils; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Streams -{ - public class Streams - { - public List list { get; set; } - public bool hasMore { get; set; } - public string headMarker { get; set; } - public string tailMarker { get; set; } - public Counters counters { get; set; } - public class Author - { - public long id { get; set; } - public string _view { get; set; } - } - - public class Counters - { - public int audiosCount { get; set; } - public int photosCount { get; set; } - public int videosCount { get; set; } - public int mediasCount { get; set; } - public int postsCount { get; set; } - public int streamsCount { get; set; } - public int archivedPostsCount { get; set; } - } - - public class Files - { - public Full full { get; set; } - public Thumb thumb { get; set; } - public Preview preview { get; set; } - public SquarePreview squarePreview { get; set; } - public Drm drm { get; set; } - } - - public class Full - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public List sources { get; set; } - } - - public class SquarePreview - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class Thumb - { - public string url { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - } - - public class Info - { - public Source source { get; set; } - public Preview preview { get; set; } - } - - public class List - { - public string responseType { get; set; } - public long id { get; set; } - public DateTime postedAt { get; set; } - public string postedAtPrecise { get; set; } - public object expiredAt { get; set; } - public Author author { get; set; } - public string text { get; set; } - private string _rawText; - public string rawText - { - get - { - if (string.IsNullOrEmpty(_rawText)) - { - _rawText = XmlUtils.EvaluateInnerText(text); - } - - return _rawText; - } - set - { - _rawText = value; - } - } - public bool lockedText { get; set; } - public bool isFavorite { get; set; } - public bool canReport { get; set; } - public bool canDelete { get; set; } - public bool canComment { get; set; } - public bool canEdit { get; set; } - public bool isPinned { get; set; } - public int favoritesCount { get; set; } - public int mediaCount { get; set; } - public bool isMediaReady { get; set; } - public object voting { get; set; } - public bool isOpened { get; set; } - public bool canToggleFavorite { get; set; } - public int streamId { get; set; } - public string price { get; set; } - public bool hasVoting { get; set; } - public bool isAddedToBookmarks { get; set; } - public bool isArchived { get; set; } - public bool isPrivateArchived { get; set; } - public bool isDeleted { get; set; } - public bool hasUrl { get; set; } - public bool isCouplePeopleMedia { get; set; } - public string cantCommentReason { get; set; } - public int commentsCount { get; set; } - public List mentionedUsers { get; set; } - public List linkedUsers { get; set; } - public string tipsAmount { get; set; } - public string tipsAmountRaw { get; set; } - public List media { get; set; } - public bool canViewMedia { get; set; } - public List preview { get; set; } - } - - public class Medium - { - public long id { get; set; } - public string type { get; set; } - public bool convertedToVideo { get; set; } - public bool canView { get; set; } - public bool hasError { get; set; } - public DateTime? createdAt { get; set; } - public Info info { get; set; } - public Source source { get; set; } - public string squarePreview { get; set; } - public string full { get; set; } - public string preview { get; set; } - public string thumb { get; set; } - public bool hasCustomPreview { get; set; } - public Files files { get; set; } - public VideoSources videoSources { get; set; } - } - - public class Preview - { - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public string url { get; set; } - } - - public class Source - { - public string source { get; set; } - public int width { get; set; } - public int height { get; set; } - public long size { get; set; } - public int duration { get; set; } - } - - public class VideoSources - { - [JsonProperty("720")] - public object _720 { get; set; } - - [JsonProperty("240")] - public object _240 { get; set; } - } - public class Drm - { - public Manifest manifest { get; set; } - public Signature signature { get; set; } - } - public class Manifest - { - public string? hls { get; set; } - public string? dash { get; set; } - } - public class Signature - { - public Hls hls { get; set; } - public Dash dash { get; set; } - } - public class Hls - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - public class Dash - { - [JsonProperty("CloudFront-Policy")] - public string CloudFrontPolicy { get; set; } - - [JsonProperty("CloudFront-Signature")] - public string CloudFrontSignature { get; set; } - - [JsonProperty("CloudFront-Key-Pair-Id")] - public string CloudFrontKeyPairId { get; set; } - } - } -} diff --git a/OF DL/Entities/Streams/StreamsCollection.cs b/OF DL/Entities/Streams/StreamsCollection.cs deleted file mode 100644 index 247a13b..0000000 --- a/OF DL/Entities/Streams/StreamsCollection.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities.Streams -{ - public class StreamsCollection - { - public Dictionary Streams = new Dictionary(); - public List StreamObjects = new List(); - public List StreamMedia = new List(); - } -} diff --git a/OF DL/Entities/Subscriptions.cs b/OF DL/Entities/Subscriptions.cs deleted file mode 100644 index f5bacdd..0000000 --- a/OF DL/Entities/Subscriptions.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities -{ - public class Subscriptions - { - public List list { get; set; } - public bool hasMore { get; set; } - public class AvatarThumbs - { - public string c50 { get; set; } - public string c144 { get; set; } - } - - public class HeaderSize - { - public int? width { get; set; } - public int? height { get; set; } - } - - public class HeaderThumbs - { - public string w480 { get; set; } - public string w760 { get; set; } - } - - public class List - { - public string view { get; set; } - public string avatar { get; set; } - public AvatarThumbs avatarThumbs { get; set; } - public string header { get; set; } - public HeaderSize headerSize { get; set; } - public HeaderThumbs headerThumbs { get; set; } - public long id { get; set; } - public string name { get; set; } - public string username { get; set; } - public bool? canLookStory { get; set; } - public bool? canCommentStory { get; set; } - public bool? hasNotViewedStory { get; set; } - public bool? isVerified { get; set; } - public bool? canPayInternal { get; set; } - public bool? hasScheduledStream { get; set; } - public bool? hasStream { get; set; } - public bool? hasStories { get; set; } - public bool? tipsEnabled { get; set; } - public bool? tipsTextEnabled { get; set; } - public int? tipsMin { get; set; } - public int? tipsMinInternal { get; set; } - public int? tipsMax { get; set; } - public bool? canEarn { get; set; } - public bool? canAddSubscriber { get; set; } - public string? subscribePrice { get; set; } - public bool? isPaywallRequired { get; set; } - public bool? unprofitable { get; set; } - public List listsStates { get; set; } - public bool? isMuted { get; set; } - public bool? isRestricted { get; set; } - public bool? canRestrict { get; set; } - public bool? subscribedBy { get; set; } - public bool? subscribedByExpire { get; set; } - public DateTime? subscribedByExpireDate { get; set; } - public bool? subscribedByAutoprolong { get; set; } - public bool? subscribedIsExpiredNow { get; set; } - public string? currentSubscribePrice { get; set; } - public bool? subscribedOn { get; set; } - public bool? subscribedOnExpiredNow { get; set; } - public string subscribedOnDuration { get; set; } - public bool? canReport { get; set; } - public bool? canReceiveChatMessage { get; set; } - public bool? hideChat { get; set; } - public DateTime? lastSeen { get; set; } - public bool? isPerformer { get; set; } - public bool? isRealPerformer { get; set; } - public SubscribedByData subscribedByData { get; set; } - public SubscribedOnData subscribedOnData { get; set; } - public bool? canTrialSend { get; set; } - public bool? isBlocked { get; set; } - public string displayName { get; set; } - public string notice { get; set; } - } - - public class ListsState - { - public object id { get; set; } - public string type { get; set; } - public string name { get; set; } - public bool? hasUser { get; set; } - public bool? canAddUser { get; set; } - } - - public class Subscribe - { - public object id { get; set; } - public long? userId { get; set; } - public int? subscriberId { get; set; } - public DateTime? date { get; set; } - public int? duration { get; set; } - public DateTime? startDate { get; set; } - public DateTime? expireDate { get; set; } - public object cancelDate { get; set; } - public string? price { get; set; } - public string? regularPrice { get; set; } - public string? discount { get; set; } - public string action { get; set; } - public string type { get; set; } - public object offerStart { get; set; } - public object offerEnd { get; set; } - public bool? isCurrent { get; set; } - } - - public class SubscribedByData - { - public string? price { get; set; } - public string? newPrice { get; set; } - public string? regularPrice { get; set; } - public string? subscribePrice { get; set; } - public int? discountPercent { get; set; } - public int? discountPeriod { get; set; } - public DateTime? subscribeAt { get; set; } - public DateTime? expiredAt { get; set; } - public DateTime? renewedAt { get; set; } - public object discountFinishedAt { get; set; } - public object discountStartedAt { get; set; } - public string status { get; set; } - public bool? isMuted { get; set; } - public string unsubscribeReason { get; set; } - public string duration { get; set; } - public bool? showPostsInFeed { get; set; } - public List subscribes { get; set; } - public bool? hasActivePaidSubscriptions { get; set; } - } - - public class SubscribedOnData - { - public string? price { get; set; } - public string? newPrice { get; set; } - public string? regularPrice { get; set; } - public string? subscribePrice { get; set; } - public int? discountPercent { get; set; } - public int? discountPeriod { get; set; } - public DateTime? subscribeAt { get; set; } - public DateTime? expiredAt { get; set; } - public DateTime? renewedAt { get; set; } - public object discountFinishedAt { get; set; } - public object discountStartedAt { get; set; } - public object status { get; set; } - public bool? isMuted { get; set; } - public string unsubscribeReason { get; set; } - public string duration { get; set; } - public string? tipsSumm { get; set; } - public string? subscribesSumm { get; set; } - public string? messagesSumm { get; set; } - public string? postsSumm { get; set; } - public string? streamsSumm { get; set; } - public string? totalSumm { get; set; } - public List subscribes { get; set; } - public bool? hasActivePaidSubscriptions { get; set; } - } - } -} diff --git a/OF DL/Entities/ToggleableConfigAttribute.cs b/OF DL/Entities/ToggleableConfigAttribute.cs deleted file mode 100644 index ff0a4e8..0000000 --- a/OF DL/Entities/ToggleableConfigAttribute.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OF_DL.Entities -{ - [AttributeUsage(AttributeTargets.Property)] - internal class ToggleableConfigAttribute : Attribute - { - } - -} diff --git a/OF DL/Entities/User.cs b/OF DL/Entities/User.cs deleted file mode 100644 index e5dae37..0000000 --- a/OF DL/Entities/User.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Entities -{ - public class User - { - public string view { get; set; } - public string? avatar { get; set; } - public AvatarThumbs avatarThumbs { get; set; } - public string? header { get; set; } - public HeaderSize headerSize { get; set; } - public HeaderThumbs headerThumbs { get; set; } - public long? id { get; set; } - public string name { get; set; } - public string username { get; set; } - public bool? canLookStory { get; set; } - public bool? canCommentStory { get; set; } - public bool? hasNotViewedStory { get; set; } - public bool? isVerified { get; set; } - public bool? canPayInternal { get; set; } - public bool? hasScheduledStream { get; set; } - public bool? hasStream { get; set; } - public bool? hasStories { get; set; } - public bool? tipsEnabled { get; set; } - public bool? tipsTextEnabled { get; set; } - public int? tipsMin { get; set; } - public int? tipsMinInternal { get; set; } - public int? tipsMax { get; set; } - public bool? canEarn { get; set; } - public bool? canAddSubscriber { get; set; } - public string? subscribePrice { get; set; } - public string displayName { get; set; } - public string notice { get; set; } - public bool? isPaywallRequired { get; set; } - public bool? unprofitable { get; set; } - public List listsStates { get; set; } - public bool? isMuted { get; set; } - public bool? isRestricted { get; set; } - public bool? canRestrict { get; set; } - public bool? subscribedBy { get; set; } - public bool? subscribedByExpire { get; set; } - public DateTime? subscribedByExpireDate { get; set; } - public bool? subscribedByAutoprolong { get; set; } - public bool? subscribedIsExpiredNow { get; set; } - public string? currentSubscribePrice { get; set; } - public bool? subscribedOn { get; set; } - public bool? subscribedOnExpiredNow { get; set; } - public string subscribedOnDuration { get; set; } - public DateTime? joinDate { get; set; } - public bool? isReferrerAllowed { get; set; } - public string about { get; set; } - public string rawAbout { get; set; } - public object website { get; set; } - public object wishlist { get; set; } - public object location { get; set; } - public int? postsCount { get; set; } - public int? archivedPostsCount { get; set; } - public int? privateArchivedPostsCount { get; set; } - public int? photosCount { get; set; } - public int? videosCount { get; set; } - public int? audiosCount { get; set; } - public int? mediasCount { get; set; } - public DateTime? lastSeen { get; set; } - public int? favoritesCount { get; set; } - public int? favoritedCount { get; set; } - public bool? showPostsInFeed { get; set; } - public bool? canReceiveChatMessage { get; set; } - public bool? isPerformer { get; set; } - public bool? isRealPerformer { get; set; } - public bool? isSpotifyConnected { get; set; } - public int? subscribersCount { get; set; } - public bool? hasPinnedPosts { get; set; } - public bool? hasLabels { get; set; } - public bool? canChat { get; set; } - public string? callPrice { get; set; } - public bool? isPrivateRestriction { get; set; } - public bool? showSubscribersCount { get; set; } - public bool? showMediaCount { get; set; } - public SubscribedByData subscribedByData { get; set; } - public SubscribedOnData subscribedOnData { get; set; } - public bool? canPromotion { get; set; } - public bool? canCreatePromotion { get; set; } - public bool? canCreateTrial { get; set; } - public bool? isAdultContent { get; set; } - public bool? canTrialSend { get; set; } - public bool? hadEnoughLastPhotos { get; set; } - public bool? hasLinks { get; set; } - public DateTime? firstPublishedPostDate { get; set; } - public bool? isSpringConnected { get; set; } - public bool? isFriend { get; set; } - public bool? isBlocked { get; set; } - public bool? canReport { get; set; } - public class AvatarThumbs - { - public string c50 { get; set; } - public string c144 { get; set; } - } - - public class HeaderSize - { - public int? width { get; set; } - public int? height { get; set; } - } - - public class HeaderThumbs - { - public string w480 { get; set; } - public string w760 { get; set; } - } - - public class ListsState - { - public string id { get; set; } - public string type { get; set; } - public string name { get; set; } - public bool hasUser { get; set; } - public bool canAddUser { get; set; } - } - - public class Subscribe - { - public long? id { get; set; } - public long? userId { get; set; } - public int? subscriberId { get; set; } - public DateTime? date { get; set; } - public int? duration { get; set; } - public DateTime? startDate { get; set; } - public DateTime? expireDate { get; set; } - public object cancelDate { get; set; } - public string? price { get; set; } - public string? regularPrice { get; set; } - public int? discount { get; set; } - public string action { get; set; } - public string type { get; set; } - public object offerStart { get; set; } - public object offerEnd { get; set; } - public bool? isCurrent { get; set; } - } - - public class SubscribedByData - { - public string? price { get; set; } - public string? newPrice { get; set; } - public string? regularPrice { get; set; } - public string? subscribePrice { get; set; } - public int? discountPercent { get; set; } - public int? discountPeriod { get; set; } - public DateTime? subscribeAt { get; set; } - public DateTime? expiredAt { get; set; } - public object? renewedAt { get; set; } - public object? discountFinishedAt { get; set; } - public object? discountStartedAt { get; set; } - public string? status { get; set; } - public bool? isMuted { get; set; } - public string? unsubscribeReason { get; set; } - public string? duration { get; set; } - public bool? showPostsInFeed { get; set; } - public List? subscribes { get; set; } - } - - public class SubscribedOnData - { - public string? price { get; set; } - public string? newPrice { get; set; } - public string? regularPrice { get; set; } - public string? subscribePrice { get; set; } - public int? discountPercent { get; set; } - public int? discountPeriod { get; set; } - public DateTime? subscribeAt { get; set; } - public DateTime? expiredAt { get; set; } - public DateTime? renewedAt { get; set; } - public object? discountFinishedAt { get; set; } - public object? discountStartedAt { get; set; } - public object? status { get; set; } - public bool? isMuted { get; set; } - public string? unsubscribeReason { get; set; } - public string? duration { get; set; } - public string? tipsSumm { get; set; } - public string? subscribesSumm { get; set; } - public string? messagesSumm { get; set; } - public string? postsSumm { get; set; } - public string? streamsSumm { get; set; } - public string? totalSumm { get; set; } - public List? subscribes { get; set; } - } - } -} diff --git a/OF DL/Enumerations/CustomFileNameOption.cs b/OF DL/Enumerations/CustomFileNameOption.cs deleted file mode 100644 index eecd762..0000000 --- a/OF DL/Enumerations/CustomFileNameOption.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Enumerations; - -public enum CustomFileNameOption -{ - ReturnOriginal, - ReturnEmpty, -} diff --git a/OF DL/Enumerations/DownloadDateSelection.cs b/OF DL/Enumerations/DownloadDateSelection.cs deleted file mode 100644 index 926c7bd..0000000 --- a/OF DL/Enumerations/DownloadDateSelection.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Enumerations -{ - public enum DownloadDateSelection - { - before, - after, - } -} diff --git a/OF DL/Enumerations/LoggingLevel.cs b/OF DL/Enumerations/LoggingLevel.cs deleted file mode 100644 index 6757262..0000000 --- a/OF DL/Enumerations/LoggingLevel.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Enumerations -{ - public enum LoggingLevel - { - // - // Summary: - // Anything and everything you might want to know about a running block of code. - Verbose, - // - // Summary: - // Internal system events that aren't necessarily observable from the outside. - Debug, - // - // Summary: - // The lifeblood of operational intelligence - things happen. - Information, - // - // Summary: - // Service is degraded or endangered. - Warning, - // - // Summary: - // Functionality is unavailable, invariants are broken or data is lost. - Error, - // - // Summary: - // If you have a pager, it goes off when one of these occurs. - Fatal - } -} diff --git a/OF DL/Enumerations/MediaType.cs b/OF DL/Enumerations/MediaType.cs deleted file mode 100644 index 86078e8..0000000 --- a/OF DL/Enumerations/MediaType.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Enumurations -{ - public enum MediaType - { - PaidPosts = 10, - Posts = 20, - Archived = 30, - Stories = 40, - Highlights = 50, - Messages = 60, - PaidMessages = 70 - } -} diff --git a/OF DL/Enumerations/VideoResolution.cs b/OF DL/Enumerations/VideoResolution.cs deleted file mode 100644 index 2514dee..0000000 --- a/OF DL/Enumerations/VideoResolution.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Enumerations -{ - public enum VideoResolution - { - _240, - _720, - source - } -} diff --git a/OF DL/Helpers/APIHelper.cs b/OF DL/Helpers/APIHelper.cs deleted file mode 100644 index ea209c8..0000000 --- a/OF DL/Helpers/APIHelper.cs +++ /dev/null @@ -1,2945 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using OF_DL.Entities; -using OF_DL.Entities.Archived; -using OF_DL.Entities.Highlights; -using OF_DL.Entities.Lists; -using OF_DL.Entities.Messages; -using OF_DL.Entities.Post; -using OF_DL.Entities.Purchased; -using OF_DL.Entities.Stories; -using OF_DL.Entities.Streams; -using OF_DL.Enumerations; -using OF_DL.Enumurations; -using Serilog; -using Spectre.Console; -using System.Globalization; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Xml.Linq; -using WidevineClient.Widevine; -using static WidevineClient.HttpUtil; - -namespace OF_DL.Helpers; - -public class APIHelper : IAPIHelper -{ - private static readonly JsonSerializerSettings m_JsonSerializerSettings; - private readonly IDBHelper m_DBHelper; - private readonly IDownloadConfig downloadConfig; - private readonly Auth auth; - private static DateTime? cachedDynamicRulesExpiration; - private static DynamicRules? cachedDynamicRules; - private const int MaxAttempts = 30; - private const int DelayBetweenAttempts = 3000; - - static APIHelper() - { - m_JsonSerializerSettings = new() - { - MissingMemberHandling = MissingMemberHandling.Ignore - }; - } - - public APIHelper(Auth auth, IDownloadConfig downloadConfig) - { - this.auth = auth; - m_DBHelper = new DBHelper(downloadConfig); - this.downloadConfig = downloadConfig; - } - - - public Dictionary GetDynamicHeaders(string path, string queryParams) - { - Log.Debug("Calling GetDynamicHeaders"); - Log.Debug($"Path: {path}"); - Log.Debug($"Query Params: {queryParams}"); - - DynamicRules? root; - - //Check if we have a cached version of the dynamic rules - if (cachedDynamicRules != null && cachedDynamicRulesExpiration.HasValue && - DateTime.UtcNow < cachedDynamicRulesExpiration) - { - Log.Debug("Using cached dynamic rules"); - root = cachedDynamicRules; - } - else - { - //Get rules from GitHub and fallback to local file - string? dynamicRulesJSON = GetDynamicRules(); - if (!string.IsNullOrEmpty(dynamicRulesJSON)) - { - Log.Debug("Using dynamic rules from GitHub"); - root = JsonConvert.DeserializeObject(dynamicRulesJSON); - - // Cache the GitHub response for 15 minutes - cachedDynamicRules = root; - cachedDynamicRulesExpiration = DateTime.UtcNow.AddMinutes(15); - } - else - { - Log.Debug("Using dynamic rules from local file"); - root = JsonConvert.DeserializeObject(File.ReadAllText("rules.json")); - - // Cache the dynamic rules from local file to prevent unnecessary disk - // operations and frequent call to GitHub. Since the GitHub dynamic rules - // are preferred to the local file, the cache time is shorter than when dynamic rules - // are successfully retrieved from GitHub. - cachedDynamicRules = root; - cachedDynamicRulesExpiration = DateTime.UtcNow.AddMinutes(5); - } - } - - DateTimeOffset dto = (DateTimeOffset)DateTime.UtcNow; - long timestamp = dto.ToUnixTimeMilliseconds(); - - string input = $"{root!.StaticParam}\n{timestamp}\n{path + queryParams}\n{auth.USER_ID}"; - byte[] inputBytes = Encoding.UTF8.GetBytes(input); - byte[] hashBytes = SHA1.HashData(inputBytes); - string hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); - - var checksum = root.ChecksumIndexes.Aggregate(0, (current, number) => current + hashString[number]) + root.ChecksumConstant!.Value; - var sign = $"{root.Prefix}:{hashString}:{checksum.ToString("X").ToLower()}:{root.Suffix}"; - - Dictionary headers = new() - { - { "accept", "application/json, text/plain" }, - { "app-token", root.AppToken! }, - { "cookie", auth!.COOKIE! }, - { "sign", sign }, - { "time", timestamp.ToString() }, - { "user-id", auth!.USER_ID! }, - { "user-agent", auth!.USER_AGENT! }, - { "x-bc", auth!.X_BC! } - }; - return headers; - } - - - private async Task BuildHeaderAndExecuteRequests(Dictionary getParams, string endpoint, HttpClient client) - { - Log.Debug("Calling BuildHeaderAndExecuteRequests"); - - HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint); - using var response = await client.SendAsync(request); - response.EnsureSuccessStatusCode(); - string body = await response.Content.ReadAsStringAsync(); - - Log.Debug(body); - - return body; - } - - - private async Task BuildHttpRequestMessage(Dictionary getParams, string endpoint) - { - Log.Debug("Calling BuildHttpRequestMessage"); - - string queryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); - - Dictionary headers = GetDynamicHeaders($"/api2/v2{endpoint}", queryParams); - - HttpRequestMessage request = new(HttpMethod.Get, $"{Constants.API_URL}{endpoint}{queryParams}"); - - Log.Debug($"Full request URL: {Constants.API_URL}{endpoint}{queryParams}"); - - foreach (KeyValuePair keyValuePair in headers) - { - request.Headers.Add(keyValuePair.Key, keyValuePair.Value); - } - - return request; - } - - private static double ConvertToUnixTimestampWithMicrosecondPrecision(DateTime date) - { - DateTime origin = new(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); - TimeSpan diff = date.ToUniversalTime() - origin; - - return diff.TotalSeconds; // This gives the number of seconds. If you need milliseconds, use diff.TotalMilliseconds - } - - public static bool IsStringOnlyDigits(string input) - { - return input.All(char.IsDigit); - } - - - private static HttpClient GetHttpClient(IDownloadConfig? config = null) - { - var client = new HttpClient(); - if (config?.Timeout != null && config.Timeout > 0) - { - client.Timeout = TimeSpan.FromSeconds(config.Timeout.Value); - } - return client; - } - - - /// - /// this one is used during initialization only - /// if the config option is not available then no modificatiotns will be done on the getParams - /// - /// - /// - /// - private static void UpdateGetParamsForDateSelection(Enumerations.DownloadDateSelection downloadDateSelection, ref Dictionary getParams, DateTime? dt) - { - //if (config.DownloadOnlySpecificDates && dt.HasValue) - //{ - if (dt.HasValue) - { - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - ConvertToUnixTimestampWithMicrosecondPrecision(dt.Value).ToString("0.000000", CultureInfo.InvariantCulture) - ); - } - //} - } - - private static void UpdateGetParamsForDateSelection(Enumerations.DownloadDateSelection downloadDateSelection, ref Dictionary getParams, string unixTimeStampInMicrosec) - { - switch (downloadDateSelection) - { - case Enumerations.DownloadDateSelection.before: - getParams["beforePublishTime"] = unixTimeStampInMicrosec; - break; - case Enumerations.DownloadDateSelection.after: - getParams["order"] = "publish_date_asc"; - getParams["afterPublishTime"] = unixTimeStampInMicrosec; - break; - } - } - - - public async Task GetUserInfo(string endpoint) - { - Log.Debug($"Calling GetUserInfo: {endpoint}"); - - try - { - Entities.User? user = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "order", "publish_date_asc" } - }; - - HttpClient client = new(); - HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint); - - using var response = await client.SendAsync(request); - - if (!response.IsSuccessStatusCode) - { - return user; - } - - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStringAsync(); - user = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - return user; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - public async Task GetUserInfoById(string endpoint) - { - try - { - HttpClient client = new(); - HttpRequestMessage request = await BuildHttpRequestMessage(new Dictionary(), endpoint); - - using var response = await client.SendAsync(request); - - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStringAsync(); - - //if the content creator doesnt exist, we get a 200 response, but the content isnt usable - //so let's not throw an exception, since "content creator no longer exists" is handled elsewhere - //which means we wont get loads of exceptions - if (body.Equals("[]")) - return null; - - JObject jObject = JObject.Parse(body); - - return jObject; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task?> GetAllSubscriptions(Dictionary getParams, string endpoint, bool includeRestricted, IDownloadConfig config) - { - try - { - Dictionary users = new(); - Subscriptions subscriptions = new(); - - Log.Debug("Calling GetAllSubscrptions"); - - string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); - - subscriptions = JsonConvert.DeserializeObject(body); - if (subscriptions != null && subscriptions.hasMore) - { - getParams["offset"] = subscriptions.list.Count.ToString(); - - while (true) - { - Subscriptions newSubscriptions = new(); - string? loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); - - if (!string.IsNullOrEmpty(loopbody) && (!loopbody.Contains("[]") || loopbody.Trim() != "[]")) - { - newSubscriptions = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - } - else - { - break; - } - - subscriptions.list.AddRange(newSubscriptions.list); - if (!newSubscriptions.hasMore) - { - break; - } - getParams["offset"] = subscriptions.list.Count.ToString(); - } - } - - foreach (Subscriptions.List subscription in subscriptions.list) - { - if ((!(subscription.isRestricted ?? false) || ((subscription.isRestricted ?? false) && includeRestricted)) - && !users.ContainsKey(subscription.username)) - { - users.Add(subscription.username, subscription.id); - } - } - - return users; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - public async Task?> GetActiveSubscriptions(string endpoint, bool includeRestricted, IDownloadConfig config) - { - Dictionary getParams = new() - { - { "offset", "0" }, - { "limit", "50" }, - { "type", "active" }, - { "format", "infinite"} - }; - - return await GetAllSubscriptions(getParams, endpoint, includeRestricted, config); - } - - - public async Task?> GetExpiredSubscriptions(string endpoint, bool includeRestricted, IDownloadConfig config) - { - - Dictionary getParams = new() - { - { "offset", "0" }, - { "limit", "50" }, - { "type", "expired" }, - { "format", "infinite"} - }; - - Log.Debug("Calling GetExpiredSubscriptions"); - - return await GetAllSubscriptions(getParams, endpoint, includeRestricted, config); - } - - - public async Task> GetLists(string endpoint, IDownloadConfig config) - { - Log.Debug("Calling GetLists"); - - try - { - int offset = 0; - Dictionary getParams = new() - { - { "offset", offset.ToString() }, - { "skip_users", "all" }, - { "limit", "50" }, - { "format", "infinite" } - }; - Dictionary lists = new(); - while (true) - { - string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); - - if (body == null) - { - break; - } - - UserList userList = JsonConvert.DeserializeObject(body); - if (userList == null) - { - break; - } - - foreach (UserList.List l in userList.list) - { - if (IsStringOnlyDigits(l.id) && !lists.ContainsKey(l.name)) - { - lists.Add(l.name, Convert.ToInt32(l.id)); - } - } - - if (userList.hasMore.Value) - { - offset += 50; - getParams["offset"] = Convert.ToString(offset); - } - else - { - break; - } - - } - return lists; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task?> GetListUsers(string endpoint, IDownloadConfig config) - { - Log.Debug($"Calling GetListUsers - {endpoint}"); - - try - { - int offset = 0; - Dictionary getParams = new() - { - { "offset", offset.ToString() }, - { "limit", "50" }, - }; - List users = new(); - - while (true) - { - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); - if (body == null) - { - break; - } - - List? usersList = JsonConvert.DeserializeObject>(body); - - if (usersList == null || usersList.Count <= 0) - { - break; - } - - foreach (UsersList ul in usersList) - { - users.Add(ul.username); - } - - if (users.Count < 50) - { - break; - } - - offset += 50; - getParams["offset"] = Convert.ToString(offset); - - } - return users; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task> GetMedia(MediaType mediatype, - string endpoint, - string? username, - string folder, - IDownloadConfig config, - List paid_post_ids) - { - - Log.Debug($"Calling GetMedia - {username}"); - - try - { - Dictionary return_urls = new(); - int post_limit = 50; - int limit = 5; - int offset = 0; - - Dictionary getParams = new(); - - switch (mediatype) - { - - case MediaType.Stories: - getParams = new Dictionary - { - { "limit", post_limit.ToString() }, - { "order", "publish_date_desc" }, - { "skip_users", "all" } - }; - break; - - case MediaType.Highlights: - getParams = new Dictionary - { - { "limit", limit.ToString() }, - { "offset", offset.ToString() }, - { "skip_users", "all" } - }; - break; - } - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); - - - if (mediatype == MediaType.Stories) - { - Log.Debug("Media Stories - " + endpoint); - - var stories = JsonConvert.DeserializeObject>(body, m_JsonSerializerSettings) ?? new List(); - - foreach (Stories story in stories) - { - if (story.media[0].createdAt.HasValue) - { - await m_DBHelper.AddStory(folder, story.id, string.Empty, "0", false, false, story.media[0].createdAt.Value); - } - else if (story.createdAt.HasValue) - { - await m_DBHelper.AddStory(folder, story.id, string.Empty, "0", false, false, story.createdAt.Value); - } - else - { - await m_DBHelper.AddStory(folder, story.id, string.Empty, "0", false, false, DateTime.Now); - } - if (story.media != null && story.media.Count > 0) - { - foreach (Stories.Medium medium in story.media) - { - await m_DBHelper.AddMedia(folder, medium.id, story.id, medium.files.full.url, null, null, null, "Stories", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), false, false, null); - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (medium.canView) - { - if (!return_urls.ContainsKey(medium.id)) - { - return_urls.Add(medium.id, medium.files.full.url); - } - } - } - } - } - } - else if (mediatype == MediaType.Highlights) - { - List highlight_ids = new(); - var highlights = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings) ?? new Highlights(); - - if (highlights.hasMore) - { - offset += 5; - getParams["offset"] = offset.ToString(); - while (true) - { - Highlights newhighlights = new(); - - Log.Debug("Media Highlights - " + endpoint); - - var loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - newhighlights = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - - highlights.list.AddRange(newhighlights.list); - if (!newhighlights.hasMore) - { - break; - } - offset += 5; - getParams["offset"] = offset.ToString(); - } - } - foreach (Highlights.List list in highlights.list) - { - if (!highlight_ids.Contains(list.id.ToString())) - { - highlight_ids.Add(list.id.ToString()); - } - } - - foreach (string highlight_id in highlight_ids) - { - HighlightMedia highlightMedia = new(); - Dictionary highlight_headers = GetDynamicHeaders("/api2/v2/stories/highlights/" + highlight_id, string.Empty); - - HttpClient highlight_client = GetHttpClient(config); - - HttpRequestMessage highlight_request = new(HttpMethod.Get, $"https://onlyfans.com/api2/v2/stories/highlights/{highlight_id}"); - - foreach (KeyValuePair keyValuePair in highlight_headers) - { - highlight_request.Headers.Add(keyValuePair.Key, keyValuePair.Value); - } - - using var highlightResponse = await highlight_client.SendAsync(highlight_request); - highlightResponse.EnsureSuccessStatusCode(); - var highlightBody = await highlightResponse.Content.ReadAsStringAsync(); - highlightMedia = JsonConvert.DeserializeObject(highlightBody, m_JsonSerializerSettings); - if (highlightMedia != null) - { - foreach (HighlightMedia.Story item in highlightMedia.stories) - { - if (item.media[0].createdAt.HasValue) - { - await m_DBHelper.AddStory(folder, item.id, string.Empty, "0", false, false, item.media[0].createdAt.Value); - } - else if (item.createdAt.HasValue) - { - await m_DBHelper.AddStory(folder, item.id, string.Empty, "0", false, false, item.createdAt.Value); - } - else - { - await m_DBHelper.AddStory(folder, item.id, string.Empty, "0", false, false, DateTime.Now); - } - if (item.media.Count > 0 && item.media[0].canView) - { - foreach (HighlightMedia.Medium medium in item.media) - { - await m_DBHelper.AddMedia(folder, medium.id, item.id, item.media[0].files.full.url, null, null, null, "Stories", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), false, false, null); - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!return_urls.ContainsKey(medium.id)) - { - return_urls.Add(medium.id, item.media[0].files.full.url); - } - } - } - } - } - } - } - - return return_urls; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task GetPaidPosts(string endpoint, string folder, string username, IDownloadConfig config, List paid_post_ids, StatusContext ctx) - { - Log.Debug($"Calling GetPaidPosts - {username}"); - - try - { - Purchased paidPosts = new(); - PaidPostCollection paidPostCollection = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "skip_users", "all" }, - { "order", "publish_date_desc" }, - { "format", "infinite" }, - { "author", username } - }; - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - paidPosts = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - ctx.Status($"[red]Getting Paid Posts\n[/] [red]Found {paidPosts.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (paidPosts != null && paidPosts.hasMore) - { - getParams["offset"] = paidPosts.list.Count.ToString(); - while (true) - { - - Purchased newPaidPosts = new(); - - var loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - newPaidPosts = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - - paidPosts.list.AddRange(newPaidPosts.list); - ctx.Status($"[red]Getting Paid Posts\n[/] [red]Found {paidPosts.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (!newPaidPosts.hasMore) - { - break; - } - getParams["offset"] = Convert.ToString(Convert.ToInt32(getParams["offset"]) + post_limit); - } - - } - - foreach (Purchased.List purchase in paidPosts.list) - { - if (purchase.responseType == "post" && purchase.media != null && purchase.media.Count > 0) - { - List previewids = new(); - if (purchase.previews != null) - { - for (int i = 0; i < purchase.previews.Count; i++) - { - if (purchase.previews[i] is long previewId) - { - if (!previewids.Contains(previewId)) - { - previewids.Add(previewId); - } - } - } - } - else if (purchase.preview != null) - { - for (int i = 0; i < purchase.preview.Count; i++) - { - if (purchase.preview[i] is long previewId) - { - if (!previewids.Contains(previewId)) - { - previewids.Add(previewId); - } - } - } - } - await m_DBHelper.AddPost(folder, purchase.id, purchase.text != null ? purchase.text : string.Empty, purchase.price != null ? purchase.price.ToString() : "0", purchase.price != null && purchase.isOpened ? true : false, purchase.isArchived.HasValue ? purchase.isArchived.Value : false, purchase.createdAt != null ? purchase.createdAt.Value : purchase.postedAt.Value); - paidPostCollection.PaidPostObjects.Add(purchase); - foreach (Messages.Medium medium in purchase.media) - { - if (!previewids.Contains(medium.id)) - { - paid_post_ids.Add(medium.id); - } - - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (previewids.Count > 0) - { - bool has = previewids.Any(cus => cus.Equals(medium.id)); - if (!has && medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (!paidPostCollection.PaidPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, purchase.id, medium.files.full.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - paidPostCollection.PaidPosts.Add(medium.id, medium.files.full.url); - paidPostCollection.PaidPostMedia.Add(medium); - } - } - else if (!has && medium.canView && medium.files != null && medium.files.drm != null) - { - - if (!paidPostCollection.PaidPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, purchase.id, medium.files.drm.manifest.dash, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - paidPostCollection.PaidPosts.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{purchase.id}"); - paidPostCollection.PaidPostMedia.Add(medium); - } - - } - } - else - { - if (medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (!paidPostCollection.PaidPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, purchase.id, medium.files.full.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - paidPostCollection.PaidPosts.Add(medium.id, medium.files.full.url); - paidPostCollection.PaidPostMedia.Add(medium); - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null) - { - if (!paidPostCollection.PaidPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, purchase.id, medium.files.drm.manifest.dash, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - paidPostCollection.PaidPosts.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{purchase.id}"); - paidPostCollection.PaidPostMedia.Add(medium); - } - } - } - } - } - } - return paidPostCollection; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task GetPosts(string endpoint, string folder, IDownloadConfig config, List paid_post_ids, StatusContext ctx) - { - Log.Debug($"Calling GetPosts - {endpoint}"); - - try - { - Post posts = new(); - PostCollection postCollection = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "order", "publish_date_desc" }, - { "format", "infinite" }, - { "skip_users", "all" } - }; - - Enumerations.DownloadDateSelection downloadDateSelection = Enumerations.DownloadDateSelection.before; - DateTime? downloadAsOf = null; - - if (config.DownloadOnlySpecificDates && config.CustomDate.HasValue) - { - downloadDateSelection = config.DownloadDateSelection; - downloadAsOf = config.CustomDate; - } - else if (config.DownloadPostsIncrementally) - { - var mostRecentPostDate = await m_DBHelper.GetMostRecentPostDate(folder); - if (mostRecentPostDate.HasValue) - { - downloadDateSelection = Enumerations.DownloadDateSelection.after; - downloadAsOf = mostRecentPostDate.Value.AddMinutes(-5); // Back track a little for a margin of error - } - } - - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - downloadAsOf); - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); - posts = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - ctx.Status($"[red]Getting Posts (this may take a long time, depending on the number of Posts the creator has)\n[/] [red]Found {posts.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (posts != null && posts.hasMore) - { - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - posts.tailMarker); - - while (true) - { - Post newposts = new(); - - var loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - newposts = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - - posts.list.AddRange(newposts.list); - ctx.Status($"[red]Getting Posts (this may take a long time, depending on the number of Posts the creator has)\n[/] [red]Found {posts.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (!newposts.hasMore) - { - break; - } - - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - newposts.tailMarker); - } - } - - foreach (Post.List post in posts.list) - { - if (config.SkipAds) - { - if (post.rawText != null && (post.rawText.Contains("#ad") || post.rawText.Contains("/trial/") || post.rawText.Contains("#announcement"))) - { - continue; - } - - if (post.text != null && (post.text.Contains("#ad") || post.text.Contains("/trial/") || post.text.Contains("#announcement"))) - { - continue; - } - } - List postPreviewIds = new(); - if (post.preview != null && post.preview.Count > 0) - { - for (int i = 0; i < post.preview.Count; i++) - { - if (post.preview[i] is long previewId) - { - if (!postPreviewIds.Contains(previewId)) - { - postPreviewIds.Add(previewId); - } - } - } - } - await m_DBHelper.AddPost(folder, post.id, post.rawText != null ? post.rawText : string.Empty, post.price != null ? post.price.ToString() : "0", post.price != null && post.isOpened ? true : false, post.isArchived, post.postedAt); - postCollection.PostObjects.Add(post); - if (post.media != null && post.media.Count > 0) - { - foreach (Post.Medium medium in post.media) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (medium.canView && medium.files?.drm == null) - { - bool has = paid_post_ids.Any(cus => cus.Equals(medium.id)); - if (medium.files!.full != null && !string.IsNullOrEmpty(medium.files!.full.url)) - { - if (!has) - { - if (!postCollection.Posts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, post.id, medium.files!.full.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), postPreviewIds.Contains((long)medium.id) ? true : false, false, null); - postCollection.Posts.Add(medium.id, medium.files!.full.url); - postCollection.PostMedia.Add(medium); - } - } - } - else if (medium.files.preview != null && medium.files!.full == null) - { - if (!has) - { - if (!postCollection.Posts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, post.id, medium.files.preview.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), postPreviewIds.Contains((long)medium.id) ? true : false, false, null); - postCollection.Posts.Add(medium.id, medium.files.preview.url); - postCollection.PostMedia.Add(medium); - } - } - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null) - { - bool has = paid_post_ids.Any(cus => cus.Equals(medium.id)); - if (!has && medium.files != null && medium.files.drm != null) - { - if (!postCollection.Posts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, post.id, medium.files.drm.manifest.dash, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), postPreviewIds.Contains((long)medium.id) ? true : false, false, null); - postCollection.Posts.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{post.id}"); - postCollection.PostMedia.Add(medium); - } - } - } - } - } - } - - return postCollection; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - public async Task GetPost(string endpoint, string folder, IDownloadConfig config) - { - Log.Debug($"Calling GetPost - {endpoint}"); - - try - { - SinglePost singlePost = new(); - SinglePostCollection singlePostCollection = new(); - Dictionary getParams = new() - { - { "skip_users", "all" } - }; - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); - singlePost = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - - if (singlePost != null) - { - List postPreviewIds = new(); - if (singlePost.preview != null && singlePost.preview.Count > 0) - { - for (int i = 0; i < singlePost.preview.Count; i++) - { - if (singlePost.preview[i] is long previewId) - { - if (!postPreviewIds.Contains(previewId)) - { - postPreviewIds.Add(previewId); - } - } - } - } - await m_DBHelper.AddPost(folder, singlePost.id, singlePost.text != null ? singlePost.text : string.Empty, singlePost.price != null ? singlePost.price.ToString() : "0", singlePost.price != null && singlePost.isOpened ? true : false, singlePost.isArchived, singlePost.postedAt); - singlePostCollection.SinglePostObjects.Add(singlePost); - if (singlePost.media != null && singlePost.media.Count > 0) - { - foreach (SinglePost.Medium medium in singlePost.media) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (medium.canView && medium.files?.drm == null) - { - switch (downloadConfig.DownloadVideoResolution) - { - case VideoResolution.source: - if (medium.files!.full != null && !string.IsNullOrEmpty(medium.files!.full.url)) - { - if (!singlePostCollection.SinglePosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, singlePost.id, medium.files!.full.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), postPreviewIds.Contains((long)medium.id) ? true : false, false, null); - singlePostCollection.SinglePosts.Add(medium.id, medium.files!.full.url); - singlePostCollection.SinglePostMedia.Add(medium); - } - } - break; - case VideoResolution._240: - if(medium.videoSources != null) - { - if (!string.IsNullOrEmpty(medium.videoSources._240)) - { - if (!singlePostCollection.SinglePosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, singlePost.id, medium.videoSources._240, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), postPreviewIds.Contains((long)medium.id) ? true : false, false, null); - singlePostCollection.SinglePosts.Add(medium.id, medium.videoSources._240); - singlePostCollection.SinglePostMedia.Add(medium); - } - } - } - break; - case VideoResolution._720: - if (medium.videoSources != null) - { - if (!string.IsNullOrEmpty(medium.videoSources._720)) - { - if (!singlePostCollection.SinglePosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, singlePost.id, medium.videoSources._720, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), postPreviewIds.Contains((long)medium.id) ? true : false, false, null); - singlePostCollection.SinglePosts.Add(medium.id, medium.videoSources._720); - singlePostCollection.SinglePostMedia.Add(medium); - } - } - } - break; - - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null) - { - if (medium.files != null && medium.files.drm != null) - { - if (!singlePostCollection.SinglePosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, singlePost.id, medium.files.drm.manifest.dash, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), postPreviewIds.Contains((long)medium.id) ? true : false, false, null); - singlePostCollection.SinglePosts.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{singlePost.id}"); - singlePostCollection.SinglePostMedia.Add(medium); - } - } - } - else if (medium.files.preview != null && medium.files!.full == null) - { - if (!singlePostCollection.SinglePosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, singlePost.id, medium.files.preview.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), postPreviewIds.Contains((long)medium.id) ? true : false, false, null); - singlePostCollection.SinglePosts.Add(medium.id, medium.files.preview.url); - singlePostCollection.SinglePostMedia.Add(medium); - } - } - } - } - } - - return singlePostCollection; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - public async Task GetStreams(string endpoint, string folder, IDownloadConfig config, List paid_post_ids, StatusContext ctx) - { - Log.Debug($"Calling GetStreams - {endpoint}"); - - try - { - Streams streams = new(); - StreamsCollection streamsCollection = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "order", "publish_date_desc" }, - { "format", "infinite" }, - { "skip_users", "all" } - }; - - Enumerations.DownloadDateSelection downloadDateSelection = Enumerations.DownloadDateSelection.before; - if (config.DownloadOnlySpecificDates && config.CustomDate.HasValue) - { - downloadDateSelection = config.DownloadDateSelection; - } - - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - config.CustomDate); - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, new HttpClient()); - streams = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - ctx.Status($"[red]Getting Streams\n[/] [red]Found {streams.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (streams != null && streams.hasMore) - { - - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - streams.tailMarker); - - while (true) - { - Streams newstreams = new(); - - var loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - newstreams = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - - streams.list.AddRange(newstreams.list); - ctx.Status($"[red]Getting Streams\n[/] [red]Found {streams.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (!newstreams.hasMore) - { - break; - } - - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - newstreams.tailMarker); - } - } - - foreach (Streams.List stream in streams.list) - { - List streamPreviewIds = new(); - if (stream.preview != null && stream.preview.Count > 0) - { - for (int i = 0; i < stream.preview.Count; i++) - { - if (stream.preview[i] is long previewId) - { - if (!streamPreviewIds.Contains(previewId)) - { - streamPreviewIds.Add(previewId); - } - } - } - } - await m_DBHelper.AddPost(folder, stream.id, stream.text != null ? stream.text : string.Empty, stream.price != null ? stream.price.ToString() : "0", stream.price != null && stream.isOpened ? true : false, stream.isArchived, stream.postedAt); - streamsCollection.StreamObjects.Add(stream); - if (stream.media != null && stream.media.Count > 0) - { - foreach (Streams.Medium medium in stream.media) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (medium.canView && medium.files?.drm == null) - { - bool has = paid_post_ids.Any(cus => cus.Equals(medium.id)); - if (!has && medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (!streamsCollection.Streams.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, stream.id, medium.files.full.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), streamPreviewIds.Contains((long)medium.id) ? true : false, false, null); - streamsCollection.Streams.Add(medium.id, medium.files.full.url); - streamsCollection.StreamMedia.Add(medium); - } - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null) - { - bool has = paid_post_ids.Any(cus => cus.Equals(medium.id)); - if (!has && medium.files != null && medium.files.drm != null) - { - if (!streamsCollection.Streams.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, stream.id, medium.files.drm.manifest.dash, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), streamPreviewIds.Contains((long)medium.id) ? true : false, false, null); - streamsCollection.Streams.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{stream.id}"); - streamsCollection.StreamMedia.Add(medium); - } - } - } - } - } - } - - return streamsCollection; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task GetArchived(string endpoint, string folder, IDownloadConfig config, StatusContext ctx) - { - Log.Debug($"Calling GetArchived - {endpoint}"); - - try - { - Archived archived = new(); - ArchivedCollection archivedCollection = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "order", "publish_date_desc" }, - { "skip_users", "all" }, - { "format", "infinite" }, - { "label", "archived" }, - { "counters", "1" } - }; - - Enumerations.DownloadDateSelection downloadDateSelection = Enumerations.DownloadDateSelection.before; - if (config.DownloadOnlySpecificDates && config.CustomDate.HasValue) - { - downloadDateSelection = config.DownloadDateSelection; - } - - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - config.CustomDate); - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - archived = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - ctx.Status($"[red]Getting Archived Posts\n[/] [red]Found {archived.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (archived != null && archived.hasMore) - { - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - archived.tailMarker); - while (true) - { - Archived newarchived = new(); - - var loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - newarchived = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - - archived.list.AddRange(newarchived.list); - ctx.Status($"[red]Getting Archived Posts\n[/] [red]Found {archived.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (!newarchived.hasMore) - { - break; - } - UpdateGetParamsForDateSelection( - downloadDateSelection, - ref getParams, - newarchived.tailMarker); - } - } - - foreach (Archived.List archive in archived.list) - { - List previewids = new(); - if (archive.preview != null) - { - for (int i = 0; i < archive.preview.Count; i++) - { - if (archive.preview[i] is long previewId) - { - if (!previewids.Contains(previewId)) - { - previewids.Add(previewId); - } - } - } - } - await m_DBHelper.AddPost(folder, archive.id, archive.text != null ? archive.text : string.Empty, archive.price != null ? archive.price.ToString() : "0", archive.price != null && archive.isOpened ? true : false, archive.isArchived, archive.postedAt); - archivedCollection.ArchivedPostObjects.Add(archive); - if (archive.media != null && archive.media.Count > 0) - { - foreach (Archived.Medium medium in archive.media) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (!archivedCollection.ArchivedPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, archive.id, medium.files.full.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - archivedCollection.ArchivedPosts.Add(medium.id, medium.files.full.url); - archivedCollection.ArchivedPostMedia.Add(medium); - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null) - { - if (!archivedCollection.ArchivedPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, archive.id, medium.files.drm.manifest.dash, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - archivedCollection.ArchivedPosts.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{archive.id}"); - archivedCollection.ArchivedPostMedia.Add(medium); - } - } - } - } - } - - return archivedCollection; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task GetMessages(string endpoint, string folder, IDownloadConfig config, StatusContext ctx) - { - Log.Debug($"Calling GetMessages - {endpoint}"); - - try - { - Messages messages = new(); - MessageCollection messageCollection = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "order", "desc" }, - { "skip_users", "all" } - }; - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - messages = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - ctx.Status($"[red]Getting Messages\n[/] [red]Found {messages.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (messages.hasMore) - { - getParams["id"] = messages.list[^1].id.ToString(); - while (true) - { - Messages newmessages = new(); - - var loopbody = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - newmessages = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - - messages.list.AddRange(newmessages.list); - ctx.Status($"[red]Getting Messages\n[/] [red]Found {messages.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (!newmessages.hasMore) - { - break; - } - getParams["id"] = newmessages.list[newmessages.list.Count - 1].id.ToString(); - } - } - - foreach (Messages.List list in messages.list) - { - if (config.SkipAds) - { - if (list.text != null && (list.text.Contains("#ad") || list.text.Contains("/trial/"))) - { - continue; - } - } - List messagePreviewIds = new(); - if (list.previews != null && list.previews.Count > 0) - { - for (int i = 0; i < list.previews.Count; i++) - { - if (list.previews[i] is long previewId) - { - if (!messagePreviewIds.Contains(previewId)) - { - messagePreviewIds.Add(previewId); - } - } - } - } - if (!config.IgnoreOwnMessages || list.fromUser.id != Convert.ToInt32(auth.USER_ID)) - { - await m_DBHelper.AddMessage(folder, list.id, list.text != null ? list.text : string.Empty, list.price != null ? list.price.ToString() : "0", list.canPurchaseReason == "opened" ? true : list.canPurchaseReason != "opened" ? false : (bool?)null ?? false, false, list.createdAt.HasValue ? list.createdAt.Value : DateTime.Now, list.fromUser != null && list.fromUser.id != null ? list.fromUser.id.Value : int.MinValue); - messageCollection.MessageObjects.Add(list); - if (list.canPurchaseReason != "opened" && list.media != null && list.media.Count > 0) - { - foreach (Messages.Medium medium in list.media) - { - if (medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!messageCollection.Messages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, list.id, medium.files.full.url, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), messagePreviewIds.Contains(medium.id) ? true : false, false, null); - messageCollection.Messages.Add(medium.id, medium.files.full.url); - messageCollection.MessageMedia.Add(medium); - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!messageCollection.Messages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, list.id, medium.files.drm.manifest.dash, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), messagePreviewIds.Contains(medium.id) ? true : false, false, null); - messageCollection.Messages.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{list.id}"); - messageCollection.MessageMedia.Add(medium); - } - } - } - } - else if (messagePreviewIds.Count > 0) - { - foreach (Messages.Medium medium in list.media) - { - if (medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url) && messagePreviewIds.Contains(medium.id)) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!messageCollection.Messages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, list.id, medium.files.full.url, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), messagePreviewIds.Contains(medium.id) ? true : false, false, null); - messageCollection.Messages.Add(medium.id, medium.files.full.url); - messageCollection.MessageMedia.Add(medium); - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null && messagePreviewIds.Contains(medium.id)) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!messageCollection.Messages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, list.id, medium.files.drm.manifest.dash, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), messagePreviewIds.Contains(medium.id) ? true : false, false, null); - messageCollection.Messages.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{list.id}"); - messageCollection.MessageMedia.Add(medium); - } - } - } - } - } - } - - return messageCollection; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - public async Task GetPaidMessage(string endpoint, string folder, IDownloadConfig config) - { - Log.Debug($"Calling GetPaidMessage - {endpoint}"); - - try - { - SingleMessage message = new(); - SinglePaidMessageCollection singlePaidMessageCollection = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "order", "desc" } - }; - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - message = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - - if (!config.IgnoreOwnMessages || message.fromUser.id != Convert.ToInt32(auth.USER_ID)) - { - await m_DBHelper.AddMessage(folder, message.id, message.text != null ? message.text : string.Empty, message.price != null ? message.price.ToString() : "0", true, false, message.createdAt.HasValue ? message.createdAt.Value : DateTime.Now, message.fromUser != null && message.fromUser.id != null ? message.fromUser.id.Value : int.MinValue); - singlePaidMessageCollection.SingleMessageObjects.Add(message); - List messagePreviewIds = new(); - if (message.previews != null && message.previews.Count > 0) - { - for (int i = 0; i < message.previews.Count; i++) - { - if (message.previews[i] is long previewId) - { - if (!messagePreviewIds.Contains(previewId)) - { - messagePreviewIds.Add(previewId); - } - } - } - } - - if (message.media != null && message.media.Count > 0) - { - foreach (Messages.Medium medium in message.media) - { - if (!messagePreviewIds.Contains(medium.id) && medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - - if (!singlePaidMessageCollection.SingleMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, message.id, medium.files.full.url, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), messagePreviewIds.Contains(medium.id) ? true : false, false, null); - singlePaidMessageCollection.SingleMessages.Add(medium.id, medium.files.full.url.ToString()); - singlePaidMessageCollection.SingleMessageMedia.Add(medium); - } - } - else if (messagePreviewIds.Contains(medium.id) && medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - - if (!singlePaidMessageCollection.PreviewSingleMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, message.id, medium.files.full.url, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), messagePreviewIds.Contains(medium.id) ? true : false, false, null); - singlePaidMessageCollection.PreviewSingleMessages.Add(medium.id, medium.files.full.url.ToString()); - singlePaidMessageCollection.PreviewSingleMessageMedia.Add(medium); - } - } - else if (!messagePreviewIds.Contains(medium.id) && medium.canView && medium.files != null && medium.files.drm != null) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - - if (!singlePaidMessageCollection.SingleMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, message.id, medium.files.drm.manifest.dash, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), messagePreviewIds.Contains(medium.id) ? true : false, false, null); - singlePaidMessageCollection.SingleMessages.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{message.id}"); - singlePaidMessageCollection.SingleMessageMedia.Add(medium); - } - } - else if (messagePreviewIds.Contains(medium.id) && medium.canView && medium.files != null && medium.files.drm != null) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - - if (!singlePaidMessageCollection.PreviewSingleMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, message.id, medium.files.drm.manifest.dash, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), messagePreviewIds.Contains(medium.id) ? true : false, false, null); - singlePaidMessageCollection.PreviewSingleMessages.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{message.id}"); - singlePaidMessageCollection.PreviewSingleMessageMedia.Add(medium); - } - } - } - } - } - - return singlePaidMessageCollection; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task GetPaidMessages(string endpoint, string folder, string username, IDownloadConfig config, StatusContext ctx) - { - Log.Debug($"Calling GetPaidMessages - {username}"); - - try - { - Purchased paidMessages = new(); - PaidMessageCollection paidMessageCollection = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "order", "publish_date_desc" }, - { "format", "infinite" }, - { "author", username }, - { "skip_users", "all" } - }; - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - paidMessages = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - ctx.Status($"[red]Getting Paid Messages\n[/] [red]Found {paidMessages.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (paidMessages != null && paidMessages.hasMore) - { - getParams["offset"] = paidMessages.list.Count.ToString(); - while (true) - { - string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); - Purchased newpaidMessages = new(); - Dictionary loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); - HttpClient loopclient = GetHttpClient(config); - - HttpRequestMessage looprequest = new(HttpMethod.Get, $"{Constants.API_URL}{endpoint}{loopqueryParams}"); - - foreach (KeyValuePair keyValuePair in loopheaders) - { - looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); - } - using (var loopresponse = await loopclient.SendAsync(looprequest)) - { - loopresponse.EnsureSuccessStatusCode(); - var loopbody = await loopresponse.Content.ReadAsStringAsync(); - newpaidMessages = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - } - paidMessages.list.AddRange(newpaidMessages.list); - ctx.Status($"[red]Getting Paid Messages\n[/] [red]Found {paidMessages.list.Count}[/]"); - ctx.Spinner(Spinner.Known.Dots); - ctx.SpinnerStyle(Style.Parse("blue")); - if (!newpaidMessages.hasMore) - { - break; - } - getParams["offset"] = Convert.ToString(Convert.ToInt32(getParams["offset"]) + post_limit); - } - } - - if (paidMessages.list != null && paidMessages.list.Count > 0) - { - foreach (Purchased.List purchase in paidMessages.list.Where(p => p.responseType == "message").OrderByDescending(p => p.postedAt ?? p.createdAt)) - { - if (!config.IgnoreOwnMessages || purchase.fromUser.id != Convert.ToInt32(auth.USER_ID)) - { - if (purchase.postedAt != null) - { - await m_DBHelper.AddMessage(folder, purchase.id, purchase.text != null ? purchase.text : string.Empty, purchase.price != null ? purchase.price : "0", true, false, purchase.postedAt.Value, purchase.fromUser.id); - } - else - { - await m_DBHelper.AddMessage(folder, purchase.id, purchase.text != null ? purchase.text : string.Empty, purchase.price != null ? purchase.price : "0", true, false, purchase.createdAt.Value, purchase.fromUser.id); - } - paidMessageCollection.PaidMessageObjects.Add(purchase); - if (purchase.media != null && purchase.media.Count > 0) - { - List previewids = new(); - if (purchase.previews != null) - { - for (int i = 0; i < purchase.previews.Count; i++) - { - if (purchase.previews[i] is long previewId) - { - if (!previewids.Contains(previewId)) - { - previewids.Add(previewId); - } - } - } - } - else if (purchase.preview != null) - { - for (int i = 0; i < purchase.preview.Count; i++) - { - if (purchase.preview[i] is long previewId) - { - if (!previewids.Contains(previewId)) - { - previewids.Add(previewId); - } - } - } - } - - foreach (Messages.Medium medium in purchase.media) - { - if (previewids.Count > 0) - { - bool has = previewids.Any(cus => cus.Equals(medium.id)); - if (!has && medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!paidMessageCollection.PaidMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, purchase.id, medium.files.full.url, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - paidMessageCollection.PaidMessages.Add(medium.id, medium.files.full.url); - paidMessageCollection.PaidMessageMedia.Add(medium); - } - } - else if (!has && medium.canView && medium.files != null && medium.files.drm != null) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!paidMessageCollection.PaidMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, purchase.id, medium.files.drm.manifest.dash, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - paidMessageCollection.PaidMessages.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{purchase.id}"); - paidMessageCollection.PaidMessageMedia.Add(medium); - } - } - } - else - { - if (medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!paidMessageCollection.PaidMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, purchase.id, medium.files.full.url, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - paidMessageCollection.PaidMessages.Add(medium.id, medium.files.full.url); - paidMessageCollection.PaidMessageMedia.Add(medium); - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!paidMessageCollection.PaidMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(folder, medium.id, purchase.id, medium.files.drm.manifest.dash, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - paidMessageCollection.PaidMessages.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{purchase.id}"); - paidMessageCollection.PaidMessageMedia.Add(medium); - } - } - } - } - } - } - } - } - - return paidMessageCollection; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - public async Task> GetPurchasedTabUsers(string endpoint, IDownloadConfig config, Dictionary users) - { - Log.Debug($"Calling GetPurchasedTabUsers - {endpoint}"); - - try - { - Dictionary purchasedTabUsers = new(); - Purchased purchased = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "order", "publish_date_desc" }, - { "format", "infinite" }, - { "skip_users", "all" } - }; - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - purchased = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - if (purchased != null && purchased.hasMore) - { - getParams["offset"] = purchased.list.Count.ToString(); - while (true) - { - string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); - Purchased newPurchased = new(); - Dictionary loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); - HttpClient loopclient = GetHttpClient(config); - - HttpRequestMessage looprequest = new(HttpMethod.Get, $"{Constants.API_URL}{endpoint}{loopqueryParams}"); - - foreach (KeyValuePair keyValuePair in loopheaders) - { - looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); - } - using (var loopresponse = await loopclient.SendAsync(looprequest)) - { - loopresponse.EnsureSuccessStatusCode(); - var loopbody = await loopresponse.Content.ReadAsStringAsync(); - newPurchased = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - } - purchased.list.AddRange(newPurchased.list); - if (!newPurchased.hasMore) - { - break; - } - getParams["offset"] = Convert.ToString(Convert.ToInt32(getParams["offset"]) + post_limit); - } - } - - if (purchased.list != null && purchased.list.Count > 0) - { - foreach (Purchased.List purchase in purchased.list.OrderByDescending(p => p.postedAt ?? p.createdAt)) - { - if (purchase.fromUser != null) - { - if (users.Values.Contains(purchase.fromUser.id)) - { - if (!string.IsNullOrEmpty(users.FirstOrDefault(x => x.Value == purchase.fromUser.id).Key)) - { - if (!purchasedTabUsers.ContainsKey(users.FirstOrDefault(x => x.Value == purchase.fromUser.id).Key)) - { - purchasedTabUsers.Add(users.FirstOrDefault(x => x.Value == purchase.fromUser.id).Key, purchase.fromUser.id); - } - } - else - { - if (!purchasedTabUsers.ContainsKey($"Deleted User - {purchase.fromUser.id}")) - { - purchasedTabUsers.Add($"Deleted User - {purchase.fromUser.id}", purchase.fromUser.id); - } - } - } - else - { - JObject user = await GetUserInfoById($"/users/list?x[]={purchase.fromUser.id}"); - - if(user is null) - { - if (!config.BypassContentForCreatorsWhoNoLongerExist) - { - if(!purchasedTabUsers.ContainsKey($"Deleted User - {purchase.fromUser.id}")) - { - purchasedTabUsers.Add($"Deleted User - {purchase.fromUser.id}", purchase.fromUser.id); - } - } - Log.Debug("Content creator not longer exists - {0}", purchase.fromUser.id); - } - else if (!string.IsNullOrEmpty(user[purchase.fromUser.id.ToString()]["username"].ToString())) - { - if (!purchasedTabUsers.ContainsKey(user[purchase.fromUser.id.ToString()]["username"].ToString())) - { - purchasedTabUsers.Add(user[purchase.fromUser.id.ToString()]["username"].ToString(), purchase.fromUser.id); - } - } - else - { - if (!purchasedTabUsers.ContainsKey($"Deleted User - {purchase.fromUser.id}")) - { - purchasedTabUsers.Add($"Deleted User - {purchase.fromUser.id}", purchase.fromUser.id); - } - } - } - } - else if (purchase.author != null) - { - if (users.Values.Contains(purchase.author.id)) - { - if (!string.IsNullOrEmpty(users.FirstOrDefault(x => x.Value == purchase.author.id).Key)) - { - if (!purchasedTabUsers.ContainsKey(users.FirstOrDefault(x => x.Value == purchase.author.id).Key) && users.ContainsKey(users.FirstOrDefault(x => x.Value == purchase.author.id).Key)) - { - purchasedTabUsers.Add(users.FirstOrDefault(x => x.Value == purchase.author.id).Key, purchase.author.id); - } - } - else - { - if (!purchasedTabUsers.ContainsKey($"Deleted User - {purchase.author.id}")) - { - purchasedTabUsers.Add($"Deleted User - {purchase.author.id}", purchase.author.id); - } - } - } - else - { - JObject user = await GetUserInfoById($"/users/list?x[]={purchase.author.id}"); - - if (user is null) - { - if (!config.BypassContentForCreatorsWhoNoLongerExist) - { - if(!purchasedTabUsers.ContainsKey($"Deleted User - {purchase.author.id}")) - { - purchasedTabUsers.Add($"Deleted User - {purchase.author.id}", purchase.author.id); - } - } - Log.Debug("Content creator not longer exists - {0}", purchase.author.id); - } - else if (!string.IsNullOrEmpty(user[purchase.author.id.ToString()]["username"].ToString())) - { - if (!purchasedTabUsers.ContainsKey(user[purchase.author.id.ToString()]["username"].ToString()) && users.ContainsKey(user[purchase.author.id.ToString()]["username"].ToString())) - { - purchasedTabUsers.Add(user[purchase.author.id.ToString()]["username"].ToString(), purchase.author.id); - } - } - else - { - if (!purchasedTabUsers.ContainsKey($"Deleted User - {purchase.author.id}")) - { - purchasedTabUsers.Add($"Deleted User - {purchase.author.id}", purchase.author.id); - } - } - } - } - } - } - - return purchasedTabUsers; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - public async Task> GetPurchasedTab(string endpoint, string folder, IDownloadConfig config, Dictionary users) - { - Log.Debug($"Calling GetPurchasedTab - {endpoint}"); - - try - { - Dictionary> userPurchases = new Dictionary>(); - List purchasedTabCollections = new(); - Purchased purchased = new(); - int post_limit = 50; - Dictionary getParams = new() - { - { "limit", post_limit.ToString() }, - { "order", "publish_date_desc" }, - { "format", "infinite" }, - { "skip_users", "all" } - }; - - var body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient(config)); - purchased = JsonConvert.DeserializeObject(body, m_JsonSerializerSettings); - if (purchased != null && purchased.hasMore) - { - getParams["offset"] = purchased.list.Count.ToString(); - while (true) - { - string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); - Purchased newPurchased = new(); - Dictionary loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); - HttpClient loopclient = GetHttpClient(config); - - HttpRequestMessage looprequest = new(HttpMethod.Get, $"{Constants.API_URL}{endpoint}{loopqueryParams}"); - - foreach (KeyValuePair keyValuePair in loopheaders) - { - looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); - } - using (var loopresponse = await loopclient.SendAsync(looprequest)) - { - loopresponse.EnsureSuccessStatusCode(); - var loopbody = await loopresponse.Content.ReadAsStringAsync(); - newPurchased = JsonConvert.DeserializeObject(loopbody, m_JsonSerializerSettings); - } - purchased.list.AddRange(newPurchased.list); - if (!newPurchased.hasMore) - { - break; - } - getParams["offset"] = Convert.ToString(Convert.ToInt32(getParams["offset"]) + post_limit); - } - } - - if (purchased.list != null && purchased.list.Count > 0) - { - foreach (Purchased.List purchase in purchased.list.OrderByDescending(p => p.postedAt ?? p.createdAt)) - { - if (purchase.fromUser != null) - { - if (!userPurchases.ContainsKey(purchase.fromUser.id)) - { - userPurchases.Add(purchase.fromUser.id, new List()); - } - userPurchases[purchase.fromUser.id].Add(purchase); - } - else if (purchase.author != null) - { - if (!userPurchases.ContainsKey(purchase.author.id)) - { - userPurchases.Add(purchase.author.id, new List()); - } - userPurchases[purchase.author.id].Add(purchase); - } - } - } - - foreach (KeyValuePair> user in userPurchases) - { - PurchasedTabCollection purchasedTabCollection = new PurchasedTabCollection(); - JObject userObject = await GetUserInfoById($"/users/list?x[]={user.Key}"); - purchasedTabCollection.UserId = user.Key; - purchasedTabCollection.Username = userObject is not null && !string.IsNullOrEmpty(userObject[user.Key.ToString()]["username"].ToString()) ? userObject[user.Key.ToString()]["username"].ToString() : $"Deleted User - {user.Key}"; - string path = System.IO.Path.Combine(folder, purchasedTabCollection.Username); - if (Path.Exists(path)) - { - foreach (Purchased.List purchase in user.Value) - { - if (purchase.media == null) - { - Log.Warning("PurchasedTab purchase media null, setting empty list | userId={UserId} username={Username} purchaseId={PurchaseId} responseType={ResponseType} createdAt={CreatedAt} postedAt={PostedAt}", user.Key, purchasedTabCollection.Username, purchase.id, purchase.responseType, purchase.createdAt, purchase.postedAt); - purchase.media = new List(); - } - switch (purchase.responseType) - { - case "post": - List previewids = new(); - if (purchase.previews != null) - { - for (int i = 0; i < purchase.previews.Count; i++) - { - if (purchase.previews[i] is long previewId) - { - if (!previewids.Contains(previewId)) - { - previewids.Add(previewId); - } - } - } - } - else if (purchase.preview != null) - { - for (int i = 0; i < purchase.preview.Count; i++) - { - if (purchase.preview[i] is long previewId) - { - if (!previewids.Contains(previewId)) - { - previewids.Add(previewId); - } - } - } - } - await m_DBHelper.AddPost(path, purchase.id, purchase.text != null ? purchase.text : string.Empty, purchase.price != null ? purchase.price.ToString() : "0", purchase.price != null && purchase.isOpened ? true : false, purchase.isArchived.HasValue ? purchase.isArchived.Value : false, purchase.createdAt != null ? purchase.createdAt.Value : purchase.postedAt.Value); - purchasedTabCollection.PaidPosts.PaidPostObjects.Add(purchase); - foreach (Messages.Medium medium in purchase.media) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (previewids.Count > 0) - { - bool has = previewids.Any(cus => cus.Equals(medium.id)); - if (!has && medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - - if (!purchasedTabCollection.PaidPosts.PaidPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(path, medium.id, purchase.id, medium.files.full.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - purchasedTabCollection.PaidPosts.PaidPosts.Add(medium.id, medium.files.full.url); - purchasedTabCollection.PaidPosts.PaidPostMedia.Add(medium); - } - } - else if (!has && medium.canView && medium.files != null && medium.files.drm != null) - { - - if (!purchasedTabCollection.PaidPosts.PaidPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(path, medium.id, purchase.id, medium.files.drm.manifest.dash, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - purchasedTabCollection.PaidPosts.PaidPosts.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{purchase.id}"); - purchasedTabCollection.PaidPosts.PaidPostMedia.Add(medium); - } - - } - } - else - { - if (medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (!purchasedTabCollection.PaidPosts.PaidPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(path, medium.id, purchase.id, medium.files.full.url, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - purchasedTabCollection.PaidPosts.PaidPosts.Add(medium.id, medium.files.full.url); - purchasedTabCollection.PaidPosts.PaidPostMedia.Add(medium); - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null) - { - if (!purchasedTabCollection.PaidPosts.PaidPosts.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(path, medium.id, purchase.id, medium.files.drm.manifest.dash, null, null, null, "Posts", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), previewids.Contains(medium.id) ? true : false, false, null); - purchasedTabCollection.PaidPosts.PaidPosts.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{purchase.id}"); - purchasedTabCollection.PaidPosts.PaidPostMedia.Add(medium); - } - } - } - } - break; - case "message": - if (purchase.postedAt != null) - { - await m_DBHelper.AddMessage(path, purchase.id, purchase.text != null ? purchase.text : string.Empty, purchase.price != null ? purchase.price : "0", true, false, purchase.postedAt.Value, purchase.fromUser.id); - } - else - { - await m_DBHelper.AddMessage(path, purchase.id, purchase.text != null ? purchase.text : string.Empty, purchase.price != null ? purchase.price : "0", true, false, purchase.createdAt.Value, purchase.fromUser.id); - } - purchasedTabCollection.PaidMessages.PaidMessageObjects.Add(purchase); - if (purchase.media != null && purchase.media.Count > 0) - { - List paidMessagePreviewids = new(); - if (purchase.previews != null) - { - for (int i = 0; i < purchase.previews.Count; i++) - { - if (purchase.previews[i] is long previewId) - { - if (!paidMessagePreviewids.Contains(previewId)) - { - paidMessagePreviewids.Add(previewId); - } - } - } - } - else if (purchase.preview != null) - { - for (int i = 0; i < purchase.preview.Count; i++) - { - if (purchase.preview[i] is long previewId) - { - if (!paidMessagePreviewids.Contains(previewId)) - { - paidMessagePreviewids.Add(previewId); - } - } - } - } - - foreach (Messages.Medium medium in purchase.media) - { - if (paidMessagePreviewids.Count > 0) - { - bool has = paidMessagePreviewids.Any(cus => cus.Equals(medium.id)); - if (!has && medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!purchasedTabCollection.PaidMessages.PaidMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(path, medium.id, purchase.id, medium.files.full.url, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), paidMessagePreviewids.Contains(medium.id) ? true : false, false, null); - purchasedTabCollection.PaidMessages.PaidMessages.Add(medium.id, medium.files.full.url); - purchasedTabCollection.PaidMessages.PaidMessageMedia.Add(medium); - } - } - else if (!has && medium.canView && medium.files != null && medium.files.drm != null) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!purchasedTabCollection.PaidMessages.PaidMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(path, medium.id, purchase.id, medium.files.drm.manifest.dash, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), paidMessagePreviewids.Contains(medium.id) ? true : false, false, null); - purchasedTabCollection.PaidMessages.PaidMessages.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{purchase.id}"); - purchasedTabCollection.PaidMessages.PaidMessageMedia.Add(medium); - } - } - } - else - { - if (medium.canView && medium.files != null && medium.files.full != null && !string.IsNullOrEmpty(medium.files.full.url)) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!purchasedTabCollection.PaidMessages.PaidMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(path, medium.id, purchase.id, medium.files.full.url, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), paidMessagePreviewids.Contains(medium.id) ? true : false, false, null); - purchasedTabCollection.PaidMessages.PaidMessages.Add(medium.id, medium.files.full.url); - purchasedTabCollection.PaidMessages.PaidMessageMedia.Add(medium); - } - } - else if (medium.canView && medium.files != null && medium.files.drm != null) - { - if (medium.type == "photo" && !config.DownloadImages) - { - continue; - } - if (medium.type == "video" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "gif" && !config.DownloadVideos) - { - continue; - } - if (medium.type == "audio" && !config.DownloadAudios) - { - continue; - } - if (!purchasedTabCollection.PaidMessages.PaidMessages.ContainsKey(medium.id)) - { - await m_DBHelper.AddMedia(path, medium.id, purchase.id, medium.files.drm.manifest.dash, null, null, null, "Messages", medium.type == "photo" ? "Images" : (medium.type == "video" || medium.type == "gif" ? "Videos" : (medium.type == "audio" ? "Audios" : null)), paidMessagePreviewids.Contains(medium.id) ? true : false, false, null); - purchasedTabCollection.PaidMessages.PaidMessages.Add(medium.id, $"{medium.files.drm.manifest.dash},{medium.files.drm.signature.dash.CloudFrontPolicy},{medium.files.drm.signature.dash.CloudFrontSignature},{medium.files.drm.signature.dash.CloudFrontKeyPairId},{medium.id},{purchase.id}"); - purchasedTabCollection.PaidMessages.PaidMessageMedia.Add(medium); - } - } - } - } - } - break; - } - } - purchasedTabCollections.Add(purchasedTabCollection); - } - } - return purchasedTabCollections; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task GetDRMMPDPSSH(string mpdUrl, string policy, string signature, string kvp) - { - try - { - string pssh = null; - - HttpClient client = new(); - HttpRequestMessage request = new(HttpMethod.Get, mpdUrl); - request.Headers.Add("user-agent", auth.USER_AGENT); - request.Headers.Add("Accept", "*/*"); - request.Headers.Add("Cookie", $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {auth.COOKIE};"); - using (var response = await client.SendAsync(request)) - { - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStringAsync(); - XNamespace ns = "urn:mpeg:dash:schema:mpd:2011"; - XNamespace cenc = "urn:mpeg:cenc:2013"; - XDocument xmlDoc = XDocument.Parse(body); - var psshElements = xmlDoc.Descendants(cenc + "pssh"); - pssh = psshElements.ElementAt(1).Value; - } - - return pssh; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - - public async Task GetDRMMPDLastModified(string mpdUrl, string policy, string signature, string kvp) - { - Log.Debug("Calling GetDRMMPDLastModified"); - Log.Debug($"mpdUrl: {mpdUrl}"); - Log.Debug($"policy: {policy}"); - Log.Debug($"signature: {signature}"); - Log.Debug($"kvp: {kvp}"); - - try - { - DateTime lastmodified; - - HttpClient client = new(); - HttpRequestMessage request = new(HttpMethod.Get, mpdUrl); - request.Headers.Add("user-agent", auth.USER_AGENT); - request.Headers.Add("Accept", "*/*"); - request.Headers.Add("Cookie", $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {auth.COOKIE};"); - using (var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)) - { - response.EnsureSuccessStatusCode(); - lastmodified = response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now; - - Log.Debug($"Last modified: {lastmodified}"); - } - return lastmodified; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return DateTime.Now; - } - - public async Task GetDecryptionKeyCDRMProject(Dictionary drmHeaders, string licenceURL, string pssh) - { - Log.Debug("Calling GetDecryptionKey"); - - int attempt = 0; - - try - { - string dcValue = string.Empty; - HttpClient client = new(); - - CDRMProjectRequest cdrmProjectRequest = new CDRMProjectRequest - { - PSSH = pssh, - LicenseURL = licenceURL, - Headers = JsonConvert.SerializeObject(drmHeaders), - Cookies = "", - Data = "" - }; - - string json = JsonConvert.SerializeObject(cdrmProjectRequest); - - Log.Debug($"Posting to CDRM Project: {json}"); - - while (attempt < MaxAttempts) - { - attempt++; - - HttpRequestMessage request = new(HttpMethod.Post, "https://cdrm-project.com/api/decrypt") - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; - - using var response = await client.SendAsync(request); - - Log.Debug($"CDRM Project Response (Attempt {attempt}): {response.Content.ReadAsStringAsync().Result}"); - - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(body); - - if (doc.RootElement.TryGetProperty("status", out JsonElement status)) - { - if (status.ToString().Trim().Equals("success", StringComparison.OrdinalIgnoreCase)) - { - dcValue = doc.RootElement.GetProperty("message").GetString().Trim(); - return dcValue; - } - else - { - Log.Debug($"CDRM response status not successful. Retrying... Attempt {attempt} of {MaxAttempts}"); - if (attempt < MaxAttempts) - { - await Task.Delay(DelayBetweenAttempts); - } - } - } - else - { - Log.Debug($"Status not in CDRM response. Retrying... Attempt {attempt} of {MaxAttempts}"); - if (attempt < MaxAttempts) - { - await Task.Delay(DelayBetweenAttempts); - } - } - } - - throw new Exception("Maximum retry attempts reached. Unable to get a valid decryption key."); - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - public async Task GetDecryptionKeyOFDL(Dictionary drmHeaders, string licenceURL, string pssh) - { - Log.Debug("Calling GetDecryptionOFDL"); - - try - { - HttpClient client = new(); - int attempt = 0; - - OFDLRequest ofdlRequest = new OFDLRequest - { - PSSH = pssh, - LicenseURL = licenceURL, - Headers = JsonConvert.SerializeObject(drmHeaders) - }; - - string json = JsonConvert.SerializeObject(ofdlRequest); - - Log.Debug($"Posting to ofdl.tools: {json}"); - - while (attempt < MaxAttempts) - { - attempt++; - - HttpRequestMessage request = new(HttpMethod.Post, "https://ofdl.tools/WV") - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; - - using var response = await client.SendAsync(request); - - if (!response.IsSuccessStatusCode) - continue; - - string body = await response.Content.ReadAsStringAsync(); - - if (!body.TrimStart().StartsWith('{')) - return body; - - Log.Debug($"Received JSON object instead of string. Retrying... Attempt {attempt} of {MaxAttempts}"); - await Task.Delay(DelayBetweenAttempts); - } - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - - return null; - } - - public async Task GetDecryptionKeyCDM(Dictionary drmHeaders, string licenceURL, string pssh) - { - Log.Debug("Calling GetDecryptionKeyCDM"); - - try - { - var resp1 = await PostData(licenceURL, drmHeaders, new byte[] { 0x08, 0x04 }); - var certDataB64 = Convert.ToBase64String(resp1); - var cdm = new CDMApi(); - var challenge = cdm.GetChallenge(pssh, certDataB64, false, false); - var resp2 = await PostData(licenceURL, drmHeaders, challenge); - var licenseB64 = Convert.ToBase64String(resp2); - Log.Debug($"resp1: {resp1}"); - Log.Debug($"certDataB64: {certDataB64}"); - Log.Debug($"challenge: {challenge}"); - Log.Debug($"resp2: {resp2}"); - Log.Debug($"licenseB64: {licenseB64}"); - cdm.ProvideLicense(licenseB64); - List keys = cdm.GetKeys(); - if (keys.Count > 0) - { - Log.Debug($"GetDecryptionKeyCDM Key: {keys[0].ToString()}"); - return keys[0].ToString(); - } - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } - - public static string? GetDynamicRules() - { - Log.Debug("Calling GetDynamicRules"); - try - { - HttpClient client = new HttpClient(); - HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "https://git.ofdl.tools/sim0n00ps/dynamic-rules/raw/branch/main/rules.json"); - using var response = client.Send(request); - - if (!response.IsSuccessStatusCode) - { - Log.Debug("GetDynamicRules did not return a Success Status Code"); - return null; - } - - var body = response.Content.ReadAsStringAsync().Result; - - Log.Debug("GetDynamicRules Response: "); - Log.Debug(body); - - return body; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return null; - } -} diff --git a/OF DL/Helpers/AuthHelper.cs b/OF DL/Helpers/AuthHelper.cs deleted file mode 100644 index a694be1..0000000 --- a/OF DL/Helpers/AuthHelper.cs +++ /dev/null @@ -1,184 +0,0 @@ -using OF_DL.Entities; -using Serilog; -using Microsoft.Playwright; - -namespace OF_DL.Helpers; - -public class AuthHelper -{ - private readonly string _userDataDir = Path.GetFullPath("chromium-data"); - - private const string _initScriptsDirName = "chromium-scripts"; - - private readonly BrowserTypeLaunchPersistentContextOptions _options = new() - { - Headless = false, - Channel = "chromium", - Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-blink-features=AutomationControlled", "--disable-infobars"], - }; - - private readonly string[] _desiredCookies = - [ - "auth_id", - "sess" - ]; - - private const float LoginTimeout = 600000f; // 10 minutes - private const float FeedLoadTimeout = 60000f; // 1 minute - - public Task SetupBrowser(bool runningInDocker) - { - if (runningInDocker) - { - 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 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; - } - } - - var exitCode = Microsoft.Playwright.Program.Main(["install", "--with-deps", "chromium"]); - if (exitCode != 0) - { - throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}"); - } - - return Task.CompletedTask; - } - - private async Task GetBcToken(IPage page) - { - return await page.EvaluateAsync("window.localStorage.getItem('bcTokenSha') || ''"); - } - - public async Task GetAuthFromBrowser(bool isDocker = false) - { - try - { - IBrowserContext? browser = null; - try - { - var playwright = await Playwright.CreateAsync(); - browser = await playwright.Chromium.LaunchPersistentContextAsync(_userDataDir, _options); - } - catch (Exception e) - { - if (( - e.Message.Contains("An error occurred trying to start process") || - e.Message.Contains("The profile appears to be in use by another Chromium process") - ) && Directory.Exists(_userDataDir)) - { - Log.Error("Failed to launch browser. Deleting chromium-data directory and trying again."); - Directory.Delete(_userDataDir, true); - IPlaywright playwright = await Playwright.CreateAsync(); - browser = await playwright.Chromium.LaunchPersistentContextAsync(_userDataDir, _options); - } - else - { - throw; - } - } - - if (browser == null) - { - throw new Exception("Could not get browser"); - } - - IPage? page = browser.Pages[0]; - - if (page == null) - { - throw new Exception("Could not get page"); - } - - string exeDirectory = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty; - string initScriptsDir = Path.Combine(exeDirectory, _initScriptsDirName); - if (Directory.Exists(initScriptsDir)) - { - Log.Information("Loading init scripts from {initScriptsDir}", initScriptsDir); - foreach (string initScript in Directory.GetFiles(initScriptsDir, "*.js")) - { - Log.Debug("Loading init script {initScript}", initScript); - await page.AddInitScriptAsync(initScript); - } - } - - Log.Debug("Navigating to OnlyFans."); - await page.GotoAsync("https://onlyfans.com"); - - Log.Debug("Waiting for user to login"); - await page.WaitForSelectorAsync(".b-feed", new PageWaitForSelectorOptions { Timeout = LoginTimeout }); - Log.Debug("Feed element detected (user logged in)"); - - await page.ReloadAsync(); - - await page.WaitForNavigationAsync(new PageWaitForNavigationOptions { - WaitUntil = WaitUntilState.DOMContentLoaded, - Timeout = FeedLoadTimeout - }); - Log.Debug("DOM loaded. Getting BC token and cookies ..."); - - string xBc; - try - { - xBc = await GetBcToken(page); - } - catch (Exception e) - { - await browser.CloseAsync(); - throw new Exception("Error getting bcToken"); - } - - Dictionary mappedCookies = (await browser.CookiesAsync()) - .Where(cookie => cookie.Domain.Contains("onlyfans.com")) - .ToDictionary(cookie => cookie.Name, cookie => cookie.Value); - - mappedCookies.TryGetValue("auth_id", out string? userId); - if (userId == null) - { - await browser.CloseAsync(); - throw new Exception("Could not find 'auth_id' cookie"); - } - - mappedCookies.TryGetValue("sess", out string? sess); - if (sess == null) - { - await browser.CloseAsync(); - throw new Exception("Could not find 'sess' cookie"); - } - - string? userAgent = await page.EvaluateAsync("navigator.userAgent"); - if (userAgent == null) - { - await browser.CloseAsync(); - throw new Exception("Could not get user agent"); - } - - string cookies = string.Join(" ", mappedCookies.Keys.Where(key => _desiredCookies.Contains(key)) - .Select(key => $"${key}={mappedCookies[key]};")); - - await browser.CloseAsync(); - - return new Auth() - { - COOKIE = cookies, - USER_AGENT = userAgent, - USER_ID = userId, - X_BC = xBc - }; - } - catch (Exception e) - { - Log.Error(e, "Error getting auth from browser"); - return null; - } - } -} diff --git a/OF DL/Helpers/Constants.cs b/OF DL/Helpers/Constants.cs deleted file mode 100644 index 01a410d..0000000 --- a/OF DL/Helpers/Constants.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OF_DL.Helpers; - -public static class Constants -{ - public const string API_URL = "https://onlyfans.com/api2/v2"; - - public const int WIDEVINE_RETRY_DELAY = 10; - public const int WIDEVINE_MAX_RETRIES = 3; -} diff --git a/OF DL/Helpers/DBHelper.cs b/OF DL/Helpers/DBHelper.cs deleted file mode 100644 index 697fa71..0000000 --- a/OF DL/Helpers/DBHelper.cs +++ /dev/null @@ -1,531 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using OF_DL.Enumurations; -using System.IO; -using Microsoft.Data.Sqlite; -using Serilog; -using OF_DL.Entities; - -namespace OF_DL.Helpers -{ - public class DBHelper : IDBHelper - { - private readonly IDownloadConfig downloadConfig; - - public DBHelper(IDownloadConfig downloadConfig) - { - this.downloadConfig = downloadConfig; - } - - public async Task CreateDB(string folder) - { - try - { - if (!Directory.Exists(folder + "/Metadata")) - { - Directory.CreateDirectory(folder + "/Metadata"); - } - - string dbFilePath = $"{folder}/Metadata/user_data.db"; - - // connect to the new database file - using SqliteConnection connection = new($"Data Source={dbFilePath}"); - // open the connection - connection.Open(); - - // create the 'medias' table - using (SqliteCommand cmd = new("CREATE TABLE IF NOT EXISTS medias (id INTEGER NOT NULL, media_id INTEGER, post_id INTEGER NOT NULL, link VARCHAR, directory VARCHAR, filename VARCHAR, size INTEGER, api_type VARCHAR, media_type VARCHAR, preview INTEGER, linked VARCHAR, downloaded INTEGER, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(media_id));", connection)) - { - await cmd.ExecuteNonQueryAsync(); - } - - await EnsureCreatedAtColumnExists(connection, "medias"); - - // - // Alter existing databases to create unique constraint on `medias` - // - using (SqliteCommand cmd = new(@" - PRAGMA foreign_keys=off; - - BEGIN TRANSACTION; - - ALTER TABLE medias RENAME TO old_medias; - - CREATE TABLE medias ( - id INTEGER NOT NULL, - media_id INTEGER, - post_id INTEGER NOT NULL, - link VARCHAR, - directory VARCHAR, - filename VARCHAR, - size INTEGER, - api_type VARCHAR, - media_type VARCHAR, - preview INTEGER, - linked VARCHAR, - downloaded INTEGER, - created_at TIMESTAMP, - record_created_at TIMESTAMP, - PRIMARY KEY(id), - UNIQUE(media_id, api_type) - ); - - INSERT INTO medias SELECT * FROM old_medias; - - DROP TABLE old_medias; - - COMMIT; - - PRAGMA foreign_keys=on;", connection)) - { - await cmd.ExecuteNonQueryAsync(); - } - - // create the 'messages' table - using (SqliteCommand cmd = new("CREATE TABLE IF NOT EXISTS messages (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, user_id INTEGER, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) - { - await cmd.ExecuteNonQueryAsync(); - } - - // create the 'posts' table - using (SqliteCommand cmd = new("CREATE TABLE IF NOT EXISTS posts (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) - { - await cmd.ExecuteNonQueryAsync(); - } - - // create the 'stories' table - using (SqliteCommand cmd = new("CREATE TABLE IF NOT EXISTS stories (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) - { - await cmd.ExecuteNonQueryAsync(); - } - - // create the 'others' table - using (SqliteCommand cmd = new("CREATE TABLE IF NOT EXISTS others (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) - { - await cmd.ExecuteNonQueryAsync(); - } - - // create the 'products' table - using (SqliteCommand cmd = new("CREATE TABLE IF NOT EXISTS products (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, title VARCHAR, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) - { - await cmd.ExecuteNonQueryAsync(); - } - - // create the 'profiles' table - using (SqliteCommand cmd = new("CREATE TABLE IF NOT EXISTS profiles (id INTEGER NOT NULL, user_id INTEGER NOT NULL, username VARCHAR NOT NULL, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(username));", connection)) - { - await cmd.ExecuteNonQueryAsync(); - } - - connection.Close(); - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - } - - public async Task CreateUsersDB(Dictionary users) - { - try - { - using SqliteConnection connection = new($"Data Source={Directory.GetCurrentDirectory()}/users.db"); - Log.Debug("Database data source: " + connection.DataSource); - - connection.Open(); - - using (SqliteCommand cmd = new("CREATE TABLE IF NOT EXISTS users (id INTEGER NOT NULL, user_id INTEGER NOT NULL, username VARCHAR NOT NULL, PRIMARY KEY(id), UNIQUE(username));", connection)) - { - await cmd.ExecuteNonQueryAsync(); - } - - Log.Debug("Adding missing creators"); - foreach (KeyValuePair user in users) - { - using (SqliteCommand checkCmd = new($"SELECT user_id, username FROM users WHERE user_id = @userId;", connection)) - { - checkCmd.Parameters.AddWithValue("@userId", user.Value); - using (var reader = await checkCmd.ExecuteReaderAsync()) - { - if (!reader.Read()) - { - using (SqliteCommand insertCmd = new($"INSERT INTO users (user_id, username) VALUES (@userId, @username);", connection)) - { - insertCmd.Parameters.AddWithValue("@userId", user.Value); - insertCmd.Parameters.AddWithValue("@username", user.Key); - await insertCmd.ExecuteNonQueryAsync(); - Log.Debug("Inserted new creator: " + user.Key); - } - } - else - { - Log.Debug("Creator " + user.Key + " already exists"); - } - } - } - } - - connection.Close(); - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - } - - public async Task CheckUsername(KeyValuePair user, string path) - { - try - { - using SqliteConnection connection = new($"Data Source={Directory.GetCurrentDirectory()}/users.db"); - - connection.Open(); - - using (SqliteCommand checkCmd = new($"SELECT user_id, username FROM users WHERE user_id = @userId;", connection)) - { - checkCmd.Parameters.AddWithValue("@userId", user.Value); - using (var reader = await checkCmd.ExecuteReaderAsync()) - { - if (reader.Read()) - { - long storedUserId = reader.GetInt64(0); - string storedUsername = reader.GetString(1); - - if (storedUsername != user.Key) - { - using (SqliteCommand updateCmd = new($"UPDATE users SET username = @newUsername WHERE user_id = @userId;", connection)) - { - updateCmd.Parameters.AddWithValue("@newUsername", user.Key); - updateCmd.Parameters.AddWithValue("@userId", user.Value); - await updateCmd.ExecuteNonQueryAsync(); - } - - string oldPath = path.Replace(path.Split("/")[^1], storedUsername); - - if (Directory.Exists(oldPath)) - { - Directory.Move(path.Replace(path.Split("/")[^1], storedUsername), path); - } - } - } - } - } - - connection.Close(); - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - } - - public async Task AddMessage(string folder, long post_id, string message_text, string price, bool is_paid, bool is_archived, DateTime created_at, long user_id) - { - try - { - using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); - connection.Open(); - await EnsureCreatedAtColumnExists(connection, "messages"); - using SqliteCommand cmd = new($"SELECT COUNT(*) FROM messages WHERE post_id=@post_id", connection); - cmd.Parameters.AddWithValue("@post_id", post_id); - int count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - if (count == 0) - { - // If the record doesn't exist, insert a new one - using SqliteCommand insertCmd = new("INSERT INTO messages(post_id, text, price, paid, archived, created_at, user_id, record_created_at) VALUES(@post_id, @message_text, @price, @is_paid, @is_archived, @created_at, @user_id, @record_created_at)", connection); - insertCmd.Parameters.AddWithValue("@post_id", post_id); - insertCmd.Parameters.AddWithValue("@message_text", message_text ?? (object)DBNull.Value); - insertCmd.Parameters.AddWithValue("@price", price ?? (object)DBNull.Value); - insertCmd.Parameters.AddWithValue("@is_paid", is_paid); - insertCmd.Parameters.AddWithValue("@is_archived", is_archived); - insertCmd.Parameters.AddWithValue("@created_at", created_at); - insertCmd.Parameters.AddWithValue("@user_id", user_id); - insertCmd.Parameters.AddWithValue("@record_created_at", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); - await insertCmd.ExecuteNonQueryAsync(); - } - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - } - - - public async Task AddPost(string folder, long post_id, string message_text, string price, bool is_paid, bool is_archived, DateTime created_at) - { - try - { - using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); - connection.Open(); - await EnsureCreatedAtColumnExists(connection, "posts"); - using SqliteCommand cmd = new($"SELECT COUNT(*) FROM posts WHERE post_id=@post_id", connection); - cmd.Parameters.AddWithValue("@post_id", post_id); - int count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - if (count == 0) - { - // If the record doesn't exist, insert a new one - using SqliteCommand insertCmd = new("INSERT INTO posts(post_id, text, price, paid, archived, created_at, record_created_at) VALUES(@post_id, @message_text, @price, @is_paid, @is_archived, @created_at, @record_created_at)", connection); - insertCmd.Parameters.AddWithValue("@post_id", post_id); - insertCmd.Parameters.AddWithValue("@message_text", message_text ?? (object)DBNull.Value); - insertCmd.Parameters.AddWithValue("@price", price ?? (object)DBNull.Value); - insertCmd.Parameters.AddWithValue("@is_paid", is_paid); - insertCmd.Parameters.AddWithValue("@is_archived", is_archived); - insertCmd.Parameters.AddWithValue("@created_at", created_at); - insertCmd.Parameters.AddWithValue("@record_created_at", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); - await insertCmd.ExecuteNonQueryAsync(); - } - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - } - - - public async Task AddStory(string folder, long post_id, string message_text, string price, bool is_paid, bool is_archived, DateTime created_at) - { - try - { - using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); - connection.Open(); - await EnsureCreatedAtColumnExists(connection, "stories"); - using SqliteCommand cmd = new($"SELECT COUNT(*) FROM stories WHERE post_id=@post_id", connection); - cmd.Parameters.AddWithValue("@post_id", post_id); - int count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - if (count == 0) - { - // If the record doesn't exist, insert a new one - using SqliteCommand insertCmd = new("INSERT INTO stories(post_id, text, price, paid, archived, created_at, record_created_at) VALUES(@post_id, @message_text, @price, @is_paid, @is_archived, @created_at, @record_created_at)", connection); - insertCmd.Parameters.AddWithValue("@post_id", post_id); - insertCmd.Parameters.AddWithValue("@message_text", message_text ?? (object)DBNull.Value); - insertCmd.Parameters.AddWithValue("@price", price ?? (object)DBNull.Value); - insertCmd.Parameters.AddWithValue("@is_paid", is_paid); - insertCmd.Parameters.AddWithValue("@is_archived", is_archived); - insertCmd.Parameters.AddWithValue("@created_at", created_at); - insertCmd.Parameters.AddWithValue("@record_created_at", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); - await insertCmd.ExecuteNonQueryAsync(); - } - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - } - - - public async Task AddMedia(string folder, long media_id, long post_id, string link, string? directory, string? filename, long? size, string api_type, string media_type, bool preview, bool downloaded, DateTime? created_at) - { - try - { - using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); - connection.Open(); - await EnsureCreatedAtColumnExists(connection, "medias"); - StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM medias WHERE media_id=@media_id"); - if (downloadConfig.DownloadDuplicatedMedia) - { - sql.Append(" and api_type=@api_type"); - } - - using SqliteCommand cmd = new(sql.ToString(), connection); - cmd.Parameters.AddWithValue("@media_id", media_id); - cmd.Parameters.AddWithValue("@api_type", api_type); - int count = Convert.ToInt32(cmd.ExecuteScalar()); - if (count == 0) - { - // If the record doesn't exist, insert a new one - using SqliteCommand insertCmd = new($"INSERT INTO medias(media_id, post_id, link, directory, filename, size, api_type, media_type, preview, downloaded, created_at, record_created_at) VALUES({media_id}, {post_id}, '{link}', '{directory?.ToString() ?? "NULL"}', '{filename?.ToString() ?? "NULL"}', {size?.ToString() ?? "NULL"}, '{api_type}', '{media_type}', {Convert.ToInt32(preview)}, {Convert.ToInt32(downloaded)}, '{created_at?.ToString("yyyy-MM-dd HH:mm:ss")}', '{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}')", connection); - await insertCmd.ExecuteNonQueryAsync(); - } - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - } - - - public async Task CheckDownloaded(string folder, long media_id, string api_type) - { - try - { - bool downloaded = false; - - using (SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db")) - { - StringBuilder sql = new StringBuilder("SELECT downloaded FROM medias WHERE media_id=@media_id"); - if(downloadConfig.DownloadDuplicatedMedia) - { - sql.Append(" and api_type=@api_type"); - } - - connection.Open(); - using SqliteCommand cmd = new (sql.ToString(), connection); - cmd.Parameters.AddWithValue("@media_id", media_id); - cmd.Parameters.AddWithValue("@api_type", api_type); - downloaded = Convert.ToBoolean(await cmd.ExecuteScalarAsync()); - } - return downloaded; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - - - public async Task UpdateMedia(string folder, long media_id, string api_type, string directory, string filename, long size, bool downloaded, DateTime created_at) - { - using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); - connection.Open(); - - // Construct the update command - StringBuilder sql = new StringBuilder("UPDATE medias SET directory=@directory, filename=@filename, size=@size, downloaded=@downloaded, created_at=@created_at WHERE media_id=@media_id"); - if (downloadConfig.DownloadDuplicatedMedia) - { - sql.Append(" and api_type=@api_type"); - } - - // Create a new command object - using SqliteCommand command = new(sql.ToString(), connection); - // Add parameters to the command object - command.Parameters.AddWithValue("@directory", directory); - command.Parameters.AddWithValue("@filename", filename); - command.Parameters.AddWithValue("@size", size); - command.Parameters.AddWithValue("@downloaded", downloaded ? 1 : 0); - command.Parameters.AddWithValue("@created_at", created_at); - command.Parameters.AddWithValue("@media_id", media_id); - command.Parameters.AddWithValue("@api_type", api_type); - - // Execute the command - await command.ExecuteNonQueryAsync(); - } - - - public async Task GetStoredFileSize(string folder, long media_id, string api_type) - { - long size; - using (SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db")) - { - connection.Open(); - using SqliteCommand cmd = new($"SELECT size FROM medias WHERE media_id=@media_id and api_type=@api_type", connection); - cmd.Parameters.AddWithValue("@media_id", media_id); - cmd.Parameters.AddWithValue("@api_type", api_type); - size = Convert.ToInt64(await cmd.ExecuteScalarAsync()); - } - return size; - } - - public async Task GetMostRecentPostDate(string folder) - { - DateTime? mostRecentDate = null; - using (SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db")) - { - connection.Open(); - using SqliteCommand cmd = new(@" - SELECT - MIN(created_at) AS created_at - FROM ( - SELECT MAX(P.created_at) AS created_at - FROM posts AS P - LEFT OUTER JOIN medias AS m - ON P.post_id = m.post_id - AND m.downloaded = 1 - UNION - SELECT MIN(P.created_at) AS created_at - FROM posts AS P - INNER JOIN medias AS m - ON P.post_id = m.post_id - WHERE m.downloaded = 0 - )", connection); - var scalarValue = await cmd.ExecuteScalarAsync(); - if(scalarValue != null && scalarValue != DBNull.Value) - { - mostRecentDate = Convert.ToDateTime(scalarValue); - } - } - return mostRecentDate; - } - - private async Task EnsureCreatedAtColumnExists(SqliteConnection connection, string tableName) - { - using SqliteCommand cmd = new($"PRAGMA table_info({tableName});", connection); - using var reader = await cmd.ExecuteReaderAsync(); - bool columnExists = false; - - while (await reader.ReadAsync()) - { - if (reader["name"].ToString() == "record_created_at") - { - columnExists = true; - break; - } - } - - if (!columnExists) - { - using SqliteCommand alterCmd = new($"ALTER TABLE {tableName} ADD COLUMN record_created_at TIMESTAMP;", connection); - await alterCmd.ExecuteNonQueryAsync(); - } - } - } -} diff --git a/OF DL/Helpers/DownloadContext.cs b/OF DL/Helpers/DownloadContext.cs deleted file mode 100644 index cc65dcb..0000000 --- a/OF DL/Helpers/DownloadContext.cs +++ /dev/null @@ -1,36 +0,0 @@ -using OF_DL.Entities; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Helpers -{ - internal interface IDownloadContext - { - public IDownloadConfig DownloadConfig { get; } - public IFileNameFormatConfig FileNameFormatConfig { get; } - public APIHelper ApiHelper { get; } - public DBHelper DBHelper { get; } - public DownloadHelper DownloadHelper { get; } - } - - internal class DownloadContext : IDownloadContext - { - public APIHelper ApiHelper { get; } - public DBHelper DBHelper { get; } - public DownloadHelper DownloadHelper { get; } - public IDownloadConfig DownloadConfig { get; } - public IFileNameFormatConfig FileNameFormatConfig { get; } - - public DownloadContext(Auth auth, IDownloadConfig downloadConfig, IFileNameFormatConfig fileNameFormatConfig, APIHelper apiHelper, DBHelper dBHelper) - { - ApiHelper = apiHelper; - DBHelper = dBHelper; - DownloadConfig = downloadConfig; - FileNameFormatConfig = fileNameFormatConfig; - DownloadHelper = new DownloadHelper(auth, downloadConfig, fileNameFormatConfig); - } - } -} diff --git a/OF DL/Helpers/DownloadHelper.cs b/OF DL/Helpers/DownloadHelper.cs deleted file mode 100644 index f5ad8e2..0000000 --- a/OF DL/Helpers/DownloadHelper.cs +++ /dev/null @@ -1,2040 +0,0 @@ -using FFmpeg.NET; -using FFmpeg.NET.Events; -using FFmpeg.NET.Services; -using OF_DL.Entities; -using OF_DL.Entities.Archived; -using OF_DL.Entities.Messages; -using OF_DL.Entities.Post; -using OF_DL.Entities.Purchased; -using OF_DL.Entities.Stories; -using OF_DL.Entities.Streams; -using OF_DL.Enumerations; -using OF_DL.Utils; -using Org.BouncyCastle.Asn1.Tsp; -using Org.BouncyCastle.Asn1.X509; -using Org.BouncyCastle.Tsp; -using Serilog; -using Spectre.Console; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Drawing; -using System.IO; -using System.Linq; -using System.Net; -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using System.Xml.Linq; -using static OF_DL.Entities.Lists.UserList; -using static OF_DL.Entities.Messages.Messages; -using FromUser = OF_DL.Entities.Messages.FromUser; - -namespace OF_DL.Helpers; - -public class DownloadHelper : IDownloadHelper -{ - private readonly Auth auth; - private readonly IDBHelper m_DBHelper; - private readonly IFileNameHelper _FileNameHelper; - private readonly IDownloadConfig downloadConfig; - private readonly IFileNameFormatConfig fileNameFormatConfig; - private TaskCompletionSource _completionSource; - - public DownloadHelper(Auth auth, IDownloadConfig downloadConfig, IFileNameFormatConfig fileNameFormatConfig) - { - this.auth = auth; - this.m_DBHelper = new DBHelper(downloadConfig); - this._FileNameHelper = new FileNameHelper(auth); - this.downloadConfig = downloadConfig; - this.fileNameFormatConfig = fileNameFormatConfig; - } - - #region common - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - protected async Task CreateDirectoriesAndDownloadMedia(string path, - string url, - string folder, - long media_id, - string api_type, - ProgressTask task, - string serverFileName, - string resolvedFileName) - { - try - { - string customFileName = string.Empty; - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - string extension = Path.GetExtension(url.Split("?")[0]); - - path = UpdatePathBasedOnExtension(folder, path, extension); - - return await ProcessMediaDownload(folder, media_id, api_type, url, path, serverFileName, resolvedFileName, extension, task); - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - - - /// - /// Updates the given path based on the file extension. - /// - /// The parent folder. - /// The initial relative path. - /// The file extension. - /// A string that represents the updated path based on the file extension. - private string UpdatePathBasedOnExtension(string folder, string path, string extension) - { - string subdirectory = string.Empty; - - switch (extension.ToLower()) - { - case ".jpg": - case ".jpeg": - case ".png": - subdirectory = "/Images"; - break; - case ".mp4": - case ".avi": - case ".wmv": - case ".gif": - case ".mov": - subdirectory = "/Videos"; - break; - case ".mp3": - case ".wav": - case ".ogg": - subdirectory = "/Audios"; - break; - } - - if (!string.IsNullOrEmpty(subdirectory)) - { - path += subdirectory; - string fullPath = folder + path; - - if (!Directory.Exists(fullPath)) - { - Directory.CreateDirectory(fullPath); - } - } - - return path; - } - - - /// - /// Generates a custom filename based on the given format and properties. - /// - /// The format string for the filename. - /// General information about the post. - /// Media associated with the post. - /// Author of the post. - /// Dictionary containing user-related data. - /// Helper class for filename operations. - /// A Task resulting in a string that represents the custom filename. - private async Task GenerateCustomFileName(string filename, - string? filenameFormat, - object? postInfo, - object? postMedia, - object? author, - string username, - Dictionary users, - IFileNameHelper fileNameHelper, - CustomFileNameOption option) - { - if (string.IsNullOrEmpty(filenameFormat) || postInfo == null || postMedia == null || author == null) - { - return option switch - { - CustomFileNameOption.ReturnOriginal => filename, - CustomFileNameOption.ReturnEmpty => string.Empty, - _ => filename, - }; - } - - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - properties.AddRange(matches.Select(match => match.Groups[1].Value)); - - Dictionary values = await fileNameHelper.GetFilename(postInfo, postMedia, author, properties, username, users); - return await fileNameHelper.BuildFilename(filenameFormat, values); - } - - - private async Task GetFileSizeAsync(string url, Auth auth) - { - long fileSize = 0; - - try - { - Uri uri = new(url); - - if (uri.Host == "cdn3.onlyfans.com" && uri.LocalPath.Contains("/dash/files")) - { - string[] messageUrlParsed = url.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - - mpdURL = mpdURL.Replace(".mpd", "_source.mp4"); - - using HttpClient client = new(); - client.DefaultRequestHeaders.Add("Cookie", $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {auth.COOKIE}"); - client.DefaultRequestHeaders.Add("User-Agent", auth.USER_AGENT); - - using HttpResponseMessage response = await client.GetAsync(mpdURL, HttpCompletionOption.ResponseHeadersRead); - if (response.IsSuccessStatusCode) - { - fileSize = response.Content.Headers.ContentLength ?? 0; - } - } - else - { - using HttpClient client = new(); - client.DefaultRequestHeaders.Add("User-Agent", auth.USER_AGENT); - using HttpResponseMessage response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); - if (response.IsSuccessStatusCode) - { - fileSize = response.Content.Headers.ContentLength ?? 0; - } - } - } - catch (Exception ex) - { - Console.WriteLine($"Error getting file size for URL '{url}': {ex.Message}"); - } - - return fileSize; - } - - public static async Task GetDRMVideoLastModified(string url, Auth auth) - { - Uri uri = new(url); - - string[] messageUrlParsed = url.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - - mpdURL = mpdURL.Replace(".mpd", "_source.mp4"); - - using HttpClient client = new(); - client.DefaultRequestHeaders.Add("Cookie", $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {auth.COOKIE}"); - client.DefaultRequestHeaders.Add("User-Agent", auth.USER_AGENT); - - using HttpResponseMessage response = await client.GetAsync(mpdURL, HttpCompletionOption.ResponseHeadersRead); - if (response.IsSuccessStatusCode) - { - return response.Content.Headers.LastModified.Value.DateTime; - } - return DateTime.Now; - } - public static async Task GetMediaLastModified(string url) - { - using HttpClient client = new(); - - using HttpResponseMessage response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); - if (response.IsSuccessStatusCode) - { - return response.Content.Headers.LastModified.Value.DateTime; - } - return DateTime.Now; - } - - /// - /// Processes the download and database update of media. - /// - /// The folder where the media is stored. - /// The ID of the media. - /// The full path to the media. - /// The URL from where to download the media. - /// The relative path to the media. - /// The filename after any required manipulations. - /// The file extension. - /// The task object for tracking progress. - /// A Task resulting in a boolean indicating whether the media is newly downloaded or not. - public async Task ProcessMediaDownload(string folder, - long media_id, - string api_type, - string url, - string path, - string serverFilename, - string resolvedFilename, - string extension, - ProgressTask task) - { - - try - { - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - return await HandleNewMedia(folder: folder, - media_id: media_id, - api_type: api_type, - url: url, - path: path, - serverFilename: serverFilename, - resolvedFilename: resolvedFilename, - extension: extension, - task: task); - } - else - { - bool status = await HandlePreviouslyDownloadedMediaAsync(folder, media_id, api_type, task); - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (serverFilename != resolvedFilename)) - { - await HandleRenamingOfExistingFilesAsync(folder, media_id, api_type, path, serverFilename, resolvedFilename, extension); - } - return status; - } - } - catch (Exception ex) - { - // Handle exception (e.g., log it) - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - } - - - private async Task HandleRenamingOfExistingFilesAsync(string folder, - long media_id, - string api_type, - string path, - string serverFilename, - string resolvedFilename, - string extension) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{serverFilename}{extension}"; - string fullPathWithTheNewFileName = $"{folder}{path}/{resolvedFilename}{extension}"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - var lastModified = File.GetLastWriteTime(fullPathWithTheNewFileName); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, resolvedFilename + extension, size, true, lastModified); - return true; - } - - - /// - /// Handles new media by downloading and updating the database. - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// A Task resulting in a boolean indicating whether the media is newly downloaded or not. - private async Task HandleNewMedia(string folder, - long media_id, - string api_type, - string url, - string path, - string serverFilename, - string resolvedFilename, - string extension, - ProgressTask task) - { - long fileSizeInBytes; - DateTime lastModified; - bool status; - - string fullPathWithTheServerFileName = $"{folder}{path}/{serverFilename}{extension}"; - string fullPathWithTheNewFileName = $"{folder}{path}/{resolvedFilename}{extension}"; - - //there are a few possibilities here. - //1.file has been downloaded in the past but it has the server filename - // in that case it should be set as existing and it should be renamed - //2.file has been downloaded in the past but it has custom filename. - // it should be set as existing and nothing else. - // of coures 1 and 2 depends in the fact that there may be a difference in the resolved file name - // (ie user has selected a custom format. If he doesn't then the resolved name will be the same as the server filename - //3.file doesn't exist and it should be downloaded. - - // Handle the case where the file has been downloaded in the past with the server filename - //but it has downloaded outsite of this application so it doesn't exist in the database - if (File.Exists(fullPathWithTheServerFileName)) - { - string finalPath; - if (fullPathWithTheServerFileName != fullPathWithTheNewFileName) - { - finalPath = fullPathWithTheNewFileName; - //rename. - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - } - } - else - { - finalPath = fullPathWithTheServerFileName; - } - - fileSizeInBytes = GetLocalFileSize(finalPath); - lastModified = File.GetLastWriteTime(finalPath); - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - status = false; - } - // Handle the case where the file has been downloaded in the past with a custom filename. - //but it has downloaded outsite of this application so it doesn't exist in the database - // this is a bit improbable but we should check for that. - else if (File.Exists(fullPathWithTheNewFileName)) - { - fileSizeInBytes = GetLocalFileSize(fullPathWithTheNewFileName); - lastModified = File.GetLastWriteTime(fullPathWithTheNewFileName); - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - status = false; - } - else //file doesn't exist and we should download it. - { - lastModified = await DownloadFile(url, fullPathWithTheNewFileName, task); - fileSizeInBytes = GetLocalFileSize(fullPathWithTheNewFileName); - status = true; - } - - //finaly check which filename we should use. Custom or the server one. - //if a custom is used, then the servefilename will be different from the resolved filename. - string finalName = serverFilename == resolvedFilename ? serverFilename : resolvedFilename; - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, finalName + extension, fileSizeInBytes, true, lastModified); - return status; - } - - - /// - /// Handles media that has been previously downloaded and updates the task accordingly. - /// - /// - /// - /// - /// - /// A boolean indicating whether the media is newly downloaded or not. - private async Task HandlePreviouslyDownloadedMediaAsync(string folder, long media_id, string api_type, ProgressTask task) - { - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - return false; - } - - - /// - /// Gets the file size of the media. - /// - /// The path to the file. - /// The file size in bytes. - private long GetLocalFileSize(string filePath) - { - return new FileInfo(filePath).Length; - } - - - /// - /// Downloads a file from the given URL and saves it to the specified destination path. - /// - /// The URL to download the file from. - /// The path where the downloaded file will be saved. - /// Progress tracking object. - /// A Task resulting in a DateTime indicating the last modified date of the downloaded file. - - private async Task DownloadFile(string url, string destinationPath, ProgressTask task) - { - using var client = new HttpClient(); - var request = new HttpRequestMessage - { - Method = HttpMethod.Get, - RequestUri = new Uri(url) - }; - - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStreamAsync(); - - // Wrap the body stream with the ThrottledStream to limit read rate. - using (ThrottledStream throttledStream = new(body, downloadConfig.DownloadLimitInMbPerSec * 1_000_000, downloadConfig.LimitDownloadRate)) - { - using FileStream fileStream = new(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 16384, true); - var buffer = new byte[16384]; - int read; - while ((read = await throttledStream.ReadAsync(buffer, CancellationToken.None)) > 0) - { - if (downloadConfig.ShowScrapeSize) - { - task.Increment(read); - } - await fileStream.WriteAsync(buffer.AsMemory(0, read), CancellationToken.None); - } - } - - File.SetLastWriteTime(destinationPath, response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now); - if (!downloadConfig.ShowScrapeSize) - { - task.Increment(1); - } - return response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now; - } - - public async Task CalculateTotalFileSize(List urls) - { - long totalFileSize = 0; - if (urls.Count > 250) - { - int batchSize = 250; - - var tasks = new List>(); - - for (int i = 0; i < urls.Count; i += batchSize) - { - var batchUrls = urls.Skip(i).Take(batchSize).ToList(); - - var batchTasks = batchUrls.Select(url => GetFileSizeAsync(url, auth)); - tasks.AddRange(batchTasks); - - await Task.WhenAll(batchTasks); - - await Task.Delay(5000); - } - - long[] fileSizes = await Task.WhenAll(tasks); - foreach (long fileSize in fileSizes) - { - totalFileSize += fileSize; - } - } - else - { - var tasks = new List>(); - - foreach (string url in urls) - { - tasks.Add(GetFileSizeAsync(url, auth)); - } - - long[] fileSizes = await Task.WhenAll(tasks); - foreach (long fileSize in fileSizes) - { - totalFileSize += fileSize; - } - } - - return totalFileSize; - } - #endregion - - #region drm common - - private async Task DownloadDrmMedia(string user_agent, string policy, string signature, string kvp, string sess, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string customFileName, string filename, string path) - { - try - { - _completionSource = new TaskCompletionSource(); - - int pos1 = decryptionKey.IndexOf(':'); - string decKey = ""; - if (pos1 >= 0) - { - decKey = decryptionKey.Substring(pos1 + 1); - } - - int streamIndex = 0; - string tempFilename = $"{folder}{path}/{filename}_source.mp4"; - - //int? streamIndex = await GetVideoStreamIndexFromMpd(url, policy, signature, kvp, downloadConfig.DownloadVideoResolution); - - //if (streamIndex == null) - // throw new Exception($"Could not find video stream for resolution {downloadConfig.DownloadVideoResolution}"); - - //string tempFilename; - - //switch (downloadConfig.DownloadVideoResolution) - //{ - // case VideoResolution.source: - // tempFilename = $"{folder}{path}/{filename}_source.mp4"; - // break; - // case VideoResolution._240: - // tempFilename = $"{folder}{path}/{filename}_240.mp4"; - // break; - // case VideoResolution._720: - // tempFilename = $"{folder}{path}/{filename}_720.mp4"; - // break; - // default: - // tempFilename = $"{folder}{path}/{filename}_source.mp4"; - // break; - //} - - // Configure ffmpeg log level and optional report file location - bool ffmpegDebugLogging = Log.IsEnabled(Serilog.Events.LogEventLevel.Debug); - - string logLevelArgs = ffmpegDebugLogging || downloadConfig.LoggingLevel is LoggingLevel.Verbose or LoggingLevel.Debug - ? "-loglevel debug -report" - : downloadConfig.LoggingLevel switch - { - LoggingLevel.Information => "-loglevel info", - LoggingLevel.Warning => "-loglevel warning", - LoggingLevel.Error => "-loglevel error", - LoggingLevel.Fatal => "-loglevel fatal", - _ => string.Empty - }; - - if (logLevelArgs.Contains("-report", StringComparison.OrdinalIgnoreCase)) - { - // Direct ffmpeg report files into the same logs directory Serilog uses (relative to current working directory) - string logDir = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs")); - Directory.CreateDirectory(logDir); - string ffReportPath = Path.Combine(logDir, "ffmpeg-%p-%t.log"); // ffmpeg will replace %p/%t - Environment.SetEnvironmentVariable("FFREPORT", $"file={ffReportPath}:level=32"); - Log.Debug("FFREPORT enabled at: {FFREPORT} (cwd: {Cwd})", Environment.GetEnvironmentVariable("FFREPORT"), Environment.CurrentDirectory); - } - else - { - Environment.SetEnvironmentVariable("FFREPORT", null); - Log.Debug("FFREPORT disabled (cwd: {Cwd})", Environment.CurrentDirectory); - } - - string cookieHeader = - "Cookie: " + - $"CloudFront-Policy={policy}; " + - $"CloudFront-Signature={signature}; " + - $"CloudFront-Key-Pair-Id={kvp}; " + - $"{sess}"; - - string parameters = - $"{logLevelArgs} " + - $"-cenc_decryption_key {decKey} " + - $"-headers \"{cookieHeader}\" " + - $"-user_agent \"{user_agent}\" " + - "-referer \"https://onlyfans.com\" " + - "-rw_timeout 20000000 " + - "-reconnect 1 -reconnect_streamed 1 -reconnect_on_network_error 1 -reconnect_delay_max 10 " + - "-y " + - $"-i \"{url}\" " + - $"-map 0:v:{streamIndex} -map 0:a? " + - "-c copy " + - $"\"{tempFilename}\""; - - Log.Debug($"Calling FFMPEG with Parameters: {parameters}"); - - Engine ffmpeg = new Engine(downloadConfig.FFmpegPath); - ffmpeg.Error += OnError; - ffmpeg.Complete += async (sender, args) => - { - _completionSource.TrySetResult(true); - await OnFFMPEGDownloadComplete(tempFilename, lastModified, folder, path, customFileName, filename, media_id, api_type, task); - }; - await ffmpeg.ExecuteAsync(parameters, CancellationToken.None); - - return await _completionSource.Task; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - #endregion - - #region normal posts - public async Task DownloadPostMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, Post.List? postInfo, Post.Medium? postMedia, Post.Author? author, Dictionary users) - { - string path; - if (downloadConfig.FolderPerPost && postInfo != null && postInfo?.id is not null && postInfo?.postedAt is not null) - { - path = $"/Posts/Free/{postInfo.id} {postInfo.postedAt:yyyy-MM-dd HH-mm-ss}"; - } - else - { - path = "/Posts/Free"; - } - - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, postInfo, postMedia, author, folder.Split("/")[^1], users, _FileNameHelper, CustomFileNameOption.ReturnOriginal); - - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, resolvedFilename); - } - public async Task DownloadPostMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, SinglePost? postInfo, SinglePost.Medium? postMedia, SinglePost.Author? author, Dictionary users) - { - string path; - if (downloadConfig.FolderPerPost && postInfo != null && postInfo?.id is not null && postInfo?.postedAt is not null) - { - path = $"/Posts/Free/{postInfo.id} {postInfo.postedAt:yyyy-MM-dd HH-mm-ss}"; - } - else - { - path = "/Posts/Free"; - } - - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, postInfo, postMedia, author, folder.Split("/")[^1], users, _FileNameHelper, CustomFileNameOption.ReturnOriginal); - - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, resolvedFilename); - } - public async Task DownloadStreamMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, Streams.List? streamInfo, Streams.Medium? streamMedia, Streams.Author? author, Dictionary users) - { - string path; - if (downloadConfig.FolderPerPost && streamInfo != null && streamInfo?.id is not null && streamInfo?.postedAt is not null) - { - path = $"/Posts/Free/{streamInfo.id} {streamInfo.postedAt:yyyy-MM-dd HH-mm-ss}"; - } - else - { - path = "/Posts/Free"; - } - - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, streamInfo, streamMedia, author, folder.Split("/")[^1], users, _FileNameHelper, CustomFileNameOption.ReturnOriginal); - - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, resolvedFilename); - } - - - public async Task DownloadMessageMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, Messages.List? messageInfo, Messages.Medium? messageMedia, Messages.FromUser? fromUser, Dictionary users) - { - string path; - if (downloadConfig.FolderPerMessage && messageInfo != null && messageInfo?.id is not null && messageInfo?.createdAt is not null) - { - path = $"/Messages/Free/{messageInfo.id} {messageInfo.createdAt.Value:yyyy-MM-dd HH-mm-ss}"; - } - else - { - path = "/Messages/Free"; - } - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, messageInfo, messageMedia, fromUser, folder.Split("/")[^1], users, _FileNameHelper, CustomFileNameOption.ReturnOriginal); - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, resolvedFilename); - } - - public async Task DownloadMessagePreviewMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, SingleMessage? messageInfo, Messages.Medium? messageMedia, FromUser? fromUser, Dictionary users) - { - string path; - if (downloadConfig.FolderPerMessage && messageInfo != null && messageInfo?.id is not null && messageInfo?.createdAt is not null) - { - path = $"/Messages/Free/{messageInfo.id} {messageInfo.createdAt.Value:yyyy-MM-dd HH-mm-ss}"; - } - else - { - path = "/Messages/Free"; - } - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, messageInfo, messageMedia, fromUser, folder.Split("/")[^1], users, _FileNameHelper, CustomFileNameOption.ReturnOriginal); - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, resolvedFilename); - } - - - public async Task DownloadArchivedMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, Archived.List? messageInfo, Archived.Medium? messageMedia, Archived.Author? author, Dictionary users) - { - string path = "/Archived/Posts/Free"; - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, messageInfo, messageMedia, author, folder.Split("/")[^1], users, _FileNameHelper, CustomFileNameOption.ReturnOriginal); - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, resolvedFilename); - } - - - - public async Task DownloadStoryMedia(string url, string folder, long media_id, string api_type, ProgressTask task) - { - string path = "/Stories/Free"; - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, filename); - } - - public async Task DownloadPurchasedMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, Purchased.List? messageInfo, Medium? messageMedia, Purchased.FromUser? fromUser, Dictionary users) - { - string path; - if (downloadConfig.FolderPerPaidMessage && messageInfo != null && messageInfo?.id is not null && messageInfo?.createdAt is not null) - { - path = $"/Messages/Paid/{messageInfo.id} {messageInfo.createdAt.Value:yyyy-MM-dd HH-mm-ss}"; - } - else - { - path = "/Messages/Paid"; - } - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, messageInfo, messageMedia, fromUser, folder.Split("/")[^1], users, _FileNameHelper, CustomFileNameOption.ReturnOriginal); - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, resolvedFilename); - } - - public async Task DownloadSinglePurchasedMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, SingleMessage? messageInfo, Medium? messageMedia, Entities.Messages.FromUser? fromUser, Dictionary users) - { - string path; - if (downloadConfig.FolderPerPaidMessage && messageInfo != null && messageInfo?.id is not null && messageInfo?.createdAt is not null) - { - path = $"/Messages/Paid/{messageInfo.id} {messageInfo.createdAt.Value:yyyy-MM-dd HH-mm-ss}"; - } - else - { - path = "/Messages/Paid"; - } - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, messageInfo, messageMedia, fromUser, folder.Split("/")[^1], users, _FileNameHelper, CustomFileNameOption.ReturnOriginal); - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, resolvedFilename); - } - - public async Task DownloadPurchasedPostMedia(string url, - string folder, - long media_id, - string api_type, - ProgressTask task, - string? filenameFormat, - Purchased.List? messageInfo, - Medium? messageMedia, - Purchased.FromUser? fromUser, - Dictionary users) - { - string path; - if (downloadConfig.FolderPerPaidPost && messageInfo != null && messageInfo?.id is not null && messageInfo?.postedAt is not null) - { - path = $"/Posts/Paid/{messageInfo.id} {messageInfo.postedAt.Value:yyyy-MM-dd HH-mm-ss}"; - } - else - { - path = "/Posts/Paid"; - } - Uri uri = new(url); - string filename = System.IO.Path.GetFileNameWithoutExtension(uri.LocalPath); - string resolvedFilename = await GenerateCustomFileName(filename, filenameFormat, messageInfo, messageMedia, fromUser, folder.Split("/")[^1], users, _FileNameHelper, CustomFileNameOption.ReturnOriginal); - return await CreateDirectoriesAndDownloadMedia(path, url, folder, media_id, api_type, task, filename, resolvedFilename); - } - - #endregion - public async Task DownloadAvatarHeader(string? avatarUrl, string? headerUrl, string folder, string username) - { - try - { - string path = $"/Profile"; - - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - if (!string.IsNullOrEmpty(avatarUrl)) - { - string avatarpath = $"{path}/Avatars"; - if (!Directory.Exists(folder + avatarpath)) - { - Directory.CreateDirectory(folder + avatarpath); - } - - List avatarMD5Hashes = WidevineClient.Utils.CalculateFolderMD5(folder + avatarpath); - - Uri uri = new(avatarUrl); - string destinationPath = $"{folder}{avatarpath}/"; - - var client = new HttpClient(); - - var request = new HttpRequestMessage - { - Method = HttpMethod.Get, - RequestUri = uri - - }; - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); - - using var memoryStream = new MemoryStream(); - await response.Content.CopyToAsync(memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); - - MD5 md5 = MD5.Create(); - byte[] hash = md5.ComputeHash(memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); - if (!avatarMD5Hashes.Contains(BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant())) - { - destinationPath = destinationPath + string.Format("{0} {1}.jpg", username, response.Content.Headers.LastModified.HasValue ? response.Content.Headers.LastModified.Value.LocalDateTime.ToString("dd-MM-yyyy") : DateTime.Now.ToString("dd-MM-yyyy")); - - using (FileStream fileStream = File.Create(destinationPath)) - { - await memoryStream.CopyToAsync(fileStream); - } - File.SetLastWriteTime(destinationPath, response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now); - } - } - - if (!string.IsNullOrEmpty(headerUrl)) - { - string headerpath = $"{path}/Headers"; - if (!Directory.Exists(folder + headerpath)) - { - Directory.CreateDirectory(folder + headerpath); - } - - List headerMD5Hashes = WidevineClient.Utils.CalculateFolderMD5(folder + headerpath); - - Uri uri = new(headerUrl); - string destinationPath = $"{folder}{headerpath}/"; - - var client = new HttpClient(); - - var request = new HttpRequestMessage - { - Method = HttpMethod.Get, - RequestUri = uri - - }; - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); - - using var memoryStream = new MemoryStream(); - await response.Content.CopyToAsync(memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); - - MD5 md5 = MD5.Create(); - byte[] hash = md5.ComputeHash(memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); - if (!headerMD5Hashes.Contains(BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant())) - { - destinationPath = destinationPath + string.Format("{0} {1}.jpg", username, response.Content.Headers.LastModified.HasValue ? response.Content.Headers.LastModified.Value.LocalDateTime.ToString("dd-MM-yyyy") : DateTime.Now.ToString("dd-MM-yyyy")); - - using (FileStream fileStream = File.Create(destinationPath)) - { - await memoryStream.CopyToAsync(fileStream); - } - File.SetLastWriteTime(destinationPath, response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now); - } - } - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - } - - private async Task OnFFMPEGDownloadComplete(string tempFilename, DateTime lastModified, string folder, string path, string customFileName, string filename, long media_id, string api_type, ProgressTask task) - { - try - { - if (File.Exists(tempFilename)) - { - File.SetLastWriteTime(tempFilename, lastModified); - } - if (!string.IsNullOrEmpty(customFileName)) - { - File.Move(tempFilename, $"{folder + path + "/" + customFileName + ".mp4"}"); - } - - // Cleanup Files - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : tempFilename).Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + ".mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - } - - private void OnError(object sender, ConversionErrorEventArgs e) - { - // Guard all fields to avoid NullReference exceptions from FFmpeg.NET - var input = e?.Input?.Name ?? ""; - var output = e?.Output?.Name ?? ""; - var exitCode = e?.Exception?.ExitCode.ToString() ?? ""; - var message = e?.Exception?.Message ?? ""; - var inner = e?.Exception?.InnerException?.Message ?? ""; - - Log.Error("FFmpeg failed. Input={Input} Output={Output} ExitCode={ExitCode} Message={Message} Inner={Inner}", - input, output, exitCode, message, inner); - - _completionSource?.TrySetResult(false); - } - - - #region drm posts - public async Task DownloadMessageDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string? filenameFormat, Messages.List? messageInfo, Messages.Medium? messageMedia, Messages.FromUser? fromUser, Dictionary users) - { - try - { - string customFileName = string.Empty; - string path; - Uri uri = new(url); - string filename = System.IO.Path.GetFileName(uri.LocalPath).Split(".")[0]; - if (downloadConfig.FolderPerMessage && messageInfo != null && messageInfo?.id is not null && messageInfo?.createdAt is not null) - { - path = $"/Messages/Free/{messageInfo.id} {messageInfo.createdAt.Value:yyyy-MM-dd HH-mm-ss}/Videos"; - } - else - { - path = "/Messages/Free/Videos"; - } - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - - if (!string.IsNullOrEmpty(filenameFormat) && messageInfo != null && messageMedia != null) - { - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - foreach (Match match in matches) - { - properties.Add(match.Groups[1].Value); - } - Dictionary values = await _FileNameHelper.GetFilename(messageInfo, messageMedia, fromUser, properties, folder.Split("/")[^1],users); - customFileName = await _FileNameHelper.BuildFilename(filenameFormat, values); - } - - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - if (!string.IsNullOrEmpty(customFileName) ? !File.Exists(folder + path + "/" + customFileName + ".mp4") : !File.Exists(folder + path + "/" + filename + "_source.mp4")) - { - return await DownloadDrmMedia(auth.USER_AGENT, policy, signature, kvp, auth.COOKIE, url, decryptionKey, folder, lastModified, media_id, api_type, task, customFileName, filename, path); - } - else - { - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : folder + path + "/" + filename + "_source.mp4").Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + "mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - } - else - { - if (!string.IsNullOrEmpty(customFileName)) - { - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (filename + "_source" != customFileName)) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; - string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, customFileName + ".mp4", size, true, lastModified); - } - } - - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - } - return false; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - - public async Task DownloadSingleMessagePreviewDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string? filenameFormat, SingleMessage? messageInfo, Messages.Medium? messageMedia, FromUser? fromUser, Dictionary users) - { - try - { - string customFileName = string.Empty; - string path; - Uri uri = new(url); - string filename = System.IO.Path.GetFileName(uri.LocalPath).Split(".")[0]; - if (downloadConfig.FolderPerMessage && messageInfo != null && messageInfo?.id is not null && messageInfo?.createdAt is not null) - { - path = $"/Messages/Free/{messageInfo.id} {messageInfo.createdAt.Value:yyyy-MM-dd HH-mm-ss}/Videos"; - } - else - { - path = "/Messages/Free/Videos"; - } - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - - if (!string.IsNullOrEmpty(filenameFormat) && messageInfo != null && messageMedia != null) - { - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - foreach (Match match in matches) - { - properties.Add(match.Groups[1].Value); - } - Dictionary values = await _FileNameHelper.GetFilename(messageInfo, messageMedia, fromUser, properties, folder.Split("/")[^1],users); - customFileName = await _FileNameHelper.BuildFilename(filenameFormat, values); - } - - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - if (!string.IsNullOrEmpty(customFileName) ? !File.Exists(folder + path + "/" + customFileName + ".mp4") : !File.Exists(folder + path + "/" + filename + "_source.mp4")) - { - return await DownloadDrmMedia(auth.USER_AGENT, policy, signature, kvp, auth.COOKIE, url, decryptionKey, folder, lastModified, media_id, api_type, task, customFileName, filename, path); - } - else - { - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : folder + path + "/" + filename + "_source.mp4").Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + "mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - } - else - { - if (!string.IsNullOrEmpty(customFileName)) - { - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (filename + "_source" != customFileName)) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; - string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, customFileName + ".mp4", size, true, lastModified); - } - } - - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - } - return false; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - - - public async Task DownloadPurchasedMessageDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string? filenameFormat, Purchased.List? messageInfo, Medium? messageMedia, Purchased.FromUser? fromUser, Dictionary users) - { - try - { - string customFileName = string.Empty; - string path; - Uri uri = new(url); - string filename = System.IO.Path.GetFileName(uri.LocalPath).Split(".")[0]; - if (downloadConfig.FolderPerPaidMessage && messageInfo != null && messageInfo?.id is not null && messageInfo?.createdAt is not null) - { - path = $"/Messages/Paid/{messageInfo.id} {messageInfo.createdAt.Value:yyyy-MM-dd HH-mm-ss}/Videos"; - } - else - { - path = "/Messages/Paid/Videos"; - } - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - if (!string.IsNullOrEmpty(filenameFormat) && messageInfo != null && messageMedia != null) - { - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - foreach (Match match in matches) - { - properties.Add(match.Groups[1].Value); - } - Dictionary values = await _FileNameHelper.GetFilename(messageInfo, messageMedia, fromUser, properties, folder.Split("/")[^1], users); - customFileName = await _FileNameHelper.BuildFilename(filenameFormat, values); - } - - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - if (!string.IsNullOrEmpty(customFileName) ? !File.Exists(folder + path + "/" + customFileName + ".mp4") : !File.Exists(folder + path + "/" + filename + "_source.mp4")) - { - return await DownloadDrmMedia(auth.USER_AGENT, policy, signature, kvp, auth.COOKIE, url, decryptionKey, folder, lastModified, media_id, api_type, task, customFileName, filename, path); - } - else - { - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : folder + path + "/" + filename + "_source.mp4").Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + "mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - } - else - { - if (!string.IsNullOrEmpty(customFileName)) - { - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (filename + "_source" != customFileName)) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; - string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, customFileName + ".mp4", size, true, lastModified); - } - } - - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - } - return false; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - - public async Task DownloadSinglePurchasedMessageDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string? filenameFormat, SingleMessage? messageInfo, Medium? messageMedia, Entities.Messages.FromUser? fromUser, Dictionary users) - { - try - { - string customFileName = string.Empty; - string path; - Uri uri = new(url); - string filename = System.IO.Path.GetFileName(uri.LocalPath).Split(".")[0]; - if (downloadConfig.FolderPerPaidMessage && messageInfo != null && messageInfo?.id is not null && messageInfo?.createdAt is not null) - { - path = $"/Messages/Paid/{messageInfo.id} {messageInfo.createdAt.Value:yyyy-MM-dd HH-mm-ss}/Videos"; - } - else - { - path = "/Messages/Paid/Videos"; - } - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - if (!string.IsNullOrEmpty(filenameFormat) && messageInfo != null && messageMedia != null) - { - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - foreach (Match match in matches) - { - properties.Add(match.Groups[1].Value); - } - Dictionary values = await _FileNameHelper.GetFilename(messageInfo, messageMedia, fromUser, properties, folder.Split("/")[^1], users); - customFileName = await _FileNameHelper.BuildFilename(filenameFormat, values); - } - - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - if (!string.IsNullOrEmpty(customFileName) ? !File.Exists(folder + path + "/" + customFileName + ".mp4") : !File.Exists(folder + path + "/" + filename + "_source.mp4")) - { - return await DownloadDrmMedia(auth.USER_AGENT, policy, signature, kvp, auth.COOKIE, url, decryptionKey, folder, lastModified, media_id, api_type, task, customFileName, filename, path); - } - else - { - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : folder + path + "/" + filename + "_source.mp4").Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + "mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - } - else - { - if (!string.IsNullOrEmpty(customFileName)) - { - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (filename + "_source" != customFileName)) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; - string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, customFileName + ".mp4", size, true, lastModified); - } - } - - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - } - return false; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - - - public async Task DownloadPostDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string? filenameFormat, Post.List? postInfo, Post.Medium? postMedia, Post.Author? author, Dictionary users) - { - try - { - string customFileName = string.Empty; - string path; - Uri uri = new(url); - string filename = System.IO.Path.GetFileName(uri.LocalPath).Split(".")[0]; - if (downloadConfig.FolderPerPost && postInfo != null && postInfo?.id is not null && postInfo?.postedAt is not null) - { - path = $"/Posts/Free/{postInfo.id} {postInfo.postedAt:yyyy-MM-dd HH-mm-ss}/Videos"; - } - else - { - path = "/Posts/Free/Videos"; - } - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - if (!string.IsNullOrEmpty(filenameFormat) && postInfo != null && postMedia != null) - { - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - foreach (Match match in matches) - { - properties.Add(match.Groups[1].Value); - } - Dictionary values = await _FileNameHelper.GetFilename(postInfo, postMedia, author, properties, folder.Split("/")[^1], users); - customFileName = await _FileNameHelper.BuildFilename(filenameFormat, values); - } - - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - if (!string.IsNullOrEmpty(customFileName) ? !File.Exists(folder + path + "/" + customFileName + ".mp4") : !File.Exists(folder + path + "/" + filename + "_source.mp4")) - { - return await DownloadDrmMedia(auth.USER_AGENT, policy, signature, kvp, auth.COOKIE, url, decryptionKey, folder, lastModified, media_id, api_type, task, customFileName, filename, path); - } - else - { - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : folder + path + "/" + filename + "_source.mp4").Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + "mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - } - else - { - if (!string.IsNullOrEmpty(customFileName)) - { - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (filename + "_source" != customFileName)) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; - string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, customFileName + ".mp4", size, true, lastModified); - } - } - - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - } - return false; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - public async Task DownloadPostDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string filenameFormat, SinglePost postInfo, SinglePost.Medium postMedia, SinglePost.Author author, Dictionary users) - { - try - { - string customFileName = string.Empty; - string path; - Uri uri = new(url); - string filename = System.IO.Path.GetFileName(uri.LocalPath).Split(".")[0]; - if (downloadConfig.FolderPerPost && postInfo != null && postInfo?.id is not null && postInfo?.postedAt is not null) - { - path = $"/Posts/Free/{postInfo.id} {postInfo.postedAt:yyyy-MM-dd HH-mm-ss}/Videos"; - } - else - { - path = "/Posts/Free/Videos"; - } - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - if (!string.IsNullOrEmpty(filenameFormat) && postInfo != null && postMedia != null) - { - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - foreach (Match match in matches) - { - properties.Add(match.Groups[1].Value); - } - Dictionary values = await _FileNameHelper.GetFilename(postInfo, postMedia, author, properties, folder.Split("/")[^1], users); - customFileName = await _FileNameHelper.BuildFilename(filenameFormat, values); - } - - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - if (!string.IsNullOrEmpty(customFileName) ? !File.Exists(folder + path + "/" + customFileName + ".mp4") : !File.Exists(folder + path + "/" + filename + "_source.mp4")) - { - return await DownloadDrmMedia(auth.USER_AGENT, policy, signature, kvp, auth.COOKIE, url, decryptionKey, folder, lastModified, media_id, api_type, task, customFileName, filename, path); - } - else - { - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : folder + path + "/" + filename + "_source.mp4").Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + "mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - } - else - { - if (!string.IsNullOrEmpty(customFileName)) - { - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (filename + "_source" != customFileName)) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; - string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, customFileName + ".mp4", size, true, lastModified); - } - } - - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - } - return false; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - public async Task DownloadStreamsDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string? filenameFormat, Streams.List? streamInfo, Streams.Medium? streamMedia, Streams.Author? author, Dictionary users) - { - try - { - string customFileName = string.Empty; - string path; - Uri uri = new(url); - string filename = System.IO.Path.GetFileName(uri.LocalPath).Split(".")[0]; - if (downloadConfig.FolderPerPost && streamInfo != null && streamInfo?.id is not null && streamInfo?.postedAt is not null) - { - path = $"/Posts/Free/{streamInfo.id} {streamInfo.postedAt:yyyy-MM-dd HH-mm-ss}/Videos"; - } - else - { - path = "/Posts/Free/Videos"; - } - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - if (!string.IsNullOrEmpty(filenameFormat) && streamInfo != null && streamMedia != null) - { - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - foreach (Match match in matches) - { - properties.Add(match.Groups[1].Value); - } - Dictionary values = await _FileNameHelper.GetFilename(streamInfo, streamMedia, author, properties, folder.Split("/")[^1], users); - customFileName = await _FileNameHelper.BuildFilename(filenameFormat, values); - } - - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - if (!string.IsNullOrEmpty(customFileName) ? !File.Exists(folder + path + "/" + customFileName + ".mp4") : !File.Exists(folder + path + "/" + filename + "_source.mp4")) - { - return await DownloadDrmMedia(auth.USER_AGENT, policy, signature, kvp, auth.COOKIE, url, decryptionKey, folder, lastModified, media_id, api_type, task, customFileName, filename, path); - } - else - { - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : folder + path + "/" + filename + "_source.mp4").Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + "mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - } - else - { - if (!string.IsNullOrEmpty(customFileName)) - { - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (filename + "_source" != customFileName)) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; - string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, customFileName + ".mp4", size, true, lastModified); - } - } - - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - } - return false; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - - public async Task DownloadPurchasedPostDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string? filenameFormat, Purchased.List? postInfo, Medium? postMedia, Purchased.FromUser? fromUser, Dictionary users) - { - try - { - string customFileName = string.Empty; - string path; - Uri uri = new(url); - string filename = System.IO.Path.GetFileName(uri.LocalPath).Split(".")[0]; - if (downloadConfig.FolderPerPaidPost && postInfo != null && postInfo?.id is not null && postInfo?.postedAt is not null) - { - path = $"/Posts/Paid/{postInfo.id} {postInfo.postedAt.Value:yyyy-MM-dd HH-mm-ss}/Videos"; - } - else - { - path = "/Posts/Paid/Videos"; - } - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - - if (!string.IsNullOrEmpty(filenameFormat) && postInfo != null && postMedia != null) - { - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - foreach (Match match in matches) - { - properties.Add(match.Groups[1].Value); - } - Dictionary values = await _FileNameHelper.GetFilename(postInfo, postMedia, fromUser, properties, folder.Split("/")[^1], users); - customFileName = await _FileNameHelper.BuildFilename(filenameFormat, values); - } - - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - if (!string.IsNullOrEmpty(customFileName) ? !File.Exists(folder + path + "/" + customFileName + ".mp4") : !File.Exists(folder + path + "/" + filename + "_source.mp4")) - { - return await DownloadDrmMedia(auth.USER_AGENT, policy, signature, kvp, auth.COOKIE, url, decryptionKey, folder, lastModified, media_id, api_type, task, customFileName, filename, path); - } - else - { - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : folder + path + "/" + filename + "_source.mp4").Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + "mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - } - else - { - if (!string.IsNullOrEmpty(customFileName)) - { - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (filename + "_source" != customFileName)) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; - string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, customFileName + ".mp4", size, true, lastModified); - } - } - - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - } - return false; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - - - public async Task DownloadArchivedPostDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string? filenameFormat, Archived.List? postInfo, Archived.Medium? postMedia, Archived.Author? author, Dictionary users) - { - try - { - string customFileName = string.Empty; - Uri uri = new(url); - string filename = System.IO.Path.GetFileName(uri.LocalPath).Split(".")[0]; - string path = "/Archived/Posts/Free/Videos"; - if (!Directory.Exists(folder + path)) - { - Directory.CreateDirectory(folder + path); - } - - if (!string.IsNullOrEmpty(filenameFormat) && postInfo != null && postMedia != null) - { - List properties = new(); - string pattern = @"\{(.*?)\}"; - MatchCollection matches = Regex.Matches(filenameFormat, pattern); - foreach (Match match in matches) - { - properties.Add(match.Groups[1].Value); - } - Dictionary values = await _FileNameHelper.GetFilename(postInfo, postMedia, author, properties, folder.Split("/")[^1], users); - customFileName = await _FileNameHelper.BuildFilename(filenameFormat, values); - } - - if (!await m_DBHelper.CheckDownloaded(folder, media_id, api_type)) - { - if (!string.IsNullOrEmpty(customFileName) ? !File.Exists(folder + path + "/" + customFileName + ".mp4") : !File.Exists(folder + path + "/" + filename + "_source.mp4")) - { - return await DownloadDrmMedia(auth.USER_AGENT, policy, signature, kvp, auth.COOKIE, url, decryptionKey, folder, lastModified, media_id, api_type, task, customFileName, filename, path); - } - else - { - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) ? folder + path + "/" + customFileName + ".mp4" : folder + path + "/" + filename + "_source.mp4").Length; - if (downloadConfig.ShowScrapeSize) - { - task.Increment(fileSizeInBytes); - } - else - { - task.Increment(1); - } - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, !string.IsNullOrEmpty(customFileName) ? customFileName + "mp4" : filename + "_source.mp4", fileSizeInBytes, true, lastModified); - } - } - else - { - if (!string.IsNullOrEmpty(customFileName)) - { - if (downloadConfig.RenameExistingFilesWhenCustomFormatIsSelected && (filename + "_source" != customFileName)) - { - string fullPathWithTheServerFileName = $"{folder}{path}/{filename}_source.mp4"; - string fullPathWithTheNewFileName = $"{folder}{path}/{customFileName}.mp4"; - if (!File.Exists(fullPathWithTheServerFileName)) - { - return false; - } - try - { - File.Move(fullPathWithTheServerFileName, fullPathWithTheNewFileName); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - return false; - } - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - await m_DBHelper.UpdateMedia(folder, media_id, api_type, folder + path, customFileName + ".mp4", size, true, lastModified); - } - } - - if (downloadConfig.ShowScrapeSize) - { - long size = await m_DBHelper.GetStoredFileSize(folder, media_id, api_type); - task.Increment(size); - } - else - { - task.Increment(1); - } - } - return false; - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - } - return false; - } - #endregion - - private async Task GetVideoStreamIndexFromMpd(string mpdUrl, string policy, string signature, string kvp, VideoResolution resolution) - { - HttpClient client = new(); - HttpRequestMessage request = new(HttpMethod.Get, mpdUrl); - request.Headers.Add("user-agent", auth.USER_AGENT); - request.Headers.Add("Accept", "*/*"); - request.Headers.Add("Cookie", $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {auth.COOKIE};"); - using (var response = await client.SendAsync(request)) - { - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStringAsync(); - XDocument doc = XDocument.Parse(body); - XNamespace ns = "urn:mpeg:dash:schema:mpd:2011"; - XNamespace cenc = "urn:mpeg:cenc:2013"; - var videoAdaptationSet = doc - .Descendants(ns + "AdaptationSet") - .FirstOrDefault(e => (string)e.Attribute("mimeType") == "video/mp4"); - - if (videoAdaptationSet == null) - return null; - - string targetHeight = resolution switch - { - VideoResolution._240 => "240", - VideoResolution._720 => "720", - VideoResolution.source => "1280", - _ => throw new ArgumentOutOfRangeException(nameof(resolution)) - }; - - var representations = videoAdaptationSet.Elements(ns + "Representation").ToList(); - - for (int i = 0; i < representations.Count; i++) - { - if ((string)representations[i].Attribute("height") == targetHeight) - return i; // this is the index FFmpeg will use for `-map 0:v:{i}` - } - } - - return null; - } -} diff --git a/OF DL/Helpers/FileNameHelper.cs b/OF DL/Helpers/FileNameHelper.cs deleted file mode 100644 index 178a4e8..0000000 --- a/OF DL/Helpers/FileNameHelper.cs +++ /dev/null @@ -1,188 +0,0 @@ -using HtmlAgilityPack; -using OF_DL.Entities; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; - -namespace OF_DL.Helpers -{ - public class FileNameHelper : IFileNameHelper - { - private readonly Auth auth; - - public FileNameHelper(Auth auth) - { - this.auth = auth; - } - - public async Task> GetFilename(object obj1, object obj2, object obj3, List selectedProperties, string username, Dictionary users = null) - { - Dictionary values = new(); - Type type1 = obj1.GetType(); - Type type2 = obj2.GetType(); - PropertyInfo[] properties1 = type1.GetProperties(); - PropertyInfo[] properties2 = type2.GetProperties(); - - foreach (string propertyName in selectedProperties) - { - if (propertyName.Contains("media")) - { - object drmProperty = null; - object fileProperty = GetNestedPropertyValue(obj2, "files"); - if(fileProperty != null) - { - drmProperty = GetNestedPropertyValue(obj2, "files.drm"); - } - - if(fileProperty != null && drmProperty != null && propertyName == "mediaCreatedAt") - { - object mpdurl = GetNestedPropertyValue(obj2, "files.drm.manifest.dash"); - object policy = GetNestedPropertyValue(obj2, "files.drm.signature.dash.CloudFrontPolicy"); - object signature = GetNestedPropertyValue(obj2, "files.drm.signature.dash.CloudFrontSignature"); - object kvp = GetNestedPropertyValue(obj2, "files.drm.signature.dash.CloudFrontKeyPairId"); - DateTime lastModified = await DownloadHelper.GetDRMVideoLastModified(string.Join(",", mpdurl, policy, signature, kvp), auth); - values.Add(propertyName, lastModified.ToString("yyyy-MM-dd")); - continue; - } - else if((fileProperty == null || drmProperty == null) && propertyName == "mediaCreatedAt") - { - object source = GetNestedPropertyValue(obj2, "files.full.url"); - if(source != null) - { - DateTime lastModified = await DownloadHelper.GetMediaLastModified(source.ToString()); - values.Add(propertyName, lastModified.ToString("yyyy-MM-dd")); - continue; - } - else - { - object preview = GetNestedPropertyValue(obj2, "preview"); - if(preview != null) - { - DateTime lastModified = await DownloadHelper.GetMediaLastModified(preview.ToString()); - values.Add(propertyName, lastModified.ToString("yyyy-MM-dd")); - continue; - } - } - - } - PropertyInfo? property = Array.Find(properties2, p => p.Name.Equals(propertyName.Replace("media", ""), StringComparison.OrdinalIgnoreCase)); - if (property != null) - { - object? propertyValue = property.GetValue(obj2); - if (propertyValue != null) - { - if (propertyValue is DateTime dateTimeValue) - { - values.Add(propertyName, dateTimeValue.ToString("yyyy-MM-dd")); - } - else - { - values.Add(propertyName, propertyValue.ToString()); - } - } - } - } - else if (propertyName.Contains("filename")) - { - string sourcePropertyPath = "files.full.url"; - object sourcePropertyValue = GetNestedPropertyValue(obj2, sourcePropertyPath); - if (sourcePropertyValue != null) - { - Uri uri = new(sourcePropertyValue.ToString()); - string filename = System.IO.Path.GetFileName(uri.LocalPath); - values.Add(propertyName, filename.Split(".")[0]); - } - else - { - string propertyPath = "files.drm.manifest.dash"; - object nestedPropertyValue = GetNestedPropertyValue(obj2, propertyPath); - if (nestedPropertyValue != null) - { - Uri uri = new(nestedPropertyValue.ToString()); - string filename = System.IO.Path.GetFileName(uri.LocalPath); - values.Add(propertyName, filename.Split(".")[0] + "_source"); - } - } - } - else if (propertyName.Contains("username")) - { - if(!string.IsNullOrEmpty(username)) - { - values.Add(propertyName, username); - } - else - { - string propertyPath = "id"; - object nestedPropertyValue = GetNestedPropertyValue(obj3, propertyPath); - if (nestedPropertyValue != null) - { - values.Add(propertyName, users.FirstOrDefault(u => u.Value == Convert.ToInt32(nestedPropertyValue.ToString())).Key); - } - } - } - else if (propertyName.Contains("text", StringComparison.OrdinalIgnoreCase)) - { - PropertyInfo property = Array.Find(properties1, p => p.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase)); - if (property != null) - { - object propertyValue = property.GetValue(obj1); - if (propertyValue != null) - { - var pageDoc = new HtmlDocument(); - pageDoc.LoadHtml(propertyValue.ToString()); - var str = pageDoc.DocumentNode.InnerText; - if (str.Length > 100) // todo: add length limit to config - str = str.Substring(0, 100); - values.Add(propertyName, str); - } - } - } - else - { - PropertyInfo property = Array.Find(properties1, p => p.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase)); - if (property != null) - { - object propertyValue = property.GetValue(obj1); - if (propertyValue != null) - { - if (propertyValue is DateTime dateTimeValue) - { - values.Add(propertyName, dateTimeValue.ToString("yyyy-MM-dd")); - } - else - { - values.Add(propertyName, propertyValue.ToString()); - } - } - } - } - } - return values; - } - - static object GetNestedPropertyValue(object source, string propertyPath) - { - object value = source; - foreach (var propertyName in propertyPath.Split('.')) - { - PropertyInfo property = value.GetType().GetProperty(propertyName) ?? throw new ArgumentException($"Property '{propertyName}' not found."); - value = property.GetValue(value); - } - return value; - } - - public async Task BuildFilename(string fileFormat, Dictionary values) - { - foreach (var kvp in values) - { - string placeholder = "{" + kvp.Key + "}"; - fileFormat = fileFormat.Replace(placeholder, kvp.Value); - } - - return WidevineClient.Utils.RemoveInvalidFileNameChars($"{fileFormat}"); - } - } -} diff --git a/OF DL/Helpers/Interfaces/IAPIHelper.cs b/OF DL/Helpers/Interfaces/IAPIHelper.cs deleted file mode 100644 index d94d509..0000000 --- a/OF DL/Helpers/Interfaces/IAPIHelper.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Newtonsoft.Json.Linq; -using OF_DL.Entities; -using OF_DL.Entities.Archived; -using OF_DL.Entities.Messages; -using OF_DL.Entities.Post; -using OF_DL.Entities.Purchased; -using OF_DL.Entities.Streams; -using OF_DL.Enumurations; -using Spectre.Console; - -namespace OF_DL.Helpers -{ - public interface IAPIHelper - { - Task GetDecryptionKeyCDRMProject(Dictionary drmHeaders, string licenceURL, string pssh); - Task GetDecryptionKeyCDM(Dictionary drmHeaders, string licenceURL, string pssh); - Task GetDRMMPDLastModified(string mpdUrl, string policy, string signature, string kvp); - Task GetDRMMPDPSSH(string mpdUrl, string policy, string signature, string kvp); - Task> GetLists(string endpoint, IDownloadConfig config); - Task> GetListUsers(string endpoint, IDownloadConfig config); - Task> GetMedia(MediaType mediatype, string endpoint, string? username, string folder, IDownloadConfig config, List paid_post_ids); - Task GetPaidPosts(string endpoint, string folder, string username, IDownloadConfig config, List paid_post_ids, StatusContext ctx); - Task GetPosts(string endpoint, string folder, IDownloadConfig config, List paid_post_ids, StatusContext ctx); - Task GetPost(string endpoint, string folder, IDownloadConfig config); - Task GetStreams(string endpoint, string folder, IDownloadConfig config, List paid_post_ids, StatusContext ctx); - Task GetArchived(string endpoint, string folder, IDownloadConfig config, StatusContext ctx); - Task GetMessages(string endpoint, string folder, IDownloadConfig config, StatusContext ctx); - Task GetPaidMessages(string endpoint, string folder, string username, IDownloadConfig config, StatusContext ctx); - Task> GetPurchasedTabUsers(string endpoint, IDownloadConfig config, Dictionary users); - Task> GetPurchasedTab(string endpoint, string folder, IDownloadConfig config, Dictionary users); - Task GetUserInfo(string endpoint); - Task GetUserInfoById(string endpoint); - Dictionary GetDynamicHeaders(string path, string queryParam); - Task> GetActiveSubscriptions(string endpoint, bool includeRestrictedSubscriptions, IDownloadConfig config); - Task> GetExpiredSubscriptions(string endpoint, bool includeRestrictedSubscriptions, IDownloadConfig config); - Task GetDecryptionKeyOFDL(Dictionary drmHeaders, string licenceURL, string pssh); - } -} diff --git a/OF DL/Helpers/Interfaces/IDBHelper.cs b/OF DL/Helpers/Interfaces/IDBHelper.cs deleted file mode 100644 index ab55249..0000000 --- a/OF DL/Helpers/Interfaces/IDBHelper.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace OF_DL.Helpers -{ - public interface IDBHelper - { - Task AddMessage(string folder, long post_id, string message_text, string price, bool is_paid, bool is_archived, DateTime created_at, long user_id); - Task AddPost(string folder, long post_id, string message_text, string price, bool is_paid, bool is_archived, DateTime created_at); - Task AddStory(string folder, long post_id, string message_text, string price, bool is_paid, bool is_archived, DateTime created_at); - Task CreateDB(string folder); - Task CreateUsersDB(Dictionary users); - Task CheckUsername(KeyValuePair user, string path); - Task AddMedia(string folder, long media_id, long post_id, string link, string? directory, string? filename, long? size, string api_type, string media_type, bool preview, bool downloaded, DateTime? created_at); - Task UpdateMedia(string folder, long media_id, string api_type, string directory, string filename, long size, bool downloaded, DateTime created_at); - Task GetStoredFileSize(string folder, long media_id, string api_type); - Task CheckDownloaded(string folder, long media_id, string api_type); - Task GetMostRecentPostDate(string folder); - } -} diff --git a/OF DL/Helpers/Interfaces/IDownloadHelper.cs b/OF DL/Helpers/Interfaces/IDownloadHelper.cs deleted file mode 100644 index 62672ae..0000000 --- a/OF DL/Helpers/Interfaces/IDownloadHelper.cs +++ /dev/null @@ -1,45 +0,0 @@ -using OF_DL.Entities; -using OF_DL.Entities.Archived; -using OF_DL.Entities.Messages; -using OF_DL.Entities.Post; -using OF_DL.Entities.Purchased; -using OF_DL.Entities.Streams; -using Spectre.Console; -using static OF_DL.Entities.Messages.Messages; -using FromUser = OF_DL.Entities.Messages.FromUser; - -namespace OF_DL.Helpers -{ - public interface IDownloadHelper - { - Task CalculateTotalFileSize(List urls); - Task DownloadArchivedMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string filenameFormat, Archived.List messageInfo, Archived.Medium messageMedia, Archived.Author author, Dictionary users); - Task DownloadArchivedPostDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string filenameFormat, Archived.List postInfo, Archived.Medium postMedia, Archived.Author author, Dictionary users); - Task DownloadPostDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string filenameFormat, SinglePost postInfo, SinglePost.Medium postMedia, SinglePost.Author author, Dictionary users); - Task DownloadAvatarHeader(string? avatarUrl, string? headerUrl, string folder, string username); - Task DownloadMessageDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string filenameFormat, Messages.List messageInfo, Messages.Medium messageMedia, Messages.FromUser fromUser, Dictionary users); - Task DownloadMessageMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string filenameFormat, Messages.List messageInfo, Messages.Medium messageMedia, Messages.FromUser fromUser, Dictionary users); - Task DownloadPostDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string filenameFormat, Post.List postInfo, Post.Medium postMedia, Post.Author author, Dictionary users); - Task DownloadPostMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, Post.List? postInfo, Post.Medium? postMedia, Post.Author? author, Dictionary users); - Task DownloadPostMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, SinglePost? postInfo, SinglePost.Medium? postMedia, SinglePost.Author? author, Dictionary users); - Task DownloadPurchasedMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string filenameFormat, Purchased.List messageInfo, Medium messageMedia, Purchased.FromUser fromUser, Dictionary users); - - Task DownloadSinglePurchasedMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, SingleMessage? messageInfo, Medium? messageMedia, Entities.Messages.FromUser? fromUser, Dictionary users); - Task DownloadPurchasedMessageDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string filenameFormat, Purchased.List messageInfo, Medium messageMedia, Purchased.FromUser fromUser, Dictionary users); - - Task DownloadSinglePurchasedMessageDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string? filenameFormat, SingleMessage? messageInfo, Medium? messageMedia, Entities.Messages.FromUser? fromUser, Dictionary users); - Task DownloadPurchasedPostDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string filenameFormat, Purchased.List postInfo, Medium postMedia, Purchased.FromUser fromUser, Dictionary users); - Task DownloadPurchasedPostMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string filenameFormat, Purchased.List messageInfo, Medium messageMedia, Purchased.FromUser fromUser, Dictionary users); - Task DownloadStoryMedia(string url, string folder, long media_id, string api_type, ProgressTask task); - Task DownloadStreamMedia(string url, string folder, long media_id, string api_type, ProgressTask task, string? filenameFormat, Streams.List? streamInfo, Streams.Medium? streamMedia, Streams.Author? author, Dictionary users); - Task DownloadStreamsDRMVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, ProgressTask task, string filenameFormat, Streams.List streamInfo, Streams.Medium streamMedia, Streams.Author author, Dictionary users); - Task DownloadSingleMessagePreviewDRMVideo(string policy, string signature, string kvp, string url, - string decryptionKey, string folder, DateTime lastModified, long media_id, string api_type, - ProgressTask task, string? filenameFormat, SingleMessage? messageInfo, Medium? messageMedia, - FromUser? fromUser, Dictionary users); - - Task DownloadMessagePreviewMedia(string url, string folder, long media_id, string api_type, - ProgressTask task, string? filenameFormat, SingleMessage? messageInfo, Medium? messageMedia, - FromUser? fromUser, Dictionary users); - } -} diff --git a/OF DL/Helpers/Interfaces/IFileNameHelper.cs b/OF DL/Helpers/Interfaces/IFileNameHelper.cs deleted file mode 100644 index a3cc701..0000000 --- a/OF DL/Helpers/Interfaces/IFileNameHelper.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OF_DL.Helpers -{ - public interface IFileNameHelper - { - Task BuildFilename(string fileFormat, Dictionary values); - Task> GetFilename(object obj1, object obj2, object obj3, List selectedProperties, string username, Dictionary users = null); - } -} diff --git a/OF DL/HttpUtil.cs b/OF DL/HttpUtil.cs deleted file mode 100644 index 25f0ebe..0000000 --- a/OF DL/HttpUtil.cs +++ /dev/null @@ -1,149 +0,0 @@ -using OF_DL.Helpers; -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; - -namespace WidevineClient -{ - class HttpUtil - { - public static HttpClient Client { get; set; } = new HttpClient(new HttpClientHandler - { - AllowAutoRedirect = true, - //Proxy = null - }); - - public static async Task PostData(string URL, Dictionary headers, string postData) - { - var mediaType = postData.StartsWith("{") ? "application/json" : "application/x-www-form-urlencoded"; - var response = await PerformOperation(async () => - { - StringContent content = new StringContent(postData, Encoding.UTF8, mediaType); - //ByteArrayContent content = new ByteArrayContent(postData); - - return await Post(URL, headers, content); - }); - - byte[] bytes = await response.Content.ReadAsByteArrayAsync(); - return bytes; - } - - public static async Task PostData(string URL, Dictionary headers, byte[] postData) - { - var response = await PerformOperation(async () => - { - ByteArrayContent content = new ByteArrayContent(postData); - - return await Post(URL, headers, content); - }); - - byte[] bytes = await response.Content.ReadAsByteArrayAsync(); - return bytes; - } - - public static async Task PostData(string URL, Dictionary headers, Dictionary postData) - { - var response = await PerformOperation(async () => - { - FormUrlEncodedContent content = new FormUrlEncodedContent(postData); - - return await Post(URL, headers, content); - }); - - byte[] bytes = await response.Content.ReadAsByteArrayAsync(); - return bytes; - } - - public static async Task GetWebSource(string URL, Dictionary headers = null) - { - var response = await PerformOperation(async () => - { - return await Get(URL, headers); - }); - - byte[] bytes = await response.Content.ReadAsByteArrayAsync(); - return Encoding.UTF8.GetString(bytes); - } - - public static async Task GetBinary(string URL, Dictionary headers = null) - { - var response = await PerformOperation(async () => - { - return await Get(URL, headers); - }); - - byte[] bytes = await response.Content.ReadAsByteArrayAsync(); - return bytes; - } - public static string GetString(byte[] bytes) - { - return Encoding.UTF8.GetString(bytes); - } - - private static async Task Get(string URL, Dictionary headers = null) - { - HttpRequestMessage request = new HttpRequestMessage() - { - RequestUri = new Uri(URL), - Method = HttpMethod.Get - }; - - if (headers != null) - foreach (KeyValuePair header in headers) - request.Headers.TryAddWithoutValidation(header.Key, header.Value); - - return await Send(request); - } - - private static async Task Post(string URL, Dictionary headers, HttpContent content) - { - HttpRequestMessage request = new HttpRequestMessage() - { - RequestUri = new Uri(URL), - Method = HttpMethod.Post, - Content = content - }; - - if (headers != null) - foreach (KeyValuePair header in headers) - request.Headers.TryAddWithoutValidation(header.Key, header.Value); - - return await Send(request); - } - - private static async Task Send(HttpRequestMessage request) - { - return await Client.SendAsync(request); - } - - private static async Task PerformOperation(Func> operation) - { - var response = await operation(); - - var retryCount = 0; - - while (retryCount < Constants.WIDEVINE_MAX_RETRIES && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) - { - // - // We've hit a rate limit, so we should wait before retrying. - // - var retryAfterSeconds = Constants.WIDEVINE_RETRY_DELAY * (retryCount + 1); // Default retry time. Increases with each retry. - if (response.Headers.RetryAfter != null && response.Headers.RetryAfter.Delta.HasValue) - { - if (response.Headers.RetryAfter.Delta.Value.TotalSeconds > 0) - retryAfterSeconds = (int)response.Headers.RetryAfter.Delta.Value.TotalSeconds + 1; // Add 1 second to ensure we wait a bit longer than the suggested time - } - - await Task.Delay(retryAfterSeconds * 1000); // Peform the delay - - response = await operation(); - retryCount++; - } - - response.EnsureSuccessStatusCode(); // Throw an exception if the response is not successful - - return response; - } - } -} diff --git a/OF DL/OF DL.csproj b/OF DL/OF DL.csproj index 6e3d347..8285c32 100644 --- a/OF DL/OF DL.csproj +++ b/OF DL/OF DL.csproj @@ -1,8 +1,8 @@ - + Exe - net8.0 + net10.0 OF_DL enable enable @@ -10,22 +10,26 @@ - + - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/OF DL/Program.cs b/OF DL/Program.cs index e87c411..f43d269 100644 --- a/OF DL/Program.cs +++ b/OF DL/Program.cs @@ -1,879 +1,201 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using OF_DL.Entities; -using OF_DL.Entities.Archived; -using OF_DL.Entities.Messages; -using OF_DL.Entities.Post; -using OF_DL.Entities.Purchased; -using OF_DL.Entities.Streams; -using OF_DL.Enumerations; -using OF_DL.Enumurations; -using OF_DL.Helpers; -using Serilog; -using Serilog.Core; -using Serilog.Events; -using Spectre.Console; -using System.IO; -using System.Reflection; -using System.Runtime.InteropServices; using System.Text.RegularExpressions; -using static OF_DL.Entities.Messages.Messages; -using Akka.Configuration; -using System.Text; -using static Akka.Actor.ProviderSelection; +using Microsoft.Extensions.DependencyInjection; +using OF_DL.CLI; +using OF_DL.Models; +using OF_DL.Enumerations; +using OF_DL.Models.Config; +using OF_DL.Models.Downloads; +using OF_DL.Models.Entities.Users; +using OF_DL.Services; +using Serilog; +using Spectre.Console; namespace OF_DL; -public class Program +public class Program(IServiceProvider serviceProvider) { - public int MAX_AGE = 0; - public static List paid_post_ids = new(); + private async Task LoadAuthFromBrowser() + { + IAuthService authService = serviceProvider.GetRequiredService(); + bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null; - private static bool clientIdBlobMissing = false; - private static bool devicePrivateKeyMissing = false; - private static Entities.Config? config = null; - private static Auth? auth = null; - private static LoggingLevelSwitch levelSwitch = new LoggingLevelSwitch(); + // Show the initial message + AnsiConsole.MarkupLine("[yellow]Downloading dependencies. Please wait ...[/]"); - private static async Task LoadAuthFromBrowser() - { - bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null; + // Show instructions based on the environment + await Task.Delay(5000); + if (runningInDocker) + { + AnsiConsole.MarkupLine( + "[yellow]In your web browser, navigate to the port forwarded from your docker container.[/]"); + AnsiConsole.MarkupLine( + "[yellow]For instance, if your docker run command included \"-p 8080:8080\", open your web browser to \"http://localhost:8080\".[/]"); + AnsiConsole.MarkupLine( + "[yellow]Once on that webpage, please use it to log in to your OF account. Do not navigate away from the page.[/]"); + } + else + { + AnsiConsole.MarkupLine( + "[yellow]In the new window that has opened, please log in to your OF account. Do not close the window or tab. Do not navigate away from the page.[/]\n"); + AnsiConsole.MarkupLine( + "[yellow]Note: Some users have reported that \"Sign in with Google\" has not been working with the new authentication method.[/]"); + AnsiConsole.MarkupLine( + "[yellow]If you use this method or encounter other issues while logging in, use one of the legacy authentication methods documented here:[/]"); + AnsiConsole.MarkupLine("[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); + } - try - { - AuthHelper authHelper = new(); - Task setupBrowserTask = authHelper.SetupBrowser(runningInDocker); + // Load auth from browser using the service + bool success = await authService.LoadFromBrowserAsync(); - Task.Delay(1000).Wait(); - if (!setupBrowserTask.IsCompleted) - { - AnsiConsole.MarkupLine($"[yellow]Downloading dependencies. Please wait ...[/]"); - } - setupBrowserTask.Wait(); + if (!success || authService.CurrentAuth == null) + { + AnsiConsole.MarkupLine( + "\n[red]Authentication failed. Be sure to log into to OF using the new window that opened automatically.[/]"); + AnsiConsole.MarkupLine( + "[red]The window will close automatically when the authentication process is finished.[/]"); + AnsiConsole.MarkupLine( + "[red]If the problem persists, you may want to try using a legacy authentication method documented here:[/]\n"); + AnsiConsole.MarkupLine("[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); + AnsiConsole.MarkupLine("[red]Press any key to exit.[/]"); + Log.Error("auth invalid after attempt to get auth from browser"); - Task getAuthTask = authHelper.GetAuthFromBrowser(); - Task.Delay(5000).Wait(); - if (!getAuthTask.IsCompleted) - { - if (runningInDocker) - { - AnsiConsole.MarkupLine( - "[yellow]In your web browser, navigate to the port forwarded from your docker container.[/]"); - AnsiConsole.MarkupLine( - "[yellow]For instance, if your docker run command included \"-p 8080:8080\", open your web browser to \"http://localhost:8080\".[/]"); - AnsiConsole.MarkupLine("[yellow]Once on that webpage, please use it to log in to your OF account. Do not navigate away from the page.[/]"); - } - else - { - AnsiConsole.MarkupLine($"[yellow]In the new window that has opened, please log in to your OF account. Do not close the window or tab. Do not navigate away from the page.[/]\n"); - AnsiConsole.MarkupLine($"[yellow]Note: Some users have reported that \"Sign in with Google\" has not been working with the new authentication method.[/]"); - AnsiConsole.MarkupLine($"[yellow]If you use this method or encounter other issues while logging in, use one of the legacy authentication methods documented here:[/]"); - AnsiConsole.MarkupLine($"[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); - } - } - auth = await getAuthTask; - } - catch (Exception e) - { - AnsiConsole.MarkupLine($"\n[red]Authentication failed. Be sure to log into to OF using the new window that opened automatically.[/]"); - AnsiConsole.MarkupLine($"[red]The window will close automatically when the authentication process is finished.[/]"); - AnsiConsole.MarkupLine($"[red]If the problem persists, you may want to try using a legacy authentication method documented here:[/]\n"); - AnsiConsole.MarkupLine($"[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); - AnsiConsole.MarkupLine($"[red]Press any key to exit.[/]"); - Log.Error(e, "auth invalid after attempt to get auth from browser"); + Environment.Exit(2); + } - Environment.Exit(2); - } + await authService.SaveToFileAsync(); + } - if (auth == null) - { - AnsiConsole.MarkupLine($"\n[red]Authentication failed. Be sure to log into to OF using the new window that opened automatically.[/]"); - AnsiConsole.MarkupLine($"[red]The window will close automatically when the authentication process is finished.[/]"); - AnsiConsole.MarkupLine($"[red]If the problem persists, you may want to try using a legacy authentication method documented here:[/]\n"); - AnsiConsole.MarkupLine($"[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); - AnsiConsole.MarkupLine($"[red]Press any key to exit.[/]"); - Log.Error("auth invalid after attempt to get auth from browser"); + public static async Task Main(string[] args) + { + AnsiConsole.Write(new FigletText("Welcome to OF-DL").Color(Color.Red)); + AnsiConsole.Markup("Documentation: [link]https://docs.ofdl.tools/[/]\n"); + AnsiConsole.Markup("Discord server: [link]https://discord.com/invite/6bUW8EJ53j[/]\n\n"); - Environment.Exit(2); - } - else - { - await File.WriteAllTextAsync("auth.json", JsonConvert.SerializeObject(auth, Formatting.Indented)); - } - } + ServiceCollection services = await ConfigureServices(args); + ServiceProvider serviceProvider = services.BuildServiceProvider(); - public static async Task Main(string[] args) - { - bool cliNonInteractive = false; + // Get the Program instance and run + Program program = serviceProvider.GetRequiredService(); + await program.RunAsync(); + } - try - { - levelSwitch.MinimumLevel = LogEventLevel.Error; //set initial level (until we've read from config) + private static async Task ConfigureServices(string[] args) + { + // Set up dependency injection with LoggingService and ConfigService + ServiceCollection services = new(); + services.AddSingleton(); + services.AddSingleton(); + ServiceProvider tempServiceProvider = services.BuildServiceProvider(); - Log.Logger = new LoggerConfiguration() - .MinimumLevel.ControlledBy(levelSwitch) - .WriteTo.File("logs/OFDL.txt", rollingInterval: RollingInterval.Day) - .CreateLogger(); + ILoggingService loggingService = tempServiceProvider.GetRequiredService(); + IConfigService configService = tempServiceProvider.GetRequiredService(); - AnsiConsole.Write(new FigletText("Welcome to OF-DL").Color(Color.Red)); - AnsiConsole.Markup("Documentation: [link]https://docs.ofdl.tools/[/]\n"); - AnsiConsole.Markup("Discord server: [link]https://discord.com/invite/6bUW8EJ53j[/]\n\n"); - //Remove config.json and convert to config.conf - if (File.Exists("config.json")) + if (!await configService.LoadConfigurationAsync(args)) + { + AnsiConsole.MarkupLine("\n[red]config.conf is not valid, check your syntax![/]\n"); + if (!configService.IsCliNonInteractive) { - AnsiConsole.Markup("[green]config.json located successfully!\n[/]"); - try - { - string jsonText = File.ReadAllText("config.json"); - var jsonConfig = JsonConvert.DeserializeObject(jsonText); - - if (jsonConfig != null) - { - var hoconConfig = new StringBuilder(); - hoconConfig.AppendLine("# Auth"); - hoconConfig.AppendLine("Auth {"); - hoconConfig.AppendLine($" DisableBrowserAuth = {jsonConfig.DisableBrowserAuth.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - hoconConfig.AppendLine("# External Tools"); - hoconConfig.AppendLine("External {"); - hoconConfig.AppendLine($" FFmpegPath = \"{jsonConfig.FFmpegPath}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Download Settings"); - hoconConfig.AppendLine("Download {"); - hoconConfig.AppendLine(" Media {"); - hoconConfig.AppendLine($" DownloadAvatarHeaderPhoto = {jsonConfig.DownloadAvatarHeaderPhoto.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPaidPosts = {jsonConfig.DownloadPaidPosts.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPosts = {jsonConfig.DownloadPosts.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadArchived = {jsonConfig.DownloadArchived.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadStreams = {jsonConfig.DownloadStreams.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadStories = {jsonConfig.DownloadStories.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadHighlights = {jsonConfig.DownloadHighlights.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadMessages = {jsonConfig.DownloadMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPaidMessages = {jsonConfig.DownloadPaidMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadImages = {jsonConfig.DownloadImages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadVideos = {jsonConfig.DownloadVideos.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadAudios = {jsonConfig.DownloadAudios.ToString().ToLower()}"); - hoconConfig.AppendLine(" }"); - hoconConfig.AppendLine($" IgnoreOwnMessages = {jsonConfig.IgnoreOwnMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPostsIncrementally = {jsonConfig.DownloadPostsIncrementally.ToString().ToLower()}"); - hoconConfig.AppendLine($" BypassContentForCreatorsWhoNoLongerExist = {jsonConfig.BypassContentForCreatorsWhoNoLongerExist.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadDuplicatedMedia = {jsonConfig.DownloadDuplicatedMedia.ToString().ToLower()}"); - hoconConfig.AppendLine($" SkipAds = {jsonConfig.SkipAds.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPath = \"{jsonConfig.DownloadPath}\""); - hoconConfig.AppendLine($" DownloadOnlySpecificDates = {jsonConfig.DownloadOnlySpecificDates.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadDateSelection = \"{jsonConfig.DownloadDateSelection.ToString().ToLower()}\""); - hoconConfig.AppendLine($" CustomDate = \"{jsonConfig.CustomDate?.ToString("yyyy-MM-dd")}\""); - hoconConfig.AppendLine($" ShowScrapeSize = {jsonConfig.ShowScrapeSize.ToString().ToLower()}"); - hoconConfig.AppendLine($" DisableTextSanitization = false"); - hoconConfig.AppendLine($" DownloadVideoResolution = \"{(jsonConfig.DownloadVideoResolution == VideoResolution.source ? "source" : jsonConfig.DownloadVideoResolution.ToString().TrimStart('_'))}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# File Settings"); - hoconConfig.AppendLine("File {"); - hoconConfig.AppendLine($" PaidPostFileNameFormat = \"{jsonConfig.PaidPostFileNameFormat}\""); - hoconConfig.AppendLine($" PostFileNameFormat = \"{jsonConfig.PostFileNameFormat}\""); - hoconConfig.AppendLine($" PaidMessageFileNameFormat = \"{jsonConfig.PaidMessageFileNameFormat}\""); - hoconConfig.AppendLine($" MessageFileNameFormat = \"{jsonConfig.MessageFileNameFormat}\""); - hoconConfig.AppendLine($" RenameExistingFilesWhenCustomFormatIsSelected = {jsonConfig.RenameExistingFilesWhenCustomFormatIsSelected.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Creator-Specific Configurations"); - hoconConfig.AppendLine("CreatorConfigs {"); - foreach (var creatorConfig in jsonConfig.CreatorConfigs) - { - hoconConfig.AppendLine($" \"{creatorConfig.Key}\" {{"); - hoconConfig.AppendLine($" PaidPostFileNameFormat = \"{creatorConfig.Value.PaidPostFileNameFormat}\""); - hoconConfig.AppendLine($" PostFileNameFormat = \"{creatorConfig.Value.PostFileNameFormat}\""); - hoconConfig.AppendLine($" PaidMessageFileNameFormat = \"{creatorConfig.Value.PaidMessageFileNameFormat}\""); - hoconConfig.AppendLine($" MessageFileNameFormat = \"{creatorConfig.Value.MessageFileNameFormat}\""); - hoconConfig.AppendLine(" }"); - } - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Folder Settings"); - hoconConfig.AppendLine("Folder {"); - hoconConfig.AppendLine($" FolderPerPaidPost = {jsonConfig.FolderPerPaidPost.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerPost = {jsonConfig.FolderPerPost.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerPaidMessage = {jsonConfig.FolderPerPaidMessage.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerMessage = {jsonConfig.FolderPerMessage.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Subscription Settings"); - hoconConfig.AppendLine("Subscriptions {"); - hoconConfig.AppendLine($" IncludeExpiredSubscriptions = {jsonConfig.IncludeExpiredSubscriptions.ToString().ToLower()}"); - hoconConfig.AppendLine($" IncludeRestrictedSubscriptions = {jsonConfig.IncludeRestrictedSubscriptions.ToString().ToLower()}"); - hoconConfig.AppendLine($" IgnoredUsersListName = \"{jsonConfig.IgnoredUsersListName}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Interaction Settings"); - hoconConfig.AppendLine("Interaction {"); - hoconConfig.AppendLine($" NonInteractiveMode = {jsonConfig.NonInteractiveMode.ToString().ToLower()}"); - hoconConfig.AppendLine($" NonInteractiveModeListName = \"{jsonConfig.NonInteractiveModeListName}\""); - hoconConfig.AppendLine($" NonInteractiveModePurchasedTab = {jsonConfig.NonInteractiveModePurchasedTab.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Performance Settings"); - hoconConfig.AppendLine("Performance {"); - hoconConfig.AppendLine($" Timeout = {(jsonConfig.Timeout.HasValue ? jsonConfig.Timeout.Value : -1)}"); - hoconConfig.AppendLine($" LimitDownloadRate = {jsonConfig.LimitDownloadRate.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadLimitInMbPerSec = {jsonConfig.DownloadLimitInMbPerSec}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Logging/Debug Settings"); - hoconConfig.AppendLine("Logging {"); - hoconConfig.AppendLine($" LoggingLevel = \"{jsonConfig.LoggingLevel.ToString().ToLower()}\""); - hoconConfig.AppendLine("}"); - - File.WriteAllText("config.conf", hoconConfig.ToString()); - File.Delete("config.json"); - AnsiConsole.Markup("[green]config.conf created successfully from config.json!\n[/]"); - } - } - catch (Exception e) - { - Console.WriteLine(e); - AnsiConsole.MarkupLine($"\n[red]config.conf is not valid, check your syntax![/]\n"); - AnsiConsole.MarkupLine($"[red]Press any key to exit.[/]"); - Log.Error("config.conf processing failed.", e.Message); - - if (!cliNonInteractive) - { - Console.ReadKey(); - } - Environment.Exit(3); - } + AnsiConsole.MarkupLine("[red]Press any key to exit.[/]"); + Console.ReadKey(); } - //I dont like it... but I needed to move config here, otherwise the logging level gets changed too late after we missed a whole bunch of important info - if (File.Exists("config.conf")) - { - AnsiConsole.Markup("[green]config.conf located successfully!\n[/]"); - try - { - string hoconText = File.ReadAllText("config.conf"); + Environment.Exit(3); + } - var hoconConfig = ConfigurationFactory.ParseString(hoconText); + AnsiConsole.Markup("[green]config.conf located successfully!\n[/]"); - config = new Entities.Config - { - //Auth - DisableBrowserAuth = hoconConfig.GetBoolean("Auth.DisableBrowserAuth"), + // Set up full dependency injection with loaded config + services = []; + services.AddSingleton(loggingService); + services.AddSingleton(configService); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); - // FFmpeg Settings - FFmpegPath = hoconConfig.GetString("External.FFmpegPath"), + return services; + } - // Download Settings - DownloadAvatarHeaderPhoto = hoconConfig.GetBoolean("Download.Media.DownloadAvatarHeaderPhoto"), - DownloadPaidPosts = hoconConfig.GetBoolean("Download.Media.DownloadPaidPosts"), - DownloadPosts = hoconConfig.GetBoolean("Download.Media.DownloadPosts"), - DownloadArchived = hoconConfig.GetBoolean("Download.Media.DownloadArchived"), - DownloadStreams = hoconConfig.GetBoolean("Download.Media.DownloadStreams"), - DownloadStories = hoconConfig.GetBoolean("Download.Media.DownloadStories"), - DownloadHighlights = hoconConfig.GetBoolean("Download.Media.DownloadHighlights"), - DownloadMessages = hoconConfig.GetBoolean("Download.Media.DownloadMessages"), - DownloadPaidMessages = hoconConfig.GetBoolean("Download.Media.DownloadPaidMessages"), - DownloadImages = hoconConfig.GetBoolean("Download.Media.DownloadImages"), - DownloadVideos = hoconConfig.GetBoolean("Download.Media.DownloadVideos"), - DownloadAudios = hoconConfig.GetBoolean("Download.Media.DownloadAudios"), - IgnoreOwnMessages = hoconConfig.GetBoolean("Download.IgnoreOwnMessages"), - DownloadPostsIncrementally = hoconConfig.GetBoolean("Download.DownloadPostsIncrementally"), - BypassContentForCreatorsWhoNoLongerExist = hoconConfig.GetBoolean("Download.BypassContentForCreatorsWhoNoLongerExist"), - DownloadDuplicatedMedia = hoconConfig.GetBoolean("Download.DownloadDuplicatedMedia"), - SkipAds = hoconConfig.GetBoolean("Download.SkipAds"), - DownloadPath = hoconConfig.GetString("Download.DownloadPath"), - DownloadOnlySpecificDates = hoconConfig.GetBoolean("Download.DownloadOnlySpecificDates"), - DownloadDateSelection = Enum.Parse(hoconConfig.GetString("Download.DownloadDateSelection"), true), - CustomDate = !string.IsNullOrWhiteSpace(hoconConfig.GetString("Download.CustomDate")) ? DateTime.Parse(hoconConfig.GetString("Download.CustomDate")) : null, - ShowScrapeSize = hoconConfig.GetBoolean("Download.ShowScrapeSize"), - // Optional flag; default to false when missing - DisableTextSanitization = bool.TryParse(hoconConfig.GetString("Download.DisableTextSanitization", "false"), out var dts) ? dts : false, - DownloadVideoResolution = ParseVideoResolution(hoconConfig.GetString("Download.DownloadVideoResolution", "source")), + private async Task RunAsync() + { + IConfigService configService = serviceProvider.GetRequiredService(); + IAuthService authService = serviceProvider.GetRequiredService(); + IStartupService startupService = serviceProvider.GetRequiredService(); + IDownloadOrchestrationService orchestrationService = + serviceProvider.GetRequiredService(); - // File Settings - PaidPostFileNameFormat = hoconConfig.GetString("File.PaidPostFileNameFormat"), - PostFileNameFormat = hoconConfig.GetString("File.PostFileNameFormat"), - PaidMessageFileNameFormat = hoconConfig.GetString("File.PaidMessageFileNameFormat"), - MessageFileNameFormat = hoconConfig.GetString("File.MessageFileNameFormat"), - RenameExistingFilesWhenCustomFormatIsSelected = hoconConfig.GetBoolean("File.RenameExistingFilesWhenCustomFormatIsSelected"), + try + { + // Version check + VersionCheckResult versionResult = await startupService.CheckVersionAsync(); + DisplayVersionResult(versionResult); - // Folder Settings - FolderPerPaidPost = hoconConfig.GetBoolean("Folder.FolderPerPaidPost"), - FolderPerPost = hoconConfig.GetBoolean("Folder.FolderPerPost"), - FolderPerPaidMessage = hoconConfig.GetBoolean("Folder.FolderPerPaidMessage"), - FolderPerMessage = hoconConfig.GetBoolean("Folder.FolderPerMessage"), + // Environment validation + StartupResult startupResult = await startupService.ValidateEnvironmentAsync(); + DisplayStartupResult(startupResult); - // Subscription Settings - IncludeExpiredSubscriptions = hoconConfig.GetBoolean("Subscriptions.IncludeExpiredSubscriptions"), - IncludeRestrictedSubscriptions = hoconConfig.GetBoolean("Subscriptions.IncludeRestrictedSubscriptions"), - IgnoredUsersListName = hoconConfig.GetString("Subscriptions.IgnoredUsersListName"), - - // Interaction Settings - NonInteractiveMode = hoconConfig.GetBoolean("Interaction.NonInteractiveMode"), - NonInteractiveModeListName = hoconConfig.GetString("Interaction.NonInteractiveModeListName"), - NonInteractiveModePurchasedTab = hoconConfig.GetBoolean("Interaction.NonInteractiveModePurchasedTab"), - - // Performance Settings - Timeout = string.IsNullOrWhiteSpace(hoconConfig.GetString("Performance.Timeout")) ? -1 : hoconConfig.GetInt("Performance.Timeout"), - LimitDownloadRate = hoconConfig.GetBoolean("Performance.LimitDownloadRate"), - DownloadLimitInMbPerSec = hoconConfig.GetInt("Performance.DownloadLimitInMbPerSec"), - - // Logging/Debug Settings - LoggingLevel = Enum.Parse(hoconConfig.GetString("Logging.LoggingLevel"), true) - }; - - ValidateFileNameFormat(config.PaidPostFileNameFormat, "PaidPostFileNameFormat"); - ValidateFileNameFormat(config.PostFileNameFormat, "PostFileNameFormat"); - ValidateFileNameFormat(config.PaidMessageFileNameFormat, "PaidMessageFileNameFormat"); - ValidateFileNameFormat(config.MessageFileNameFormat, "MessageFileNameFormat"); - - var creatorConfigsSection = hoconConfig.GetConfig("CreatorConfigs"); - if (creatorConfigsSection != null) - { - foreach (var key in creatorConfigsSection.AsEnumerable()) - { - var creatorKey = key.Key; - var creatorHocon = creatorConfigsSection.GetConfig(creatorKey); - if (!config.CreatorConfigs.ContainsKey(creatorKey) && creatorHocon != null) - { - config.CreatorConfigs.Add(key.Key, new CreatorConfig - { - PaidPostFileNameFormat = creatorHocon.GetString("PaidPostFileNameFormat"), - PostFileNameFormat = creatorHocon.GetString("PostFileNameFormat"), - PaidMessageFileNameFormat = creatorHocon.GetString("PaidMessageFileNameFormat"), - MessageFileNameFormat = creatorHocon.GetString("MessageFileNameFormat") - }); - - ValidateFileNameFormat(config.CreatorConfigs[key.Key].PaidPostFileNameFormat, $"{key.Key}.PaidPostFileNameFormat"); - ValidateFileNameFormat(config.CreatorConfigs[key.Key].PostFileNameFormat, $"{key.Key}.PostFileNameFormat"); - ValidateFileNameFormat(config.CreatorConfigs[key.Key].PaidMessageFileNameFormat, $"{key.Key}.PaidMessageFileNameFormat"); - ValidateFileNameFormat(config.CreatorConfigs[key.Key].MessageFileNameFormat, $"{key.Key}.MessageFileNameFormat"); - } - } - } - - levelSwitch.MinimumLevel = (LogEventLevel)config.LoggingLevel; //set the logging level based on config - // Apply text sanitization preference globally - OF_DL.Utils.XmlUtils.Passthrough = config.DisableTextSanitization; - Log.Debug("Configuration:"); - string configString = JsonConvert.SerializeObject(config, Formatting.Indented); - Log.Debug(configString); - } - catch (Exception e) - { - Console.WriteLine(e); - AnsiConsole.MarkupLine($"\n[red]config.conf is not valid, check your syntax![/]\n"); - AnsiConsole.MarkupLine($"[red]Press any key to exit.[/]"); - Log.Error("config.conf processing failed.", e.Message); - - if (!cliNonInteractive) - { - Console.ReadKey(); - } - Environment.Exit(3); - } - } - else - { - Entities.Config jsonConfig = new Entities.Config(); - var hoconConfig = new StringBuilder(); - hoconConfig.AppendLine("# Auth"); - hoconConfig.AppendLine("Auth {"); - hoconConfig.AppendLine($" DisableBrowserAuth = {jsonConfig.DisableBrowserAuth.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - hoconConfig.AppendLine("# External Tools"); - hoconConfig.AppendLine("External {"); - hoconConfig.AppendLine($" FFmpegPath = \"{jsonConfig.FFmpegPath}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Download Settings"); - hoconConfig.AppendLine("Download {"); - hoconConfig.AppendLine(" Media {"); - hoconConfig.AppendLine($" DownloadAvatarHeaderPhoto = {jsonConfig.DownloadAvatarHeaderPhoto.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPaidPosts = {jsonConfig.DownloadPaidPosts.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPosts = {jsonConfig.DownloadPosts.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadArchived = {jsonConfig.DownloadArchived.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadStreams = {jsonConfig.DownloadStreams.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadStories = {jsonConfig.DownloadStories.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadHighlights = {jsonConfig.DownloadHighlights.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadMessages = {jsonConfig.DownloadMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPaidMessages = {jsonConfig.DownloadPaidMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadImages = {jsonConfig.DownloadImages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadVideos = {jsonConfig.DownloadVideos.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadAudios = {jsonConfig.DownloadAudios.ToString().ToLower()}"); - hoconConfig.AppendLine(" }"); - hoconConfig.AppendLine($" IgnoreOwnMessages = {jsonConfig.IgnoreOwnMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPostsIncrementally = {jsonConfig.DownloadPostsIncrementally.ToString().ToLower()}"); - hoconConfig.AppendLine($" BypassContentForCreatorsWhoNoLongerExist = {jsonConfig.BypassContentForCreatorsWhoNoLongerExist.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadDuplicatedMedia = {jsonConfig.DownloadDuplicatedMedia.ToString().ToLower()}"); - hoconConfig.AppendLine($" SkipAds = {jsonConfig.SkipAds.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPath = \"{jsonConfig.DownloadPath}\""); - hoconConfig.AppendLine($" DownloadOnlySpecificDates = {jsonConfig.DownloadOnlySpecificDates.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadDateSelection = \"{jsonConfig.DownloadDateSelection.ToString().ToLower()}\""); - hoconConfig.AppendLine($" CustomDate = \"{jsonConfig.CustomDate?.ToString("yyyy-MM-dd")}\""); - hoconConfig.AppendLine($" ShowScrapeSize = {jsonConfig.ShowScrapeSize.ToString().ToLower()}"); - // New option defaults to false when converting legacy json - hoconConfig.AppendLine($" DisableTextSanitization = false"); - hoconConfig.AppendLine($" DownloadVideoResolution = \"{(jsonConfig.DownloadVideoResolution == VideoResolution.source ? "source" : jsonConfig.DownloadVideoResolution.ToString().TrimStart('_'))}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# File Settings"); - hoconConfig.AppendLine("File {"); - hoconConfig.AppendLine($" PaidPostFileNameFormat = \"{jsonConfig.PaidPostFileNameFormat}\""); - hoconConfig.AppendLine($" PostFileNameFormat = \"{jsonConfig.PostFileNameFormat}\""); - hoconConfig.AppendLine($" PaidMessageFileNameFormat = \"{jsonConfig.PaidMessageFileNameFormat}\""); - hoconConfig.AppendLine($" MessageFileNameFormat = \"{jsonConfig.MessageFileNameFormat}\""); - hoconConfig.AppendLine($" RenameExistingFilesWhenCustomFormatIsSelected = {jsonConfig.RenameExistingFilesWhenCustomFormatIsSelected.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Creator-Specific Configurations"); - hoconConfig.AppendLine("CreatorConfigs {"); - foreach (var creatorConfig in jsonConfig.CreatorConfigs) + if (!startupResult.IsWindowsVersionValid) + { + Console.Write( + "This appears to be running on an older version of Windows which is not supported.\n\n"); + Console.Write( + "OF-DL requires Windows 10 or higher when being run on Windows. Your reported version is: {0}\n\n", + startupResult.OsVersionString); + if (!configService.CurrentConfig.NonInteractiveMode) { - hoconConfig.AppendLine($" \"{creatorConfig.Key}\" {{"); - hoconConfig.AppendLine($" PaidPostFileNameFormat = \"{creatorConfig.Value.PaidPostFileNameFormat}\""); - hoconConfig.AppendLine($" PostFileNameFormat = \"{creatorConfig.Value.PostFileNameFormat}\""); - hoconConfig.AppendLine($" PaidMessageFileNameFormat = \"{creatorConfig.Value.PaidMessageFileNameFormat}\""); - hoconConfig.AppendLine($" MessageFileNameFormat = \"{creatorConfig.Value.MessageFileNameFormat}\""); - hoconConfig.AppendLine(" }"); + Console.Write("Press any key to continue.\n"); + Console.ReadKey(); } - hoconConfig.AppendLine("}"); - hoconConfig.AppendLine("# Folder Settings"); - hoconConfig.AppendLine("Folder {"); - hoconConfig.AppendLine($" FolderPerPaidPost = {jsonConfig.FolderPerPaidPost.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerPost = {jsonConfig.FolderPerPost.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerPaidMessage = {jsonConfig.FolderPerPaidMessage.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerMessage = {jsonConfig.FolderPerMessage.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); + Environment.Exit(1); + } - hoconConfig.AppendLine("# Subscription Settings"); - hoconConfig.AppendLine("Subscriptions {"); - hoconConfig.AppendLine($" IncludeExpiredSubscriptions = {jsonConfig.IncludeExpiredSubscriptions.ToString().ToLower()}"); - hoconConfig.AppendLine($" IncludeRestrictedSubscriptions = {jsonConfig.IncludeRestrictedSubscriptions.ToString().ToLower()}"); - hoconConfig.AppendLine($" IgnoredUsersListName = \"{jsonConfig.IgnoredUsersListName}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Interaction Settings"); - hoconConfig.AppendLine("Interaction {"); - hoconConfig.AppendLine($" NonInteractiveMode = {jsonConfig.NonInteractiveMode.ToString().ToLower()}"); - hoconConfig.AppendLine($" NonInteractiveModeListName = \"{jsonConfig.NonInteractiveModeListName}\""); - hoconConfig.AppendLine($" NonInteractiveModePurchasedTab = {jsonConfig.NonInteractiveModePurchasedTab.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Performance Settings"); - hoconConfig.AppendLine("Performance {"); - hoconConfig.AppendLine($" Timeout = {(jsonConfig.Timeout.HasValue ? jsonConfig.Timeout.Value : -1)}"); - hoconConfig.AppendLine($" LimitDownloadRate = {jsonConfig.LimitDownloadRate.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadLimitInMbPerSec = {jsonConfig.DownloadLimitInMbPerSec}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Logging/Debug Settings"); - hoconConfig.AppendLine("Logging {"); - hoconConfig.AppendLine($" LoggingLevel = \"{jsonConfig.LoggingLevel.ToString().ToLower()}\""); - hoconConfig.AppendLine("}"); - - File.WriteAllText("config.conf", hoconConfig.ToString()); - AnsiConsole.Markup("[red]config.conf does not exist, a default file has been created in the folder you are running the program from[/]"); - Log.Error("config.conf does not exist"); - - if (!cliNonInteractive) - { - Console.ReadKey(); - } - Environment.Exit(3); - } - - - if (args is not null && args.Length > 0) - { - const string NON_INTERACTIVE_ARG = "--non-interactive"; - - if (args.Any(a => NON_INTERACTIVE_ARG.Equals(NON_INTERACTIVE_ARG, StringComparison.OrdinalIgnoreCase))) - { - cliNonInteractive = true; - Log.Debug("NonInteractiveMode set via command line"); - } - - Log.Debug("Additional arguments:"); - foreach (string argument in args) - { - Log.Debug(argument); - } - } - - var os = Environment.OSVersion; - - Log.Debug($"Operating system information: {os.VersionString}"); - - if (os.Platform == PlatformID.Win32NT) - { - // check if this is windows 10+ - if (os.Version.Major < 10) - { - Console.Write("This appears to be running on an older version of Windows which is not supported.\n\n"); - Console.Write("OF-DL requires Windows 10 or higher when being run on Windows. Your reported version is: {0}\n\n", os.VersionString); - Console.Write("Press any key to continue.\n"); - Log.Error("Windows version prior to 10.x: {0}", os.VersionString); - - if (!cliNonInteractive) - { - Console.ReadKey(); - } - Environment.Exit(1); - } - else - { - AnsiConsole.Markup("[green]Valid version of Windows found.\n[/]"); - } - } - - try - { - // Only run the version check if not in DEBUG mode - #if !DEBUG - Version localVersion = Assembly.GetEntryAssembly()?.GetName().Version; //Only tested with numeric values. - - // Create a cancellation token with 30 second timeout - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - String? latestReleaseTag = null; - - try - { - latestReleaseTag = await VersionHelper.GetLatestReleaseTag(cts.Token); - } - catch (OperationCanceledException) - { - AnsiConsole.Markup("[yellow]Version check timed out after 30 seconds.\n[/]"); - Log.Warning("Version check timed out after 30 seconds"); - latestReleaseTag = null; - } - - if (latestReleaseTag == null) - { - AnsiConsole.Markup("[yellow]Failed to verify that OF-DL is up-to-date.\n[/]"); - Log.Error("Failed to get the latest release tag."); - } - else - { - Version latestGiteaRelease = new Version(latestReleaseTag.Replace("OFDLV", "")); - - // Compare the Versions - int versionComparison = localVersion.CompareTo(latestGiteaRelease); - if (versionComparison < 0) - { - // The version on GitHub is more up to date than this local release. - AnsiConsole.Markup("[red]You are running OF-DL version " + $"{localVersion.Major}.{localVersion.Minor}.{localVersion.Build}\n[/]"); - AnsiConsole.Markup("[red]Please update to the current release, " + $"{latestGiteaRelease.Major}.{latestGiteaRelease.Minor}.{latestGiteaRelease.Build}: [link=https://git.ofdl.tools/sim0n00ps/OF-DL/releases]https://git.ofdl.tools/sim0n00ps/OF-DL/releases[/]\n[/]"); - Log.Debug("Detected outdated client running version " + $"{localVersion.Major}.{localVersion.Minor}.{localVersion.Build}"); - Log.Debug("Latest release version " + $"{latestGiteaRelease.Major}.{latestGiteaRelease.Minor}.{latestGiteaRelease.Build}"); - } - else - { - // This local version is greater than the release version on GitHub. - AnsiConsole.Markup("[green]You are running OF-DL version " + $"{localVersion.Major}.{localVersion.Minor}.{localVersion.Build}\n[/]"); - AnsiConsole.Markup("[green]Latest Release version: " + $"{latestGiteaRelease.Major}.{latestGiteaRelease.Minor}.{latestGiteaRelease.Build}\n[/]"); - Log.Debug("Detected client running version " + $"{localVersion.Major}.{localVersion.Minor}.{localVersion.Build}"); - Log.Debug("Latest release version " + $"{latestGiteaRelease.Major}.{latestGiteaRelease.Minor}.{latestGiteaRelease.Build}"); - } - } - - #else - AnsiConsole.Markup("[yellow]Running in Debug/Local mode. Version check skipped.\n[/]"); - Log.Debug("Running in Debug/Local mode. Version check skipped."); - #endif - } - catch (Exception e) - { - AnsiConsole.Markup("[red]Error checking latest release on GitHub:\n[/]"); - Console.WriteLine(e); - Log.Error("Error checking latest release on GitHub.", e.Message); - } - - - if (File.Exists("auth.json")) - { - AnsiConsole.Markup("[green]auth.json located successfully!\n[/]"); - Log.Debug("Auth file found"); - try - { - auth = JsonConvert.DeserializeObject(await File.ReadAllTextAsync("auth.json")); - Log.Debug("Auth file found and deserialized"); - } - catch (Exception _) - { - Log.Information("Auth file found but could not be deserialized"); - if (!config!.DisableBrowserAuth) - { - Log.Debug("Deleting auth.json"); - File.Delete("auth.json"); - } - - if (cliNonInteractive) - { - AnsiConsole.MarkupLine($"\n[red]auth.json has invalid JSON syntax. The file can be generated automatically when OF-DL is run in the standard, interactive mode.[/]\n"); - AnsiConsole.MarkupLine($"[red]You may also want to try using the browser extension which is documented here:[/]\n"); - AnsiConsole.MarkupLine($"[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); - AnsiConsole.MarkupLine($"[red]Press any key to exit.[/]"); - - Console.ReadKey(); - Environment.Exit(2); - } - - - if (!config!.DisableBrowserAuth) - { - await LoadAuthFromBrowser(); - } - else - { - AnsiConsole.MarkupLine($"\n[red]auth.json is missing. The file can be generated automatically when OF-DL is run in the standard, interactive mode.[/]\n"); - AnsiConsole.MarkupLine($"[red]You may also want to try using the browser extension which is documented here:[/]\n"); - AnsiConsole.MarkupLine($"[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); - AnsiConsole.MarkupLine($"[red]Press any key to exit.[/]"); - - Console.ReadKey(); - Environment.Exit(2); - } - } - } - else - { - if (cliNonInteractive) - { - AnsiConsole.MarkupLine($"\n[red]auth.json is missing. The file can be generated automatically when OF-DL is run in the standard, interactive mode.[/]\n"); - AnsiConsole.MarkupLine($"[red]You may also want to try using the browser extension which is documented here:[/]\n"); - AnsiConsole.MarkupLine($"[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); - AnsiConsole.MarkupLine($"[red]Press any key to exit.[/]"); - - Console.ReadKey(); - Environment.Exit(2); - } - - if (!config!.DisableBrowserAuth) + if (!startupResult.FfmpegFound) + { + if (!configService.CurrentConfig.NonInteractiveMode) { - await LoadAuthFromBrowser(); + AnsiConsole.Markup( + "[red]Cannot locate FFmpeg; please modify config.conf with the correct path. Press any key to exit.[/]"); + Console.ReadKey(); } else { - AnsiConsole.MarkupLine($"\n[red]auth.json is missing. The file can be generated automatically when OF-DL is run in the standard, interactive mode.[/]\n"); - AnsiConsole.MarkupLine($"[red]You may also want to try using the browser extension which is documented here:[/]\n"); - AnsiConsole.MarkupLine($"[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); - AnsiConsole.MarkupLine($"[red]Press any key to exit.[/]"); - - Console.ReadKey(); - Environment.Exit(2); + AnsiConsole.Markup( + "[red]Cannot locate FFmpeg; please modify config.conf with the correct path.[/]"); } - } - //Added to stop cookie being filled with un-needed headers - ValidateCookieString(); + Environment.Exit(4); + } - if (File.Exists("rules.json")) - { - AnsiConsole.Markup("[green]rules.json located successfully!\n[/]"); - try - { - JsonConvert.DeserializeObject(File.ReadAllText("rules.json")); - Log.Debug($"Rules.json: "); - Log.Debug(JsonConvert.SerializeObject(File.ReadAllText("rules.json"), Formatting.Indented)); - } - catch (Exception e) - { - Console.WriteLine(e); - AnsiConsole.MarkupLine($"\n[red]rules.json is not valid, check your JSON syntax![/]\n"); - AnsiConsole.MarkupLine($"[red]Please ensure you are using the latest version of the software.[/]\n"); - AnsiConsole.MarkupLine($"[red]Press any key to exit.[/]"); - Log.Error("rules.json processing failed.", e.Message); + // Auth flow + await HandleAuthFlow(authService, configService); - if (!cliNonInteractive) - { - Console.ReadKey(); - } - Environment.Exit(2); - } - } + // Validate cookie string + authService.ValidateCookieString(); - if(cliNonInteractive) - { - // CLI argument overrides configuration - config!.NonInteractiveMode = true; - Log.Debug("NonInteractiveMode = true"); - } + // rules.json validation + DisplayRulesJsonResult(startupResult, configService); - if(config!.NonInteractiveMode) - { - cliNonInteractive = true; // If it was set in the config, reset the cli value so exception handling works - Log.Debug("NonInteractiveMode = true (set via config)"); - } + // NonInteractiveMode + if (configService.CurrentConfig.NonInteractiveMode) + { + configService.CurrentConfig.NonInteractiveMode = true; + Log.Debug("NonInteractiveMode = true"); + } - var ffmpegFound = false; - var pathAutoDetected = false; - if (!string.IsNullOrEmpty(config!.FFmpegPath) && ValidateFilePath(config.FFmpegPath)) - { - // FFmpeg path is set in config.json and is valid - ffmpegFound = true; - Log.Debug($"FFMPEG found: {config.FFmpegPath}"); - Log.Debug("FFMPEG path set in config.conf"); - } - else if (!string.IsNullOrEmpty(auth!.FFMPEG_PATH) && ValidateFilePath(auth.FFMPEG_PATH)) - { - // FFmpeg path is set in auth.json and is valid (config.conf takes precedence and auth.json is only available for backward compatibility) - ffmpegFound = true; - config.FFmpegPath = auth.FFMPEG_PATH; - Log.Debug($"FFMPEG found: {config.FFmpegPath}"); - Log.Debug("FFMPEG path set in auth.json"); - } - else if (string.IsNullOrEmpty(config.FFmpegPath)) - { - // FFmpeg path is not set in config.conf, so we will try to locate it in the PATH or current directory - var ffmpegPath = GetFullPath("ffmpeg"); - if (ffmpegPath != null) - { - // FFmpeg is found in the PATH or current directory - ffmpegFound = true; - pathAutoDetected = true; - config.FFmpegPath = ffmpegPath; - Log.Debug($"FFMPEG found: {ffmpegPath}"); - Log.Debug("FFMPEG path found via PATH or current directory"); - } - else - { - // FFmpeg is not found in the PATH or current directory, so we will try to locate the windows executable - ffmpegPath = GetFullPath("ffmpeg.exe"); - if (ffmpegPath != null) - { - // FFmpeg windows executable is found in the PATH or current directory - ffmpegFound = true; - pathAutoDetected = true; - config.FFmpegPath = ffmpegPath; - Log.Debug($"FFMPEG found: {ffmpegPath}"); - Log.Debug("FFMPEG path found in windows excutable directory"); - } - } - } + // Validate auth via API + User? validate = await authService.ValidateAuthAsync(); + if (validate == null || (validate.Name == null && validate.Username == null)) + { + Log.Error("Auth failed"); + authService.CurrentAuth = null; - if (ffmpegFound) - { - if (pathAutoDetected) - { - AnsiConsole.Markup($"[green]FFmpeg located successfully. Path auto-detected: {config.FFmpegPath}\n[/]"); - } - else - { - AnsiConsole.Markup($"[green]FFmpeg located successfully\n[/]"); - } - - // Escape backslashes in the path for Windows - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && config.FFmpegPath!.Contains(@":\") && !config.FFmpegPath.Contains(@":\\")) - { - config.FFmpegPath = config.FFmpegPath.Replace(@"\", @"\\"); - } - - // Get FFmpeg version - try - { - var processStartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = config.FFmpegPath, - Arguments = "-version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using (var process = System.Diagnostics.Process.Start(processStartInfo)) - { - if (process != null) - { - string output = await process.StandardOutput.ReadToEndAsync(); - await process.WaitForExitAsync(); - - // Log full output - Log.Information("FFmpeg version output:\n{Output}", output); - - // Parse first line for console output - string firstLine = output.Split('\n')[0].Trim(); - if (firstLine.StartsWith("ffmpeg version")) - { - // Extract version string (text between "ffmpeg version " and " Copyright") - int versionStart = "ffmpeg version ".Length; - int copyrightIndex = firstLine.IndexOf(" Copyright"); - if (copyrightIndex > versionStart) - { - string version = firstLine.Substring(versionStart, copyrightIndex - versionStart); - AnsiConsole.Markup($"[green]ffmpeg version detected as {version}[/]\n"); - } - else - { - // Fallback if Copyright not found - string version = firstLine.Substring(versionStart); - AnsiConsole.Markup($"[green]ffmpeg version detected as {version}[/]\n"); - } - } - else - { - AnsiConsole.Markup($"[yellow]ffmpeg version could not be parsed[/]\n"); - } - } - } - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to get FFmpeg version"); - AnsiConsole.Markup($"[yellow]Could not retrieve ffmpeg version[/]\n"); - } - } - else - { - AnsiConsole.Markup("[red]Cannot locate FFmpeg; please modify config.conf with the correct path. Press any key to exit.[/]"); - Log.Error($"Cannot locate FFmpeg with path: {config.FFmpegPath}"); - if (!config.NonInteractiveMode) - { - Console.ReadKey(); - } - Environment.Exit(4); - } - - if (!File.Exists(Path.Join(WidevineClient.Widevine.Constants.DEVICES_FOLDER, WidevineClient.Widevine.Constants.DEVICE_NAME, "device_client_id_blob"))) - { - clientIdBlobMissing = true; - Log.Debug("clientIdBlobMissing missing"); - } - else - { - AnsiConsole.Markup($"[green]device_client_id_blob located successfully![/]\n"); - Log.Debug("clientIdBlobMissing found: " + File.Exists(Path.Join(WidevineClient.Widevine.Constants.DEVICES_FOLDER, WidevineClient.Widevine.Constants.DEVICE_NAME, "device_client_id_blob"))); - } - - if (!File.Exists(Path.Join(WidevineClient.Widevine.Constants.DEVICES_FOLDER, WidevineClient.Widevine.Constants.DEVICE_NAME, "device_private_key"))) - { - devicePrivateKeyMissing = true; - Log.Debug("devicePrivateKeyMissing missing"); - } - else - { - AnsiConsole.Markup($"[green]device_private_key located successfully![/]\n"); - Log.Debug("devicePrivateKeyMissing found: " + File.Exists(Path.Join(WidevineClient.Widevine.Constants.DEVICES_FOLDER, WidevineClient.Widevine.Constants.DEVICE_NAME, "device_private_key"))); - } - - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - AnsiConsole.Markup("[yellow]device_client_id_blob and/or device_private_key missing, https://ofdl.tools/ or https://cdrm-project.com/ will be used instead for DRM protected videos\n[/]"); - } - - //Check if auth is valid - var apiHelper = new APIHelper(auth, config); - - Entities.User? validate = await apiHelper.GetUserInfo($"/users/me"); - if (validate == null || (validate?.name == null && validate?.username == null)) - { - Log.Error("Auth failed"); - - auth = null; - if (!config!.DisableBrowserAuth) + if (!configService.CurrentConfig.DisableBrowserAuth) { if (File.Exists("auth.json")) { @@ -881,2617 +203,704 @@ public class Program } } - if (!cliNonInteractive && !config!.DisableBrowserAuth) - { + if (!configService.CurrentConfig.NonInteractiveMode && + !configService.CurrentConfig.DisableBrowserAuth) + { await LoadAuthFromBrowser(); } - if (auth == null) - { - AnsiConsole.MarkupLine($"\n[red]Auth failed. Please try again or use other authentication methods detailed here:[/]\n"); - AnsiConsole.MarkupLine($"[link]https://docs.ofdl.tools/config/auth[/]\n"); - Console.ReadKey(); - Environment.Exit(2); - } - } - - AnsiConsole.Markup($"[green]Logged In successfully as {validate.name} {validate.username}\n[/]"); - await DownloadAllData(apiHelper, auth, config); - } - catch (Exception ex) - { - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine("\nInner Exception:"); - Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, ex.InnerException.StackTrace); - } - Console.WriteLine("\nPress any key to exit."); - if (!cliNonInteractive) - { - Console.ReadKey(); - } - Environment.Exit(5); - } - } - - - private static async Task DownloadAllData(APIHelper m_ApiHelper, Auth Auth, Entities.Config Config) - { - DBHelper dBHelper = new DBHelper(Config); - - Log.Debug("Calling DownloadAllData"); - - do - { - DateTime startTime = DateTime.Now; - Dictionary users = new(); - Dictionary activeSubs = await m_ApiHelper.GetActiveSubscriptions("/subscriptions/subscribes", Config.IncludeRestrictedSubscriptions, Config); - - Log.Debug("Subscriptions: "); - - foreach (KeyValuePair activeSub in activeSubs) - { - if (!users.ContainsKey(activeSub.Key)) - { - users.Add(activeSub.Key, activeSub.Value); - Log.Debug($"Name: {activeSub.Key} ID: {activeSub.Value}"); - } - } - if (Config!.IncludeExpiredSubscriptions) - { - Log.Debug("Inactive Subscriptions: "); - - Dictionary expiredSubs = await m_ApiHelper.GetExpiredSubscriptions("/subscriptions/subscribes", Config.IncludeRestrictedSubscriptions, Config); - foreach (KeyValuePair expiredSub in expiredSubs) - { - if (!users.ContainsKey(expiredSub.Key)) - { - users.Add(expiredSub.Key, expiredSub.Value); - Log.Debug($"Name: {expiredSub.Key} ID: {expiredSub.Value}"); - } - } - } - - Dictionary lists = await m_ApiHelper.GetLists("/lists", Config); - - // Remove users from the list if they are in the ignored list - if (!string.IsNullOrEmpty(Config.IgnoredUsersListName)) - { - if (!lists.TryGetValue(Config.IgnoredUsersListName, out var ignoredUsersListId)) - { - AnsiConsole.Markup($"[red]Ignored users list '{Config.IgnoredUsersListName}' not found\n[/]"); - Log.Error($"Ignored users list '{Config.IgnoredUsersListName}' not found"); - } - else - { - var ignoredUsernames = await m_ApiHelper.GetListUsers($"/lists/{ignoredUsersListId}/users", Config) ?? []; - users = users.Where(x => !ignoredUsernames.Contains(x.Key)).ToDictionary(x => x.Key, x => x.Value); - } - } - - await dBHelper.CreateUsersDB(users); - KeyValuePair> hasSelectedUsersKVP; - if(Config.NonInteractiveMode && Config.NonInteractiveModePurchasedTab) - { - hasSelectedUsersKVP = new KeyValuePair>(true, new Dictionary { { "PurchasedTab", 0 } }); - } - else if (Config.NonInteractiveMode && string.IsNullOrEmpty(Config.NonInteractiveModeListName)) - { - hasSelectedUsersKVP = new KeyValuePair>(true, users); - } - else if (Config.NonInteractiveMode && !string.IsNullOrEmpty(Config.NonInteractiveModeListName)) - { - var listId = lists[Config.NonInteractiveModeListName]; - var listUsernames = await m_ApiHelper.GetListUsers($"/lists/{listId}/users", Config) ?? []; - var selectedUsers = users.Where(x => listUsernames.Contains(x.Key)).Distinct().ToDictionary(x => x.Key, x => x.Value); - hasSelectedUsersKVP = new KeyValuePair>(true, selectedUsers); - } - else - { - var userSelectionResult = await HandleUserSelection(m_ApiHelper, Config, users, lists); - - Config = userSelectionResult.updatedConfig; - hasSelectedUsersKVP = new KeyValuePair>(userSelectionResult.IsExit, userSelectionResult.selectedUsers); - } - - if (hasSelectedUsersKVP.Key && hasSelectedUsersKVP.Value != null && hasSelectedUsersKVP.Value.ContainsKey("SinglePost")) - { - AnsiConsole.Markup("[red]To find an individual post URL, click on the ... at the top right corner of the post and select 'Copy link to post'.\n\nTo return to the main menu, enter 'back' or 'exit' when prompted for the URL.\n\n[/]"); - string postUrl = AnsiConsole.Prompt( - new TextPrompt("[red]Please enter a post URL: [/]") - .ValidationErrorMessage("[red]Please enter a valid post URL[/]") - .Validate(url => - { - Log.Debug($"Single Post URL: {url}"); - Regex regex = new Regex("https://onlyfans\\.com/[0-9]+/[A-Za-z0-9]+", RegexOptions.IgnoreCase); - if (regex.IsMatch(url)) - { - return ValidationResult.Success(); - } - if (url == "" || url == "exit" || url == "back") { - return ValidationResult.Success(); - } - Log.Error("Post URL invalid"); - return ValidationResult.Error("[red]Please enter a valid post URL[/]"); - })); - - if (postUrl != "" && postUrl != "exit" && postUrl != "back") { - long post_id = Convert.ToInt64(postUrl.Split("/")[3]); - string username = postUrl.Split("/")[4]; - - Log.Debug($"Single Post ID: {post_id.ToString()}"); - Log.Debug($"Single Post Creator: {username}"); - - if (users.ContainsKey(username)) - { - string path = ""; - if (!string.IsNullOrEmpty(Config.DownloadPath)) - { - path = System.IO.Path.Combine(Config.DownloadPath, username); - } - else - { - path = $"__user_data__/sites/OnlyFans/{username}"; - } - - Log.Debug($"Download path: {path}"); - - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - AnsiConsole.Markup($"[red]Created folder for {username}\n[/]"); - Log.Debug($"Created folder for {username}"); - } - else - { - AnsiConsole.Markup($"[red]Folder for {username} already created\n[/]"); - } - - await dBHelper.CreateDB(path); - - var downloadContext = new DownloadContext(Auth, Config, GetCreatorFileNameFormatConfig(Config, username), m_ApiHelper, dBHelper); - - await DownloadSinglePost(downloadContext, post_id, path, users); - } - } - } - else if (hasSelectedUsersKVP.Key && hasSelectedUsersKVP.Value != null && hasSelectedUsersKVP.Value.ContainsKey("PurchasedTab")) - { - Dictionary purchasedTabUsers = await m_ApiHelper.GetPurchasedTabUsers("/posts/paid/all", Config, users); - AnsiConsole.Markup($"[red]Checking folders for Users in Purchased Tab\n[/]"); - foreach (KeyValuePair user in purchasedTabUsers) - { - string path = ""; - if (!string.IsNullOrEmpty(Config.DownloadPath)) - { - path = System.IO.Path.Combine(Config.DownloadPath, user.Key); - } - else - { - path = $"__user_data__/sites/OnlyFans/{user.Key}"; - } - - Log.Debug($"Download path: {path}"); - - await dBHelper.CheckUsername(user, path); - - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - AnsiConsole.Markup($"[red]Created folder for {user.Key}\n[/]"); - Log.Debug($"Created folder for {user.Key}"); - } - else - { - AnsiConsole.Markup($"[red]Folder for {user.Key} already created\n[/]"); - Log.Debug($"Folder for {user.Key} already created"); - } - - Entities.User user_info = await m_ApiHelper.GetUserInfo($"/users/{user.Key}"); - - await dBHelper.CreateDB(path); - } - - string p = ""; - if (!string.IsNullOrEmpty(Config.DownloadPath)) - { - p = Config.DownloadPath; - } - else - { - p = $"__user_data__/sites/OnlyFans/"; - } - - Log.Debug($"Download path: {p}"); - - List purchasedTabCollections = await m_ApiHelper.GetPurchasedTab("/posts/paid/all", p, Config, users); - foreach(PurchasedTabCollection purchasedTabCollection in purchasedTabCollections) - { - AnsiConsole.Markup($"[red]\nScraping Data for {purchasedTabCollection.Username}\n[/]"); - string path = ""; - if (!string.IsNullOrEmpty(Config.DownloadPath)) - { - path = System.IO.Path.Combine(Config.DownloadPath, purchasedTabCollection.Username); - } - else - { - path = $"__user_data__/sites/OnlyFans/{purchasedTabCollection.Username}"; - } - - - Log.Debug($"Download path: {path}"); - - var downloadContext = new DownloadContext(Auth, Config, GetCreatorFileNameFormatConfig(Config, purchasedTabCollection.Username), m_ApiHelper, dBHelper); - - int paidPostCount = 0; - int paidMessagesCount = 0; - paidPostCount = await DownloadPaidPostsPurchasedTab(downloadContext, purchasedTabCollection.PaidPosts, users.FirstOrDefault(u => u.Value == purchasedTabCollection.UserId), paidPostCount, path, users); - paidMessagesCount = await DownloadPaidMessagesPurchasedTab(downloadContext, purchasedTabCollection.PaidMessages, users.FirstOrDefault(u => u.Value == purchasedTabCollection.UserId), paidMessagesCount, path, users); - - AnsiConsole.Markup("\n"); - AnsiConsole.Write(new BreakdownChart() - .FullSize() - .AddItem("Paid Posts", paidPostCount, Color.Red) - .AddItem("Paid Messages", paidMessagesCount, Color.Aqua)); - AnsiConsole.Markup("\n"); - } - DateTime endTime = DateTime.Now; - TimeSpan totalTime = endTime - startTime; - AnsiConsole.Markup($"[green]Scrape Completed in {totalTime.TotalMinutes:0.00} minutes\n[/]"); - Log.Debug($"Scrape Completed in {totalTime.TotalMinutes:0.00} minutes"); - } - else if (hasSelectedUsersKVP.Key && hasSelectedUsersKVP.Value != null && hasSelectedUsersKVP.Value.ContainsKey("SingleMessage")) - { - AnsiConsole.Markup("[red]To find an individual message URL, note that you can only do so for PPV messages that you have unlocked. Go the main OnlyFans timeline, click on the Purchased tab, find the relevant message, click on the ... at the top right corner of the message, and select 'Copy link to message'. For all other messages, you cannot scrape them individually, you must scrape all messages from that creator.\n\nTo return to the main menu, enter 'back' or 'exit' when prompted for the URL.\n\n[/]"); - string messageUrl = AnsiConsole.Prompt( - new TextPrompt("[red]Please enter a message URL: [/]") - .ValidationErrorMessage("[red]Please enter a valid message URL[/]") - .Validate(url => - { - Log.Debug($"Single Paid Message URL: {url}"); - Regex regex = new Regex("https://onlyfans\\.com/my/chats/chat/[0-9]+/\\?firstId=[0-9]+$", RegexOptions.IgnoreCase); - if (regex.IsMatch(url)) - { - return ValidationResult.Success(); - } - if (url == "" || url == "back" || url == "exit") - { - return ValidationResult.Success(); - } - Log.Error("Message URL invalid"); - return ValidationResult.Error("[red]Please enter a valid message URL[/]"); - })); - - if (messageUrl != "" && messageUrl != "exit" && messageUrl != "back") - { - long message_id = Convert.ToInt64(messageUrl.Split("?firstId=")[1]); - long user_id = Convert.ToInt64(messageUrl.Split("/")[6]); - JObject user = await m_ApiHelper.GetUserInfoById($"/users/list?x[]={user_id.ToString()}"); - string username = string.Empty; - - Log.Debug($"Message ID: {message_id}"); - Log.Debug($"User ID: {user_id}"); - - if (user is null) - { - username = $"Deleted User - {user_id.ToString()}"; - Log.Debug("Content creator not longer exists - ", user_id.ToString()); - } - else if (!string.IsNullOrEmpty(user[user_id.ToString()]["username"].ToString())) - { - username = user[user_id.ToString()]["username"].ToString(); - Log.Debug("Content creator: ", username); - } - - string path = ""; - if (!string.IsNullOrEmpty(Config.DownloadPath)) - { - path = System.IO.Path.Combine(Config.DownloadPath, username); - } - else - { - path = $"__user_data__/sites/OnlyFans/{username}"; - } - - Log.Debug("Download path: ", path); - - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - AnsiConsole.Markup($"[red]Created folder for {username}\n[/]"); - Log.Debug($"Created folder for {username}"); - } - else - { - AnsiConsole.Markup($"[red]Folder for {username} already created\n[/]"); - Log.Debug($"Folder for {username} already created"); - } - - await dBHelper.CreateDB(path); - - var downloadContext = new DownloadContext(Auth, Config, GetCreatorFileNameFormatConfig(Config, username), m_ApiHelper, dBHelper); - - await DownloadPaidMessage(downloadContext, hasSelectedUsersKVP, username, 1, path, message_id); - } - } - else if (hasSelectedUsersKVP.Key && !hasSelectedUsersKVP.Value.ContainsKey("ConfigChanged")) - { - //Iterate over each user in the list of users - foreach (KeyValuePair user in hasSelectedUsersKVP.Value) - { - int paidPostCount = 0; - int postCount = 0; - int archivedCount = 0; - int streamsCount = 0; - int storiesCount = 0; - int highlightsCount = 0; - int messagesCount = 0; - int paidMessagesCount = 0; - AnsiConsole.Markup($"[red]\nScraping Data for {user.Key}\n[/]"); - - Log.Debug($"Scraping Data for {user.Key}"); - - string path = ""; - if (!string.IsNullOrEmpty(Config.DownloadPath)) - { - path = System.IO.Path.Combine(Config.DownloadPath, user.Key); - } - else - { - path = $"__user_data__/sites/OnlyFans/{user.Key}"; - } - - Log.Debug("Download path: ", path); - - await dBHelper.CheckUsername(user, path); - - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - AnsiConsole.Markup($"[red]Created folder for {user.Key}\n[/]"); - Log.Debug($"Created folder for {user.Key}"); - } - else - { - AnsiConsole.Markup($"[red]Folder for {user.Key} already created\n[/]"); - Log.Debug($"Folder for {user.Key} already created"); - } - - await dBHelper.CreateDB(path); - - var downloadContext = new DownloadContext(Auth, Config, GetCreatorFileNameFormatConfig(Config, user.Key), m_ApiHelper, dBHelper); - - if (Config.DownloadAvatarHeaderPhoto) - { - Entities.User? user_info = await m_ApiHelper.GetUserInfo($"/users/{user.Key}"); - if (user_info != null) - { - await downloadContext.DownloadHelper.DownloadAvatarHeader(user_info.avatar, user_info.header, path, user.Key); - } - } - - if (Config.DownloadPaidPosts) - { - paidPostCount = await DownloadPaidPosts(downloadContext, hasSelectedUsersKVP, user, paidPostCount, path); - } - - if (Config.DownloadPosts) - { - postCount = await DownloadFreePosts(downloadContext, hasSelectedUsersKVP, user, postCount, path); - } - - if (Config.DownloadArchived) - { - archivedCount = await DownloadArchived(downloadContext, hasSelectedUsersKVP, user, archivedCount, path); - } - - if (Config.DownloadStreams) - { - streamsCount = await DownloadStreams(downloadContext, hasSelectedUsersKVP, user, streamsCount, path); - } - - if (Config.DownloadStories) - { - storiesCount = await DownloadStories(downloadContext, user, storiesCount, path); - } - - if (Config.DownloadHighlights) - { - highlightsCount = await DownloadHighlights(downloadContext, user, highlightsCount, path); - } - - if (Config.DownloadMessages) - { - messagesCount = await DownloadMessages(downloadContext, hasSelectedUsersKVP, user, messagesCount, path); - } - - if (Config.DownloadPaidMessages) - { - paidMessagesCount = await DownloadPaidMessages(downloadContext, hasSelectedUsersKVP, user, paidMessagesCount, path); - } - - AnsiConsole.Markup("\n"); - AnsiConsole.Write(new BreakdownChart() - .FullSize() - .AddItem("Paid Posts", paidPostCount, Color.Red) - .AddItem("Posts", postCount, Color.Blue) - .AddItem("Archived", archivedCount, Color.Green) - .AddItem("Streams", streamsCount, Color.Purple) - .AddItem("Stories", storiesCount, Color.Yellow) - .AddItem("Highlights", highlightsCount, Color.Orange1) - .AddItem("Messages", messagesCount, Color.LightGreen) - .AddItem("Paid Messages", paidMessagesCount, Color.Aqua)); - AnsiConsole.Markup("\n"); - } - DateTime endTime = DateTime.Now; - TimeSpan totalTime = endTime - startTime; - AnsiConsole.Markup($"[green]Scrape Completed in {totalTime.TotalMinutes:0.00} minutes\n[/]"); - } - else if (hasSelectedUsersKVP.Key && hasSelectedUsersKVP.Value != null && hasSelectedUsersKVP.Value.ContainsKey("ConfigChanged")) - { - continue; - } - else - { - break; - } - } while (!Config.NonInteractiveMode); - } - - private static IFileNameFormatConfig GetCreatorFileNameFormatConfig(Entities.Config config, string userName) - { - FileNameFormatConfig combinedConfig = new FileNameFormatConfig(); - - Func func = (val1, val2) => - { - if (string.IsNullOrEmpty(val1)) - return val2; - else - return val1; - }; - - if(config.CreatorConfigs.ContainsKey(userName)) - { - CreatorConfig creatorConfig = config.CreatorConfigs[userName]; - if(creatorConfig != null) - { - combinedConfig.PaidMessageFileNameFormat = creatorConfig.PaidMessageFileNameFormat; - combinedConfig.PostFileNameFormat = creatorConfig.PostFileNameFormat; - combinedConfig.MessageFileNameFormat = creatorConfig.MessageFileNameFormat; - combinedConfig.PaidPostFileNameFormat = creatorConfig.PaidPostFileNameFormat; - } - } - - combinedConfig.PaidMessageFileNameFormat = func(combinedConfig.PaidMessageFileNameFormat, config.PaidMessageFileNameFormat); - combinedConfig.PostFileNameFormat = func(combinedConfig.PostFileNameFormat, config.PostFileNameFormat); - combinedConfig.MessageFileNameFormat = func(combinedConfig.MessageFileNameFormat, config.MessageFileNameFormat); - combinedConfig.PaidPostFileNameFormat = func(combinedConfig.PaidPostFileNameFormat, config.PaidPostFileNameFormat); - - Log.Debug($"PaidMessageFilenameFormat: {combinedConfig.PaidMessageFileNameFormat}"); - Log.Debug($"PostFileNameFormat: {combinedConfig.PostFileNameFormat}"); - Log.Debug($"MessageFileNameFormat: {combinedConfig.MessageFileNameFormat}"); - Log.Debug($"PaidPostFileNameFormatt: {combinedConfig.PaidPostFileNameFormat}"); - - return combinedConfig; - } - - private static async Task DownloadPaidMessages(IDownloadContext downloadContext, KeyValuePair> hasSelectedUsersKVP, KeyValuePair user, int paidMessagesCount, string path) - { - Log.Debug($"Calling DownloadPaidMessages - {user.Key}"); - - PaidMessageCollection paidMessageCollection = new PaidMessageCollection(); - - await AnsiConsole.Status() - .StartAsync("[red]Getting Paid Messages[/]", async ctx => + if (authService.CurrentAuth == null) + { + AnsiConsole.MarkupLine( + "\n[red]Auth failed. Please try again or use other authentication methods detailed here:[/]\n"); + AnsiConsole.MarkupLine("[link]https://docs.ofdl.tools/config/auth[/]\n"); + if (!configService.CurrentConfig.NonInteractiveMode) + { + Console.WriteLine("\nPress any key to exit."); + Console.ReadKey(); + } + + Environment.Exit(2); + } + } + + AnsiConsole.Markup( + $"[green]Logged In successfully as {(!string.IsNullOrEmpty(validate?.Name) ? validate.Name : "Unknown Name")} {(!string.IsNullOrEmpty(validate?.Username) ? validate.Username : "Unknown Username")}\n[/]"); + + // Main download loop + await DownloadAllData(orchestrationService, configService, startupResult); + } + catch (Exception ex) { - paidMessageCollection = await downloadContext.ApiHelper.GetPaidMessages("/posts/paid/chat", path, user.Key, downloadContext.DownloadConfig!, ctx); - }); - int oldPaidMessagesCount = 0; - int newPaidMessagesCount = 0; - if (paidMessageCollection != null && paidMessageCollection.PaidMessages.Count > 0) - { - AnsiConsole.Markup($"[red]Found {paidMessageCollection.PaidMessages.Count} Media from {paidMessageCollection.PaidMessageObjects.Count} Paid Messages\n[/]"); - Log.Debug($"Found {paidMessageCollection.PaidMessages.Count} Media from {paidMessageCollection.PaidMessageObjects.Count} Paid Messages"); - paidMessagesCount = paidMessageCollection.PaidMessages.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(paidMessageCollection.PaidMessages.Values.ToList()); - } - else - { - totalSize = paidMessagesCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {paidMessageCollection.PaidMessages.Count} Paid Messages[/]", autoStart: false); - Log.Debug($"Downloading {paidMessageCollection.PaidMessages.Count} Paid Messages"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair paidMessageKVP in paidMessageCollection.PaidMessages) - { - bool isNew; - if (paidMessageKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = paidMessageKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string messageId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh != null) - { - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/message/{messageId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - - - Medium? mediaInfo = paidMessageCollection.PaidMessageMedia.FirstOrDefault(m => m.id == paidMessageKVP.Key); - Purchased.List? messageInfo = paidMessageCollection.PaidMessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPurchasedMessageDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: paidMessageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidMessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - - if (isNew) - { - newPaidMessagesCount++; - } - else - { - oldPaidMessagesCount++; - } - } - } - else - { - Medium? mediaInfo = paidMessageCollection.PaidMessageMedia.FirstOrDefault(m => m.id == paidMessageKVP.Key); - Purchased.List messageInfo = paidMessageCollection.PaidMessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPurchasedMedia( - url: paidMessageKVP.Value, - folder: path, - media_id: paidMessageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidMessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - if (isNew) - { - newPaidMessagesCount++; - } - else - { - oldPaidMessagesCount++; - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Paid Messages Already Downloaded: {oldPaidMessagesCount} New Paid Messages Downloaded: {newPaidMessagesCount}[/]\n"); - } - else - { - AnsiConsole.Markup($"[red]Found 0 Paid Messages\n[/]"); - } - - return paidMessagesCount; - } - - private static async Task DownloadMessages(IDownloadContext downloadContext, KeyValuePair> hasSelectedUsersKVP, KeyValuePair user, int messagesCount, string path) - { - Log.Debug($"Calling DownloadMessages - {user.Key}"); - - MessageCollection messages = new MessageCollection(); - - await AnsiConsole.Status() - .StartAsync("[red]Getting Messages[/]", async ctx => - { - messages = await downloadContext.ApiHelper.GetMessages($"/chats/{user.Value}/messages", path, downloadContext.DownloadConfig!, ctx); - }); - int oldMessagesCount = 0; - int newMessagesCount = 0; - if (messages != null && messages.Messages.Count > 0) - { - AnsiConsole.Markup($"[red]Found {messages.Messages.Count} Media from {messages.MessageObjects.Count} Messages\n[/]"); - Log.Debug($"[red]Found {messages.Messages.Count} Media from {messages.MessageObjects.Count} Messages"); - messagesCount = messages.Messages.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(messages.Messages.Values.ToList()); - } - else - { - totalSize = messagesCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {messages.Messages.Count} Messages[/]", autoStart: false); - Log.Debug($"Downloading {messages.Messages.Count} Messages"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair messageKVP in messages.Messages) - { - bool isNew; - if (messageKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = messageKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string messageId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh != null) - { - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/message/{messageId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - Messages.Medium? mediaInfo = messages.MessageMedia.FirstOrDefault(m => m.id == messageKVP.Key); - Messages.List? messageInfo = messages.MessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadMessageDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: messageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.MessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - - - if (isNew) - { - newMessagesCount++; - } - else - { - oldMessagesCount++; - } - } - } - else - { - Messages.Medium? mediaInfo = messages.MessageMedia.FirstOrDefault(m => m.id == messageKVP.Key); - Messages.List? messageInfo = messages.MessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadMessageMedia( - url: messageKVP.Value, - folder: path, - media_id: messageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig!.MessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - - if (isNew) - { - newMessagesCount++; - } - else - { - oldMessagesCount++; - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Messages Already Downloaded: {oldMessagesCount} New Messages Downloaded: {newMessagesCount}[/]\n"); - } - else - { - AnsiConsole.Markup($"[red]Found 0 Messages\n[/]"); - } - - return messagesCount; - } - - private static async Task DownloadHighlights(IDownloadContext downloadContext, KeyValuePair user, int highlightsCount, string path) - { - Log.Debug($"Calling DownloadHighlights - {user.Key}"); - - AnsiConsole.Markup($"[red]Getting Highlights\n[/]"); - Dictionary highlights = await downloadContext.ApiHelper.GetMedia(MediaType.Highlights, $"/users/{user.Value}/stories/highlights", null, path, downloadContext.DownloadConfig!, paid_post_ids); - int oldHighlightsCount = 0; - int newHighlightsCount = 0; - if (highlights != null && highlights.Count > 0) - { - AnsiConsole.Markup($"[red]Found {highlights.Count} Highlights\n[/]"); - Log.Debug($"Found {highlights.Count} Highlights"); - highlightsCount = highlights.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(highlights.Values.ToList()); - } - else - { - totalSize = highlightsCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {highlights.Count} Highlights[/]", autoStart: false); - Log.Debug($"Downloading {highlights.Count} Highlights"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair highlightKVP in highlights) - { - bool isNew = await downloadContext.DownloadHelper.DownloadStoryMedia(highlightKVP.Value, path, highlightKVP.Key, "Stories", task); - if (isNew) - { - newHighlightsCount++; - } - else - { - oldHighlightsCount++; - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Highlights Already Downloaded: {oldHighlightsCount} New Highlights Downloaded: {newHighlightsCount}[/]\n"); - Log.Debug($"Highlights Already Downloaded: {oldHighlightsCount} New Highlights Downloaded: {newHighlightsCount}"); - } - else - { - AnsiConsole.Markup($"[red]Found 0 Highlights\n[/]"); - Log.Debug($"Found 0 Highlights"); - } - - return highlightsCount; - } - - private static async Task DownloadStories(IDownloadContext downloadContext, KeyValuePair user, int storiesCount, string path) - { - Log.Debug($"Calling DownloadStories - {user.Key}"); - - AnsiConsole.Markup($"[red]Getting Stories\n[/]"); - Dictionary stories = await downloadContext.ApiHelper.GetMedia(MediaType.Stories, $"/users/{user.Value}/stories", null, path, downloadContext.DownloadConfig!, paid_post_ids); - int oldStoriesCount = 0; - int newStoriesCount = 0; - if (stories != null && stories.Count > 0) - { - AnsiConsole.Markup($"[red]Found {stories.Count} Stories\n[/]"); - Log.Debug($"Found {stories.Count} Stories"); - storiesCount = stories.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(stories.Values.ToList()); - } - else - { - totalSize = storiesCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {stories.Count} Stories[/]", autoStart: false); - Log.Debug($"Downloading {stories.Count} Stories"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair storyKVP in stories) - { - bool isNew = await downloadContext.DownloadHelper.DownloadStoryMedia(storyKVP.Value, path, storyKVP.Key, "Stories", task); - if (isNew) - { - newStoriesCount++; - } - else - { - oldStoriesCount++; - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Stories Already Downloaded: {oldStoriesCount} New Stories Downloaded: {newStoriesCount}[/]\n"); - Log.Debug($"Stories Already Downloaded: {oldStoriesCount} New Stories Downloaded: {newStoriesCount}"); - } - else - { - AnsiConsole.Markup($"[red]Found 0 Stories\n[/]"); - Log.Debug($"Found 0 Stories"); - } - - return storiesCount; - } - - private static async Task DownloadArchived(IDownloadContext downloadContext, KeyValuePair> hasSelectedUsersKVP, KeyValuePair user, int archivedCount, string path) - { - Log.Debug($"Calling DownloadArchived - {user.Key}"); - - ArchivedCollection archived = new ArchivedCollection(); - - await AnsiConsole.Status() - .StartAsync("[red]Getting Archived Posts[/]", async ctx => - { - archived = await downloadContext.ApiHelper.GetArchived($"/users/{user.Value}/posts", path, downloadContext.DownloadConfig!, ctx); - }); - - int oldArchivedCount = 0; - int newArchivedCount = 0; - if (archived != null && archived.ArchivedPosts.Count > 0) - { - AnsiConsole.Markup($"[red]Found {archived.ArchivedPosts.Count} Media from {archived.ArchivedPostObjects.Count} Archived Posts\n[/]"); - Log.Debug($"Found {archived.ArchivedPosts.Count} Media from {archived.ArchivedPostObjects.Count} Archived Posts"); - archivedCount = archived.ArchivedPosts.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(archived.ArchivedPosts.Values.ToList()); - } - else - { - totalSize = archivedCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {archived.ArchivedPosts.Count} Archived Posts[/]", autoStart: false); - Log.Debug($"Downloading {archived.ArchivedPosts.Count} Archived Posts"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair archivedKVP in archived.ArchivedPosts) - { - bool isNew; - if (archivedKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = archivedKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string postId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh != null) - { - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/post/{postId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - Archived.Medium? mediaInfo = archived.ArchivedPostMedia.FirstOrDefault(m => m.id == archivedKVP.Key); - Archived.List? postInfo = archived.ArchivedPostObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadArchivedPostDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: archivedKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PostFileNameFormat ?? string.Empty, - postInfo: postInfo, - postMedia: mediaInfo, - author: postInfo?.author, - users: hasSelectedUsersKVP.Value); - - if (isNew) - { - newArchivedCount++; - } - else - { - oldArchivedCount++; - } - } - } - else - { - Archived.Medium? mediaInfo = archived.ArchivedPostMedia.FirstOrDefault(m => m.id == archivedKVP.Key); - Archived.List? postInfo = archived.ArchivedPostObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadArchivedMedia( - url: archivedKVP.Value, - folder: path, - media_id: archivedKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PostFileNameFormat ?? string.Empty, - messageInfo: postInfo, - messageMedia: mediaInfo, - author: postInfo?.author, - users: hasSelectedUsersKVP.Value); - - if (isNew) - { - newArchivedCount++; - } - else - { - oldArchivedCount++; - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Archived Posts Already Downloaded: {oldArchivedCount} New Archived Posts Downloaded: {newArchivedCount}[/]\n"); - } - else - { - AnsiConsole.Markup($"[red]Found 0 Archived Posts\n[/]"); - } - - return archivedCount; - } - - private static async Task DownloadFreePosts(IDownloadContext downloadContext, KeyValuePair> hasSelectedUsersKVP, KeyValuePair user, int postCount, string path) - { - Log.Debug($"Calling DownloadFreePosts - {user.Key}"); - - PostCollection posts = new PostCollection(); - - await AnsiConsole.Status() - .StartAsync("[red]Getting Posts (this may take a long time, depending on the number of Posts the creator has)[/]", async ctx => - { - posts = await downloadContext.ApiHelper.GetPosts($"/users/{user.Value}/posts", path, downloadContext.DownloadConfig!, paid_post_ids, ctx); - }); - - int oldPostCount = 0; - int newPostCount = 0; - if (posts == null || posts.Posts.Count <= 0) - { - AnsiConsole.Markup($"[red]Found 0 Posts\n[/]"); - Log.Debug($"Found 0 Posts"); - return 0; - } - - AnsiConsole.Markup($"[red]Found {posts.Posts.Count} Media from {posts.PostObjects.Count} Posts\n[/]"); - Log.Debug($"Found {posts.Posts.Count} Media from {posts.PostObjects.Count} Posts"); - postCount = posts.Posts.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(posts.Posts.Values.ToList()); - } - else - { - totalSize = postCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - var task = ctx.AddTask($"[red]Downloading {posts.Posts.Count} Posts[/]", autoStart: false); - Log.Debug($"Downloading {posts.Posts.Count} Posts"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair postKVP in posts.Posts) - { - bool isNew; - if (postKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = postKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string postId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh == null) - { - continue; - } - - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/post/{postId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - Post.Medium mediaInfo = posts.PostMedia.FirstOrDefault(m => m.id == postKVP.Key); - Post.List postInfo = posts.PostObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPostDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: postKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PostFileNameFormat ?? string.Empty, - postInfo: postInfo, - postMedia: mediaInfo, - author: postInfo?.author, - users: hasSelectedUsersKVP.Value); - if (isNew) - { - newPostCount++; - } - else - { - oldPostCount++; - } - } - else - { - try - { - Post.Medium? mediaInfo = posts.PostMedia.FirstOrDefault(m => (m?.id == postKVP.Key) == true); - Post.List? postInfo = posts.PostObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPostMedia( - url: postKVP.Value, - folder: path, - media_id: postKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PostFileNameFormat ?? string.Empty, - postInfo: postInfo, - postMedia: mediaInfo, - author: postInfo?.author, - users: hasSelectedUsersKVP.Value); - if (isNew) - { - newPostCount++; - } - else - { - oldPostCount++; - } - } - catch - { - Console.WriteLine("Media was null"); - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Posts Already Downloaded: {oldPostCount} New Posts Downloaded: {newPostCount}[/]\n"); - Log.Debug("Posts Already Downloaded: {oldPostCount} New Posts Downloaded: {newPostCount}"); - - return postCount; - } - - private static async Task DownloadPaidPosts(IDownloadContext downloadContext, KeyValuePair> hasSelectedUsersKVP, KeyValuePair user, int paidPostCount, string path) - { - Log.Debug($"Calling DownloadPaidPosts - {user.Key}"); - - PaidPostCollection purchasedPosts = new PaidPostCollection(); - - await AnsiConsole.Status() - .StartAsync("[red]Getting Paid Posts[/]", async ctx => - { - purchasedPosts = await downloadContext.ApiHelper.GetPaidPosts("/posts/paid/post", path, user.Key, downloadContext.DownloadConfig!, paid_post_ids, ctx); - }); - - int oldPaidPostCount = 0; - int newPaidPostCount = 0; - if (purchasedPosts == null || purchasedPosts.PaidPosts.Count <= 0) - { - AnsiConsole.Markup($"[red]Found 0 Paid Posts\n[/]"); - Log.Debug("Found 0 Paid Posts"); - return 0; - } - - AnsiConsole.Markup($"[red]Found {purchasedPosts.PaidPosts.Count} Media from {purchasedPosts.PaidPostObjects.Count} Paid Posts\n[/]"); - Log.Debug($"Found {purchasedPosts.PaidPosts.Count} Media from {purchasedPosts.PaidPostObjects.Count} Paid Posts"); - paidPostCount = purchasedPosts.PaidPosts.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(purchasedPosts.PaidPosts.Values.ToList()); - } - else - { - totalSize = paidPostCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {purchasedPosts.PaidPosts.Count} Paid Posts[/]", autoStart: false); - Log.Debug($"Downloading {purchasedPosts.PaidPosts.Count} Paid Posts"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair purchasedPostKVP in purchasedPosts.PaidPosts) - { - bool isNew; - if (purchasedPostKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = purchasedPostKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string postId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh == null) - { - continue; - } - - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/post/{postId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - Medium? mediaInfo = purchasedPosts.PaidPostMedia.FirstOrDefault(m => m.id == purchasedPostKVP.Key); - Purchased.List? postInfo = purchasedPosts.PaidPostObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPurchasedPostDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: purchasedPostKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidPostFileNameFormat ?? string.Empty, - postInfo: postInfo, - postMedia: mediaInfo, - fromUser: postInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - if (isNew) - { - newPaidPostCount++; - } - else - { - oldPaidPostCount++; - } - } - else - { - Medium mediaInfo = purchasedPosts.PaidPostMedia.FirstOrDefault(m => m.id == purchasedPostKVP.Key); - Purchased.List postInfo = purchasedPosts.PaidPostObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPurchasedPostMedia( - url: purchasedPostKVP.Value, - folder: path, - media_id: purchasedPostKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidPostFileNameFormat ?? string.Empty, - messageInfo: postInfo, - messageMedia: mediaInfo, - fromUser: postInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - if (isNew) - { - newPaidPostCount++; - } - else - { - oldPaidPostCount++; - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Paid Posts Already Downloaded: {oldPaidPostCount} New Paid Posts Downloaded: {newPaidPostCount}[/]\n"); - Log.Debug($"Paid Posts Already Downloaded: {oldPaidPostCount} New Paid Posts Downloaded: {newPaidPostCount}"); - return paidPostCount; - } - - private static async Task DownloadPaidPostsPurchasedTab(IDownloadContext downloadContext, PaidPostCollection purchasedPosts, KeyValuePair user, int paidPostCount, string path, Dictionary users) - { - int oldPaidPostCount = 0; - int newPaidPostCount = 0; - if (purchasedPosts == null || purchasedPosts.PaidPosts.Count <= 0) - { - AnsiConsole.Markup($"[red]Found 0 Paid Posts\n[/]"); - Log.Debug("Found 0 Paid Posts"); - return 0; - } - - AnsiConsole.Markup($"[red]Found {purchasedPosts.PaidPosts.Count} Media from {purchasedPosts.PaidPostObjects.Count} Paid Posts\n[/]"); - Log.Debug($"Found {purchasedPosts.PaidPosts.Count} Media from {purchasedPosts.PaidPostObjects.Count} Paid Posts"); - - paidPostCount = purchasedPosts.PaidPosts.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(purchasedPosts.PaidPosts.Values.ToList()); - } - else - { - totalSize = paidPostCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {purchasedPosts.PaidPosts.Count} Paid Posts[/]", autoStart: false); - Log.Debug($"Downloading {purchasedPosts.PaidPosts.Count} Paid Posts"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair purchasedPostKVP in purchasedPosts.PaidPosts) - { - bool isNew; - if (purchasedPostKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = purchasedPostKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string postId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh == null) - { - continue; - } - - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/post/{postId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - Medium? mediaInfo = purchasedPosts?.PaidPostMedia?.FirstOrDefault(m => m.id == purchasedPostKVP.Key); - Purchased.List? postInfo = mediaInfo != null ? purchasedPosts?.PaidPostObjects?.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true) : null; - - isNew = await downloadContext.DownloadHelper.DownloadPurchasedPostDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: purchasedPostKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidPostFileNameFormat ?? string.Empty, - postInfo: postInfo, - postMedia: mediaInfo, - fromUser: postInfo?.fromUser, - users: users); - if (isNew) - { - newPaidPostCount++; - } - else - { - oldPaidPostCount++; - } - } - else - { - Medium? mediaInfo = purchasedPosts?.PaidPostMedia?.FirstOrDefault(m => m.id == purchasedPostKVP.Key); - Purchased.List? postInfo = mediaInfo != null ? purchasedPosts?.PaidPostObjects?.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true) : null; - - isNew = await downloadContext.DownloadHelper.DownloadPurchasedPostMedia( - url: purchasedPostKVP.Value, - folder: path, - media_id: purchasedPostKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidPostFileNameFormat ?? string.Empty, - messageInfo: postInfo, - messageMedia: mediaInfo, - fromUser: postInfo?.fromUser, - users: users); - if (isNew) - { - newPaidPostCount++; - } - else - { - oldPaidPostCount++; - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Paid Posts Already Downloaded: {oldPaidPostCount} New Paid Posts Downloaded: {newPaidPostCount}[/]\n"); - Log.Debug($"Paid Posts Already Downloaded: {oldPaidPostCount} New Paid Posts Downloaded: {newPaidPostCount}"); - return paidPostCount; - } - - private static async Task DownloadPaidMessagesPurchasedTab(IDownloadContext downloadContext, PaidMessageCollection paidMessageCollection, KeyValuePair user, int paidMessagesCount, string path, Dictionary users) - { - int oldPaidMessagesCount = 0; - int newPaidMessagesCount = 0; - if (paidMessageCollection != null && paidMessageCollection.PaidMessages.Count > 0) - { - AnsiConsole.Markup($"[red]Found {paidMessageCollection.PaidMessages.Count} Media from {paidMessageCollection.PaidMessageObjects.Count} Paid Messages\n[/]"); - Log.Debug($"Found {paidMessageCollection.PaidMessages.Count} Media from {paidMessageCollection.PaidMessageObjects.Count} Paid Messages"); - paidMessagesCount = paidMessageCollection.PaidMessages.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(paidMessageCollection.PaidMessages.Values.ToList()); - } - else - { - totalSize = paidMessagesCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {paidMessageCollection.PaidMessages.Count} Paid Messages[/]", autoStart: false); - Log.Debug($"Downloading {paidMessageCollection.PaidMessages.Count} Paid Messages"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair paidMessageKVP in paidMessageCollection.PaidMessages) - { - bool isNew; - if (paidMessageKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = paidMessageKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string messageId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh != null) - { - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/message/{messageId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - - Medium? mediaInfo = paidMessageCollection.PaidMessageMedia.FirstOrDefault(m => m.id == paidMessageKVP.Key); - Purchased.List? messageInfo = paidMessageCollection.PaidMessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPurchasedMessageDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: paidMessageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidMessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: users); - - if (isNew) - { - newPaidMessagesCount++; - } - else - { - oldPaidMessagesCount++; - } - } - } - else - { - Medium? mediaInfo = paidMessageCollection.PaidMessageMedia.FirstOrDefault(m => m.id == paidMessageKVP.Key); - Purchased.List messageInfo = paidMessageCollection.PaidMessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPurchasedMedia( - url: paidMessageKVP.Value, - folder: path, - media_id: paidMessageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidMessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: users); - if (isNew) - { - newPaidMessagesCount++; - } - else - { - oldPaidMessagesCount++; - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Paid Messages Already Downloaded: {oldPaidMessagesCount} New Paid Messages Downloaded: {newPaidMessagesCount}[/]\n"); - Log.Debug($"[red]Paid Messages Already Downloaded: {oldPaidMessagesCount} New Paid Messages Downloaded: {newPaidMessagesCount}"); - } - else - { - AnsiConsole.Markup($"[red]Found 0 Paid Messages\n[/]"); - Log.Debug($"Found 0 Paid Messages"); - } - - return paidMessagesCount; - } - - private static async Task DownloadStreams(IDownloadContext downloadContext, KeyValuePair> hasSelectedUsersKVP, KeyValuePair user, int streamsCount, string path) - { - Log.Debug($"Calling DownloadStreams - {user.Key}"); - - StreamsCollection streams = new StreamsCollection(); - - await AnsiConsole.Status() - .StartAsync("[red]Getting Streams[/]", async ctx => - { - streams = await downloadContext.ApiHelper.GetStreams($"/users/{user.Value}/posts/streams", path, downloadContext.DownloadConfig!, paid_post_ids, ctx); - }); - - int oldStreamsCount = 0; - int newStreamsCount = 0; - if (streams == null || streams.Streams.Count <= 0) - { - AnsiConsole.Markup($"[red]Found 0 Streams\n[/]"); - Log.Debug($"Found 0 Streams"); - return 0; - } - - AnsiConsole.Markup($"[red]Found {streams.Streams.Count} Media from {streams.StreamObjects.Count} Streams\n[/]"); - Log.Debug($"Found {streams.Streams.Count} Media from {streams.StreamObjects.Count} Streams"); - streamsCount = streams.Streams.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(streams.Streams.Values.ToList()); - } - else - { - totalSize = streamsCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - var task = ctx.AddTask($"[red]Downloading {streams.Streams.Count} Streams[/]", autoStart: false); - Log.Debug($"Downloading {streams.Streams.Count} Streams"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair streamKVP in streams.Streams) - { - bool isNew; - if (streamKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = streamKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string postId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh == null) - { - continue; - } - - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/post/{postId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - Streams.Medium mediaInfo = streams.StreamMedia.FirstOrDefault(m => m.id == streamKVP.Key); - Streams.List streamInfo = streams.StreamObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadStreamsDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: streamKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PostFileNameFormat ?? string.Empty, - streamInfo: streamInfo, - streamMedia: mediaInfo, - author: streamInfo?.author, - users: hasSelectedUsersKVP.Value); - if (isNew) - { - newStreamsCount++; - } - else - { - oldStreamsCount++; - } - } - else - { - try - { - Streams.Medium? mediaInfo = streams.StreamMedia.FirstOrDefault(m => (m?.id == streamKVP.Key) == true); - Streams.List? streamInfo = streams.StreamObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadStreamMedia( - url: streamKVP.Value, - folder: path, - media_id: streamKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PostFileNameFormat ?? string.Empty, - streamInfo: streamInfo, - streamMedia: mediaInfo, - author: streamInfo?.author, - users: hasSelectedUsersKVP.Value); - if (isNew) - { - newStreamsCount++; - } - else - { - oldStreamsCount++; - } - } - catch - { - Console.WriteLine("Media was null"); - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Streams Already Downloaded: {oldStreamsCount} New Streams Downloaded: {newStreamsCount}[/]\n"); - Log.Debug($"Streams Already Downloaded: {oldStreamsCount} New Streams Downloaded: {newStreamsCount}"); - return streamsCount; - } - - private static async Task DownloadPaidMessage(IDownloadContext downloadContext, KeyValuePair> hasSelectedUsersKVP, string username, int paidMessagesCount, string path, long message_id) - { - Log.Debug($"Calling DownloadPaidMessage - {username}"); - - AnsiConsole.Markup($"[red]Getting Paid Message\n[/]"); - - SinglePaidMessageCollection singlePaidMessageCollection = await downloadContext.ApiHelper.GetPaidMessage($"/messages/{message_id.ToString()}", path, downloadContext.DownloadConfig!); - int oldPreviewPaidMessagesCount = 0; - int newPreviewPaidMessagesCount = 0; - int oldPaidMessagesCount = 0; - int newPaidMessagesCount = 0; - if (singlePaidMessageCollection != null && singlePaidMessageCollection.PreviewSingleMessages.Count > 0) - { - AnsiConsole.Markup($"[red]Found {singlePaidMessageCollection.PreviewSingleMessages.Count} Preview Media from {singlePaidMessageCollection.SingleMessageObjects.Count} Paid Messages\n[/]"); - Log.Debug($"Found {singlePaidMessageCollection.PreviewSingleMessages.Count} Preview Media from {singlePaidMessageCollection.SingleMessageObjects.Count} Paid Messages"); - paidMessagesCount = singlePaidMessageCollection.SingleMessages.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) + Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); + Log.Error("Exception caught: {0}\n\nStackTrace: {1}", ex.Message, ex.StackTrace); + if (ex.InnerException != null) { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(singlePaidMessageCollection.PreviewSingleMessages.Values.ToList()); + Console.WriteLine("\nInner Exception:"); + Console.WriteLine("Exception caught: {0}\n\nStackTrace: {1}", ex.InnerException.Message, + ex.InnerException.StackTrace); + Log.Error("Inner Exception: {0}\n\nStackTrace: {1}", ex.InnerException.Message, + ex.InnerException.StackTrace); + } + + if (!configService.CurrentConfig.NonInteractiveMode) + { + Console.WriteLine("\nPress any key to exit."); + Console.ReadKey(); + } + + Environment.Exit(5); + } + } + + private async Task DownloadAllData( + IDownloadOrchestrationService orchestrationService, + IConfigService configService, + StartupResult startupResult) + { + Config config = configService.CurrentConfig; + SpectreDownloadEventHandler eventHandler = new(); + + Log.Debug("Calling DownloadAllData"); + + do + { + DateTime startTime = DateTime.Now; + + UserListResult userListResult = await orchestrationService.GetAvailableUsersAsync(); + Dictionary users = userListResult.Users; + Dictionary lists = userListResult.Lists; + + if (userListResult.IgnoredListError != null) + { + AnsiConsole.Markup($"[red]{Markup.Escape(userListResult.IgnoredListError)}\n[/]"); + } + + KeyValuePair> hasSelectedUsersKVP; + if (config.NonInteractiveMode && config.NonInteractiveModePurchasedTab) + { + hasSelectedUsersKVP = new KeyValuePair>(true, + new Dictionary { { "PurchasedTab", 0 } }); + } + else if (config.NonInteractiveMode && string.IsNullOrEmpty(config.NonInteractiveModeListName)) + { + hasSelectedUsersKVP = new KeyValuePair>(true, users); + } + else if (config.NonInteractiveMode && !string.IsNullOrEmpty(config.NonInteractiveModeListName)) + { + Dictionary selectedUsers = + await orchestrationService.GetUsersForListAsync(config.NonInteractiveModeListName, users, lists); + hasSelectedUsersKVP = new KeyValuePair>(true, selectedUsers); } else { - totalSize = paidMessagesCount; + (bool IsExit, Dictionary? selectedUsers) userSelectionResult = + await HandleUserSelection(users, lists); + + config = configService.CurrentConfig; + hasSelectedUsersKVP = new KeyValuePair>(userSelectionResult.IsExit, + userSelectionResult.selectedUsers ?? []); } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {singlePaidMessageCollection.PreviewSingleMessages.Count} Preview Paid Messages[/]", autoStart: false); - Log.Debug($"Downloading {singlePaidMessageCollection.PreviewSingleMessages.Count} Paid Messages"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair paidMessageKVP in singlePaidMessageCollection.PreviewSingleMessages) - { - bool isNew; - if (paidMessageKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = paidMessageKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string messageId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh != null) - { - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/message/{messageId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - Medium? mediaInfo = singlePaidMessageCollection.PreviewSingleMessageMedia.FirstOrDefault(m => m.id == paidMessageKVP.Key); - SingleMessage? messageInfo = singlePaidMessageCollection.SingleMessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadSingleMessagePreviewDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: paidMessageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidMessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - - if (isNew) - { - newPreviewPaidMessagesCount++; - } - else - { - oldPreviewPaidMessagesCount++; - } - } - } - else - { - Medium? mediaInfo = singlePaidMessageCollection.PreviewSingleMessageMedia.FirstOrDefault(m => m.id == paidMessageKVP.Key); - SingleMessage? messageInfo = singlePaidMessageCollection.SingleMessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadMessagePreviewMedia( - url: paidMessageKVP.Value, - folder: path, - media_id: paidMessageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidMessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - if (isNew) - { - newPreviewPaidMessagesCount++; - } - else - { - oldPreviewPaidMessagesCount++; - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Preview Paid Messages Already Downloaded: {oldPreviewPaidMessagesCount} New Preview Paid Messages Downloaded: {newPreviewPaidMessagesCount}[/]\n"); - Log.Debug($"Preview Paid Messages Already Downloaded: {oldPreviewPaidMessagesCount} New Preview Paid Messages Downloaded: {newPreviewPaidMessagesCount}"); - } - if (singlePaidMessageCollection != null && singlePaidMessageCollection.SingleMessages.Count > 0) - { - AnsiConsole.Markup($"[red]Found {singlePaidMessageCollection.SingleMessages.Count} Media from {singlePaidMessageCollection.SingleMessageObjects.Count} Paid Messages\n[/]"); - Log.Debug($"Found {singlePaidMessageCollection.SingleMessages.Count} Media from {singlePaidMessageCollection.SingleMessageObjects.Count} Paid Messages"); - paidMessagesCount = singlePaidMessageCollection.SingleMessages.Count; - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(singlePaidMessageCollection.SingleMessages.Values.ToList()); - } - else - { - totalSize = paidMessagesCount; - } - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - // Define tasks - var task = ctx.AddTask($"[red]Downloading {singlePaidMessageCollection.SingleMessages.Count} Paid Messages[/]", autoStart: false); - Log.Debug($"Downloading {singlePaidMessageCollection.SingleMessages.Count} Paid Messages"); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair paidMessageKVP in singlePaidMessageCollection.SingleMessages) - { - bool isNew; - if (paidMessageKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = paidMessageKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string messageId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh != null) - { - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/message/{messageId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/message/{messageId}?type=widevine", pssh); - } - - Medium? mediaInfo = singlePaidMessageCollection.SingleMessageMedia.FirstOrDefault(m => m.id == paidMessageKVP.Key); - SingleMessage? messageInfo = singlePaidMessageCollection.SingleMessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadSinglePurchasedMessageDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: paidMessageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidMessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - - if (isNew) - { - newPaidMessagesCount++; - } - else - { - oldPaidMessagesCount++; - } - } - } - else - { - Medium? mediaInfo = singlePaidMessageCollection.SingleMessageMedia.FirstOrDefault(m => m.id == paidMessageKVP.Key); - SingleMessage? messageInfo = singlePaidMessageCollection.SingleMessageObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadSinglePurchasedMedia( - url: paidMessageKVP.Value, - folder: path, - media_id: paidMessageKVP.Key, - api_type: "Messages", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PaidMessageFileNameFormat ?? string.Empty, - messageInfo: messageInfo, - messageMedia: mediaInfo, - fromUser: messageInfo?.fromUser, - users: hasSelectedUsersKVP.Value); - if (isNew) - { - newPaidMessagesCount++; - } - else - { - oldPaidMessagesCount++; - } - } - } - task.StopTask(); - }); - AnsiConsole.Markup($"[red]Paid Messages Already Downloaded: {oldPaidMessagesCount} New Paid Messages Downloaded: {newPaidMessagesCount}[/]\n"); - Log.Debug($"Paid Messages Already Downloaded: {oldPaidMessagesCount} New Paid Messages Downloaded: {newPaidMessagesCount}"); - } - else - { - AnsiConsole.Markup($"[red]Found 0 Paid Messages\n[/]"); - Log.Debug($"Found 0 Paid Messages"); - } - - return paidMessagesCount; - } - - private static async Task DownloadSinglePost(IDownloadContext downloadContext, long post_id, string path, Dictionary users) - { - Log.Debug($"Calling DownloadSinglePost - {post_id.ToString()}"); - - AnsiConsole.Markup($"[red]Getting Post\n[/]"); - SinglePostCollection post = await downloadContext.ApiHelper.GetPost($"/posts/{post_id.ToString()}", path, downloadContext.DownloadConfig!); - if (post == null) - { - AnsiConsole.Markup($"[red]Couldn't find post\n[/]"); - Log.Debug($"Couldn't find post"); - return; - } - - long totalSize = 0; - if (downloadContext.DownloadConfig.ShowScrapeSize) - { - totalSize = await downloadContext.DownloadHelper.CalculateTotalFileSize(post.SinglePosts.Values.ToList()); - } - else - { - totalSize = post.SinglePosts.Count; - } - bool isNew = false; - await AnsiConsole.Progress() - .Columns(GetProgressColumns(downloadContext.DownloadConfig.ShowScrapeSize)) - .StartAsync(async ctx => - { - var task = ctx.AddTask($"[red]Downloading Post[/]", autoStart: false); - task.MaxValue = totalSize; - task.StartTask(); - foreach (KeyValuePair postKVP in post.SinglePosts) - { - if (postKVP.Value.Contains("cdn3.onlyfans.com/dash/files")) - { - string[] messageUrlParsed = postKVP.Value.Split(','); - string mpdURL = messageUrlParsed[0]; - string policy = messageUrlParsed[1]; - string signature = messageUrlParsed[2]; - string kvp = messageUrlParsed[3]; - string mediaId = messageUrlParsed[4]; - string postId = messageUrlParsed[5]; - string? licenseURL = null; - string? pssh = await downloadContext.ApiHelper.GetDRMMPDPSSH(mpdURL, policy, signature, kvp); - if (pssh == null) - { - continue; - } - - DateTime lastModified = await downloadContext.ApiHelper.GetDRMMPDLastModified(mpdURL, policy, signature, kvp); - Dictionary drmHeaders = downloadContext.ApiHelper.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/post/{postId}", "?type=widevine"); - string decryptionKey; - if (clientIdBlobMissing || devicePrivateKeyMissing) - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyOFDL(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - else - { - decryptionKey = await downloadContext.ApiHelper.GetDecryptionKeyCDM(drmHeaders, $"https://onlyfans.com/api2/v2/users/media/{mediaId}/drm/post/{postId}?type=widevine", pssh); - } - SinglePost.Medium mediaInfo = post.SinglePostMedia.FirstOrDefault(m => m.id == postKVP.Key); - SinglePost postInfo = post.SinglePostObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPostDRMVideo( - policy: policy, - signature: signature, - kvp: kvp, - url: mpdURL, - decryptionKey: decryptionKey, - folder: path, - lastModified: lastModified, - media_id: postKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PostFileNameFormat ?? string.Empty, - postInfo: postInfo, - postMedia: mediaInfo, - author: postInfo?.author, - users: users); - } - else - { - try - { - SinglePost.Medium? mediaInfo = post.SinglePostMedia.FirstOrDefault(m => (m?.id == postKVP.Key) == true); - SinglePost? postInfo = post.SinglePostObjects.FirstOrDefault(p => p?.media?.Contains(mediaInfo) == true); - - isNew = await downloadContext.DownloadHelper.DownloadPostMedia( - url: postKVP.Value, - folder: path, - media_id: postKVP.Key, - api_type: "Posts", - task: task, - filenameFormat: downloadContext.FileNameFormatConfig.PostFileNameFormat ?? string.Empty, - postInfo: postInfo, - postMedia: mediaInfo, - author: postInfo?.author, - users: users); - } - catch - { - Console.WriteLine("Media was null"); - } - } - } - task.StopTask(); - }); - if (isNew) - { - AnsiConsole.Markup($"[red]Post {post_id} downloaded\n[/]"); - Log.Debug($"Post {post_id} downloaded"); - } - else - { - AnsiConsole.Markup($"[red]Post {post_id} already downloaded\n[/]"); - Log.Debug($"Post {post_id} already downloaded"); - } - } - - public static async Task<(bool IsExit, Dictionary? selectedUsers, Entities.Config? updatedConfig)> HandleUserSelection(APIHelper apiHelper, Entities.Config currentConfig, Dictionary users, Dictionary lists) - { - bool hasSelectedUsers = false; - Dictionary selectedUsers = new Dictionary(); - - while (!hasSelectedUsers) - { - var mainMenuOptions = GetMainMenuOptions(users, lists); - - var mainMenuSelection = AnsiConsole.Prompt( - new SelectionPrompt() - .Title("[red]Select Accounts to Scrape | Select All = All Accounts | List = Download content from users on List | Custom = Specific Account(s)[/]") - .AddChoices(mainMenuOptions) - ); - - switch (mainMenuSelection) - { - case "[red]Select All[/]": - selectedUsers = users; - hasSelectedUsers = true; - break; - case "[red]List[/]": - while (true) - { - var listSelectionPrompt = new MultiSelectionPrompt(); - listSelectionPrompt.Title = "[red]Select List[/]"; - listSelectionPrompt.PageSize = 10; - listSelectionPrompt.AddChoice("[red]Go Back[/]"); - foreach (string key in lists.Keys.Select(k => $"[red]{k}[/]").ToList()) - { - listSelectionPrompt.AddChoice(key); - } - var listSelection = AnsiConsole.Prompt(listSelectionPrompt); - - if (listSelection.Contains("[red]Go Back[/]")) - { - break; // Go back to the main menu - } - else - { - hasSelectedUsers = true; - List listUsernames = new(); - foreach (var item in listSelection) - { - long listId = lists[item.Replace("[red]", "").Replace("[/]", "")]; - List usernames = await apiHelper.GetListUsers($"/lists/{listId}/users", config); - foreach (string user in usernames) - { - listUsernames.Add(user); - } - } - selectedUsers = users.Where(x => listUsernames.Contains($"{x.Key}")).Distinct().ToDictionary(x => x.Key, x => x.Value); - AnsiConsole.Markup(string.Format("[red]Downloading from List(s): {0}[/]", string.Join(", ", listSelection))); - break; - } - } - break; - case "[red]Custom[/]": - while (true) - { - var selectedNamesPrompt = new MultiSelectionPrompt(); - selectedNamesPrompt.MoreChoicesText("[grey](Move up and down to reveal more choices)[/]"); - selectedNamesPrompt.InstructionsText("[grey](Press to select, to accept)[/]\n[grey](Press A-Z to easily navigate the list)[/]"); - selectedNamesPrompt.Title("[red]Select users[/]"); - selectedNamesPrompt.PageSize(10); - selectedNamesPrompt.AddChoice("[red]Go Back[/]"); - foreach (string key in users.Keys.OrderBy(k => k).Select(k => $"[red]{k}[/]").ToList()) - { - selectedNamesPrompt.AddChoice(key); - } - var userSelection = AnsiConsole.Prompt(selectedNamesPrompt); - if (userSelection.Contains("[red]Go Back[/]")) - { - break; // Go back to the main menu - } - else - { - hasSelectedUsers = true; - selectedUsers = users.Where(x => userSelection.Contains($"[red]{x.Key}[/]")).ToDictionary(x => x.Key, x => x.Value); - break; - } - } - break; - case "[red]Download Single Post[/]": - return (true, new Dictionary { { "SinglePost", 0 } }, currentConfig); - case "[red]Download Single Paid Message[/]": - return (true, new Dictionary { { "SingleMessage", 0 } }, currentConfig); - case "[red]Download Purchased Tab[/]": - return (true, new Dictionary { { "PurchasedTab", 0 } }, currentConfig); - case "[red]Edit config.conf[/]": - while (true) - { - if (currentConfig == null) - currentConfig = new Entities.Config(); - - var choices = new List<(string choice, bool isSelected)> - { - ("[red]Go Back[/]", false) - }; - - foreach(var propInfo in typeof(Entities.Config).GetProperties()) - { - var attr = propInfo.GetCustomAttribute(); - if(attr != null) - { - string itemLabel = $"[red]{propInfo.Name}[/]"; - choices.Add(new(itemLabel, (bool)propInfo.GetValue(currentConfig)!)); - } - } - - MultiSelectionPrompt multiSelectionPrompt = new MultiSelectionPrompt() - .Title("[red]Edit config.conf[/]") - .PageSize(25); - - foreach (var choice in choices) - { - multiSelectionPrompt.AddChoices(choice.choice, (selectionItem) => { if (choice.isSelected) selectionItem.Select(); }); - } - - var configOptions = AnsiConsole.Prompt(multiSelectionPrompt); - - if (configOptions.Contains("[red]Go Back[/]")) - { - break; - } - - bool configChanged = false; - - Entities.Config newConfig = new Entities.Config(); - foreach (var propInfo in typeof(Entities.Config).GetProperties()) - { - var attr = propInfo.GetCustomAttribute(); - if (attr != null) - { - // - // Get the new choice from the selection - // - string itemLabel = $"[red]{propInfo.Name}[/]"; - var newValue = configOptions.Contains(itemLabel); - var oldValue = choices.Where(c => c.choice == itemLabel).Select(c => c.isSelected).First(); - propInfo.SetValue(newConfig, newValue); - - if (newValue != oldValue) - configChanged = true; - } - else - { - // - // Reassign any non toggleable values - // - propInfo.SetValue(newConfig, propInfo.GetValue(currentConfig)); - } - } - - var hoconConfig = new StringBuilder(); - hoconConfig.AppendLine("# Auth"); - hoconConfig.AppendLine("Auth {"); - hoconConfig.AppendLine($" DisableBrowserAuth = {newConfig.DisableBrowserAuth.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - hoconConfig.AppendLine("# External Tools"); - hoconConfig.AppendLine("External {"); - hoconConfig.AppendLine($" FFmpegPath = \"{newConfig.FFmpegPath}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Download Settings"); - hoconConfig.AppendLine("Download {"); - hoconConfig.AppendLine(" Media {"); - hoconConfig.AppendLine($" DownloadAvatarHeaderPhoto = {newConfig.DownloadAvatarHeaderPhoto.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPaidPosts = {newConfig.DownloadPaidPosts.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPosts = {newConfig.DownloadPosts.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadArchived = {newConfig.DownloadArchived.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadStreams = {newConfig.DownloadStreams.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadStories = {newConfig.DownloadStories.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadHighlights = {newConfig.DownloadHighlights.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadMessages = {newConfig.DownloadMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPaidMessages = {newConfig.DownloadPaidMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadImages = {newConfig.DownloadImages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadVideos = {newConfig.DownloadVideos.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadAudios = {newConfig.DownloadAudios.ToString().ToLower()}"); - hoconConfig.AppendLine(" }"); - hoconConfig.AppendLine($" IgnoreOwnMessages = {newConfig.IgnoreOwnMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPostsIncrementally = {newConfig.DownloadPostsIncrementally.ToString().ToLower()}"); - hoconConfig.AppendLine($" BypassContentForCreatorsWhoNoLongerExist = {newConfig.BypassContentForCreatorsWhoNoLongerExist.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadDuplicatedMedia = {newConfig.DownloadDuplicatedMedia.ToString().ToLower()}"); - hoconConfig.AppendLine($" SkipAds = {newConfig.SkipAds.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPath = \"{newConfig.DownloadPath}\""); - hoconConfig.AppendLine($" DownloadOnlySpecificDates = {newConfig.DownloadOnlySpecificDates.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadDateSelection = \"{newConfig.DownloadDateSelection.ToString().ToLower()}\""); - hoconConfig.AppendLine($" CustomDate = \"{newConfig.CustomDate?.ToString("yyyy-MM-dd")}\""); - hoconConfig.AppendLine($" ShowScrapeSize = {newConfig.ShowScrapeSize.ToString().ToLower()}"); - hoconConfig.AppendLine($" DisableTextSanitization = {newConfig.DisableTextSanitization.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadVideoResolution = \"{(newConfig.DownloadVideoResolution == VideoResolution.source ? "source" : newConfig.DownloadVideoResolution.ToString().TrimStart('_'))}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# File Settings"); - hoconConfig.AppendLine("File {"); - hoconConfig.AppendLine($" PaidPostFileNameFormat = \"{newConfig.PaidPostFileNameFormat}\""); - hoconConfig.AppendLine($" PostFileNameFormat = \"{newConfig.PostFileNameFormat}\""); - hoconConfig.AppendLine($" PaidMessageFileNameFormat = \"{newConfig.PaidMessageFileNameFormat}\""); - hoconConfig.AppendLine($" MessageFileNameFormat = \"{newConfig.MessageFileNameFormat}\""); - hoconConfig.AppendLine($" RenameExistingFilesWhenCustomFormatIsSelected = {newConfig.RenameExistingFilesWhenCustomFormatIsSelected.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Creator-Specific Configurations"); - hoconConfig.AppendLine("CreatorConfigs {"); - foreach (var creatorConfig in newConfig.CreatorConfigs) - { - hoconConfig.AppendLine($" \"{creatorConfig.Key}\" {{"); - hoconConfig.AppendLine($" PaidPostFileNameFormat = \"{creatorConfig.Value.PaidPostFileNameFormat}\""); - hoconConfig.AppendLine($" PostFileNameFormat = \"{creatorConfig.Value.PostFileNameFormat}\""); - hoconConfig.AppendLine($" PaidMessageFileNameFormat = \"{creatorConfig.Value.PaidMessageFileNameFormat}\""); - hoconConfig.AppendLine($" MessageFileNameFormat = \"{creatorConfig.Value.MessageFileNameFormat}\""); - hoconConfig.AppendLine(" }"); - } - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Folder Settings"); - hoconConfig.AppendLine("Folder {"); - hoconConfig.AppendLine($" FolderPerPaidPost = {newConfig.FolderPerPaidPost.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerPost = {newConfig.FolderPerPost.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerPaidMessage = {newConfig.FolderPerPaidMessage.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerMessage = {newConfig.FolderPerMessage.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Subscription Settings"); - hoconConfig.AppendLine("Subscriptions {"); - hoconConfig.AppendLine($" IncludeExpiredSubscriptions = {newConfig.IncludeExpiredSubscriptions.ToString().ToLower()}"); - hoconConfig.AppendLine($" IncludeRestrictedSubscriptions = {newConfig.IncludeRestrictedSubscriptions.ToString().ToLower()}"); - hoconConfig.AppendLine($" IgnoredUsersListName = \"{newConfig.IgnoredUsersListName}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Interaction Settings"); - hoconConfig.AppendLine("Interaction {"); - hoconConfig.AppendLine($" NonInteractiveMode = {newConfig.NonInteractiveMode.ToString().ToLower()}"); - hoconConfig.AppendLine($" NonInteractiveModeListName = \"{newConfig.NonInteractiveModeListName}\""); - hoconConfig.AppendLine($" NonInteractiveModePurchasedTab = {newConfig.NonInteractiveModePurchasedTab.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Performance Settings"); - hoconConfig.AppendLine("Performance {"); - hoconConfig.AppendLine($" Timeout = {(newConfig.Timeout.HasValue ? newConfig.Timeout.Value : -1)}"); - hoconConfig.AppendLine($" LimitDownloadRate = {newConfig.LimitDownloadRate.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadLimitInMbPerSec = {newConfig.DownloadLimitInMbPerSec}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Logging/Debug Settings"); - hoconConfig.AppendLine("Logging {"); - hoconConfig.AppendLine($" LoggingLevel = \"{newConfig.LoggingLevel.ToString().ToLower()}\""); - hoconConfig.AppendLine("}"); - - File.WriteAllText("config.conf", hoconConfig.ToString()); - - string newConfigString = JsonConvert.SerializeObject(newConfig, Formatting.Indented); - - Log.Debug($"Config changed:"); - Log.Debug(newConfigString); - - currentConfig = newConfig; - if (configChanged) - { - return (true, new Dictionary { { "ConfigChanged", 0 } }, currentConfig); - } - break; - } - break; - case "[red]Change logging level[/]": - while (true) - { - var choices = new List<(string choice, bool isSelected)> - { - ("[red]Go Back[/]", false) - }; - - foreach (string name in typeof(LoggingLevel).GetEnumNames()) - { - string itemLabel = $"[red]{name}[/]"; - choices.Add(new(itemLabel, name == levelSwitch.MinimumLevel.ToString())); - } - - SelectionPrompt selectionPrompt = new SelectionPrompt() - .Title("[red]Select logging level[/]") - .PageSize(25); - - foreach (var choice in choices) - { - selectionPrompt.AddChoice(choice.choice); - } - - string levelOption = AnsiConsole.Prompt(selectionPrompt); - - if (levelOption.Contains("[red]Go Back[/]")) - { - break; - } - - levelOption = levelOption.Replace("[red]", "").Replace("[/]", ""); - LoggingLevel newLogLevel = (LoggingLevel)Enum.Parse(typeof(LoggingLevel), levelOption, true); - levelSwitch.MinimumLevel = (LogEventLevel)newLogLevel; - - Log.Debug($"Logging level changed to: {levelOption}"); - - bool configChanged = false; - - Entities.Config newConfig = new Entities.Config(); - - newConfig = currentConfig; - - newConfig.LoggingLevel = newLogLevel; - - currentConfig = newConfig; - - // Dump new config in the log file - Log.Debug("Configuration:"); - string configString = JsonConvert.SerializeObject(currentConfig, Formatting.Indented); - Log.Debug(configString); - - var hoconConfig = new StringBuilder(); - hoconConfig.AppendLine("# Auth"); - hoconConfig.AppendLine("Auth {"); - hoconConfig.AppendLine($" DisableBrowserAuth = {newConfig.DisableBrowserAuth.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - hoconConfig.AppendLine("# External Tools"); - hoconConfig.AppendLine("External {"); - hoconConfig.AppendLine($" FFmpegPath = \"{newConfig.FFmpegPath}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Download Settings"); - hoconConfig.AppendLine("Download {"); - hoconConfig.AppendLine(" Media {"); - hoconConfig.AppendLine($" DownloadAvatarHeaderPhoto = {newConfig.DownloadAvatarHeaderPhoto.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPaidPosts = {newConfig.DownloadPaidPosts.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPosts = {newConfig.DownloadPosts.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadArchived = {newConfig.DownloadArchived.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadStreams = {newConfig.DownloadStreams.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadStories = {newConfig.DownloadStories.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadHighlights = {newConfig.DownloadHighlights.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadMessages = {newConfig.DownloadMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPaidMessages = {newConfig.DownloadPaidMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadImages = {newConfig.DownloadImages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadVideos = {newConfig.DownloadVideos.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadAudios = {newConfig.DownloadAudios.ToString().ToLower()}"); - hoconConfig.AppendLine(" }"); - hoconConfig.AppendLine($" IgnoreOwnMessages = {newConfig.IgnoreOwnMessages.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPostsIncrementally = {newConfig.DownloadPostsIncrementally.ToString().ToLower()}"); - hoconConfig.AppendLine($" BypassContentForCreatorsWhoNoLongerExist = {newConfig.BypassContentForCreatorsWhoNoLongerExist.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadDuplicatedMedia = {newConfig.DownloadDuplicatedMedia.ToString().ToLower()}"); - hoconConfig.AppendLine($" SkipAds = {newConfig.SkipAds.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadPath = \"{newConfig.DownloadPath}\""); - hoconConfig.AppendLine($" DownloadOnlySpecificDates = {newConfig.DownloadOnlySpecificDates.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadDateSelection = \"{newConfig.DownloadDateSelection.ToString().ToLower()}\""); - hoconConfig.AppendLine($" CustomDate = \"{newConfig.CustomDate?.ToString("yyyy-MM-dd")}\""); - hoconConfig.AppendLine($" ShowScrapeSize = {newConfig.ShowScrapeSize.ToString().ToLower()}"); - hoconConfig.AppendLine($" DisableTextSanitization = {newConfig.DisableTextSanitization.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadVideoResolution = \"{(newConfig.DownloadVideoResolution == VideoResolution.source ? "source" : newConfig.DownloadVideoResolution.ToString().TrimStart('_'))}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# File Settings"); - hoconConfig.AppendLine("File {"); - hoconConfig.AppendLine($" PaidPostFileNameFormat = \"{newConfig.PaidPostFileNameFormat}\""); - hoconConfig.AppendLine($" PostFileNameFormat = \"{newConfig.PostFileNameFormat}\""); - hoconConfig.AppendLine($" PaidMessageFileNameFormat = \"{newConfig.PaidMessageFileNameFormat}\""); - hoconConfig.AppendLine($" MessageFileNameFormat = \"{newConfig.MessageFileNameFormat}\""); - hoconConfig.AppendLine($" RenameExistingFilesWhenCustomFormatIsSelected = {newConfig.RenameExistingFilesWhenCustomFormatIsSelected.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Creator-Specific Configurations"); - hoconConfig.AppendLine("CreatorConfigs {"); - foreach (var creatorConfig in newConfig.CreatorConfigs) - { - hoconConfig.AppendLine($" \"{creatorConfig.Key}\" {{"); - hoconConfig.AppendLine($" PaidPostFileNameFormat = \"{creatorConfig.Value.PaidPostFileNameFormat}\""); - hoconConfig.AppendLine($" PostFileNameFormat = \"{creatorConfig.Value.PostFileNameFormat}\""); - hoconConfig.AppendLine($" PaidMessageFileNameFormat = \"{creatorConfig.Value.PaidMessageFileNameFormat}\""); - hoconConfig.AppendLine($" MessageFileNameFormat = \"{creatorConfig.Value.MessageFileNameFormat}\""); - hoconConfig.AppendLine(" }"); - } - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Folder Settings"); - hoconConfig.AppendLine("Folder {"); - hoconConfig.AppendLine($" FolderPerPaidPost = {newConfig.FolderPerPaidPost.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerPost = {newConfig.FolderPerPost.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerPaidMessage = {newConfig.FolderPerPaidMessage.ToString().ToLower()}"); - hoconConfig.AppendLine($" FolderPerMessage = {newConfig.FolderPerMessage.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Subscription Settings"); - hoconConfig.AppendLine("Subscriptions {"); - hoconConfig.AppendLine($" IncludeExpiredSubscriptions = {newConfig.IncludeExpiredSubscriptions.ToString().ToLower()}"); - hoconConfig.AppendLine($" IncludeRestrictedSubscriptions = {newConfig.IncludeRestrictedSubscriptions.ToString().ToLower()}"); - hoconConfig.AppendLine($" IgnoredUsersListName = \"{newConfig.IgnoredUsersListName}\""); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Interaction Settings"); - hoconConfig.AppendLine("Interaction {"); - hoconConfig.AppendLine($" NonInteractiveMode = {newConfig.NonInteractiveMode.ToString().ToLower()}"); - hoconConfig.AppendLine($" NonInteractiveModeListName = \"{newConfig.NonInteractiveModeListName}\""); - hoconConfig.AppendLine($" NonInteractiveModePurchasedTab = {newConfig.NonInteractiveModePurchasedTab.ToString().ToLower()}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Performance Settings"); - hoconConfig.AppendLine("Performance {"); - hoconConfig.AppendLine($" Timeout = {(newConfig.Timeout.HasValue ? newConfig.Timeout.Value : -1)}"); - hoconConfig.AppendLine($" LimitDownloadRate = {newConfig.LimitDownloadRate.ToString().ToLower()}"); - hoconConfig.AppendLine($" DownloadLimitInMbPerSec = {newConfig.DownloadLimitInMbPerSec}"); - hoconConfig.AppendLine("}"); - - hoconConfig.AppendLine("# Logging/Debug Settings"); - hoconConfig.AppendLine("Logging {"); - hoconConfig.AppendLine($" LoggingLevel = \"{newConfig.LoggingLevel.ToString().ToLower()}\""); - hoconConfig.AppendLine("}"); - - File.WriteAllText("config.conf", hoconConfig.ToString()); - - if (configChanged) - { - return (true, new Dictionary { { "ConfigChanged", 0 } }, currentConfig); - } - - break; - } - break; - case "[red]Logout and Exit[/]": - if (Directory.Exists("chromium-data")) - { - Log.Information("Deleting chromium-data folder"); - Directory.Delete("chromium-data", true); - } - if (File.Exists("auth.json")) - { - Log.Information("Deleting auth.json"); - File.Delete("auth.json"); - } - return (false, null, currentConfig); // Return false to indicate exit - case "[red]Exit[/]": - return (false, null, currentConfig); // Return false to indicate exit - } - } - - return (true, selectedUsers, currentConfig); // Return true to indicate selected users - } - - public static List GetMainMenuOptions(Dictionary users, Dictionary lists) - { - if (lists.Count > 0) - { - return new List - { - "[red]Select All[/]", - "[red]List[/]", - "[red]Custom[/]", - "[red]Download Single Post[/]", - "[red]Download Single Paid Message[/]", - "[red]Download Purchased Tab[/]", - "[red]Edit config.conf[/]", - "[red]Change logging level[/]", - "[red]Logout and Exit[/]", - "[red]Exit[/]" - }; - } - else - { - return new List - { - "[red]Select All[/]", - "[red]Custom[/]", - "[red]Download Single Post[/]", - "[red]Download Single Paid Message[/]", - "[red]Download Purchased Tab[/]", - "[red]Edit config.conf[/]", - "[red]Change logging level[/]", - "[red]Logout and Exit[/]", - "[red]Exit[/]" - }; - } - } - - static bool ValidateFilePath(string path) - { - char[] invalidChars = System.IO.Path.GetInvalidPathChars(); - char[] foundInvalidChars = path.Where(c => invalidChars.Contains(c)).ToArray(); - - if (foundInvalidChars.Any()) - { - AnsiConsole.Markup($"[red]Invalid characters found in path {path}:[/] {string.Join(", ", foundInvalidChars)}\n"); - return false; - } - - if (!System.IO.File.Exists(path)) - { - if (System.IO.Directory.Exists(path)) - { - AnsiConsole.Markup($"[red]The provided path {path} improperly points to a directory and not a file.[/]\n"); - } - else - { - AnsiConsole.Markup($"[red]The provided path {path} does not exist or is not accessible.[/]\n"); - } - return false; - } - - return true; - } - static ProgressColumn[] GetProgressColumns(bool showScrapeSize) - { - List progressColumns; - if (showScrapeSize) - { - progressColumns = new List() - { - new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn(), new DownloadedColumn(), new RemainingTimeColumn() - }; - } - else - { - progressColumns = new List() - { - new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn() - }; - } - return progressColumns.ToArray(); - } - - public static string? GetFullPath(string filename) - { - if (File.Exists(filename)) - { - return Path.GetFullPath(filename); - } - - var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - foreach (var path in pathEnv.Split(Path.PathSeparator)) - { - var fullPath = Path.Combine(path, filename); - if (File.Exists(fullPath)) - { - return fullPath; - } - } - return null; - } - - public static void ValidateCookieString() - { - string pattern = @"(auth_id=\d+)|(sess=[^;]+)"; - var matches = Regex.Matches(auth.COOKIE, pattern); - - string output = string.Join("; ", matches); - - if (!output.EndsWith(";")) - { - output += ";"; - } - - if(auth.COOKIE.Trim() != output.Trim()) - { - auth.COOKIE = output; - string newAuthString = JsonConvert.SerializeObject(auth, Formatting.Indented); - File.WriteAllText("auth.json", newAuthString); - } - } - - public static void ValidateFileNameFormat(string? format, string settingName) + if (hasSelectedUsersKVP.Key && + hasSelectedUsersKVP.Value.ContainsKey("SinglePost")) + { + await HandleSinglePostDownload(orchestrationService, users, startupResult, eventHandler); + } + else if (hasSelectedUsersKVP.Key && + hasSelectedUsersKVP.Value.ContainsKey("PurchasedTab")) + { + await orchestrationService.DownloadPurchasedTabAsync(users, + startupResult.ClientIdBlobMissing, startupResult.DevicePrivateKeyMissing, eventHandler); + + DateTime endTime = DateTime.Now; + eventHandler.OnScrapeComplete(endTime - startTime); + } + else if (hasSelectedUsersKVP.Key && + hasSelectedUsersKVP.Value.ContainsKey("SingleMessage")) + { + await HandleSingleMessageDownload(orchestrationService, users, startupResult, eventHandler); + } + else if (hasSelectedUsersKVP.Key && + !hasSelectedUsersKVP.Value.ContainsKey("ConfigChanged")) + { + foreach (KeyValuePair user in hasSelectedUsersKVP.Value) + { + string path = orchestrationService.ResolveDownloadPath(user.Key); + Log.Debug($"Download path: {path}"); + + await orchestrationService.DownloadCreatorContentAsync( + user.Key, user.Value, path, users, + startupResult.ClientIdBlobMissing, startupResult.DevicePrivateKeyMissing, + eventHandler); + } + + DateTime endTime = DateTime.Now; + eventHandler.OnScrapeComplete(endTime - startTime); + } + else if (hasSelectedUsersKVP.Key && + hasSelectedUsersKVP.Value.ContainsKey("ConfigChanged")) + { + // Config was changed, loop will re-read + } + else + { + break; + } + } while (!config.NonInteractiveMode); + } + + private async Task HandleSinglePostDownload( + IDownloadOrchestrationService orchestrationService, + Dictionary users, + StartupResult startupResult, + IDownloadEventHandler eventHandler) { - if(!string.IsNullOrEmpty(format) && !format.Contains("{mediaId}", StringComparison.OrdinalIgnoreCase) && !format.Contains("{filename}", StringComparison.OrdinalIgnoreCase)) + AnsiConsole.Markup( + "[red]To find an individual post URL, click on the ... at the top right corner of the post and select 'Copy link to post'.\n\nTo return to the main menu, enter 'back' or 'exit' when prompted for the URL.\n\n[/]"); + string postUrl = AnsiConsole.Prompt( + new TextPrompt("[red]Please enter a post URL: [/]") + .ValidationErrorMessage("[red]Please enter a valid post URL[/]") + .Validate(url => + { + Log.Debug($"Single Post URL: {url}"); + Regex regex = new("https://onlyfans\\.com/[0-9]+/[A-Za-z0-9]+", RegexOptions.IgnoreCase); + if (regex.IsMatch(url)) + { + return ValidationResult.Success(); + } + + if (url == "" || url == "exit" || url == "back") + { + return ValidationResult.Success(); + } + + Log.Error("Post URL invalid"); + return ValidationResult.Error("[red]Please enter a valid post URL[/]"); + })); + + if (postUrl != "" && postUrl != "exit" && postUrl != "back") { - AnsiConsole.Markup($"[red]{settingName} is not unique enough, please make sure you include either '{{mediaId}}' or '{{filename}}' to ensure that files are not overwritten with the same filename.[/]\n"); - AnsiConsole.Markup("[red]Press any key to continue.[/]\n"); - Console.ReadKey(); - Environment.Exit(2); + long postId = Convert.ToInt64(postUrl.Split("/")[3]); + string username = postUrl.Split("/")[4]; + + Log.Debug($"Single Post ID: {postId}"); + Log.Debug($"Single Post Creator: {username}"); + + if (users.ContainsKey(username)) + { + string path = orchestrationService.ResolveDownloadPath(username); + Log.Debug($"Download path: {path}"); + + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + AnsiConsole.Markup($"[red]Created folder for {Markup.Escape(username)}\n[/]"); + Log.Debug($"Created folder for {username}"); + } + else + { + AnsiConsole.Markup($"[red]Folder for {Markup.Escape(username)} already created\n[/]"); + } + + IDbService dbService = serviceProvider.GetRequiredService(); + await dbService.CreateDb(path); + + await orchestrationService.DownloadSinglePostAsync(username, postId, path, users, + startupResult.ClientIdBlobMissing, startupResult.DevicePrivateKeyMissing, eventHandler); + } } } - public static VideoResolution ParseVideoResolution(string value) + private async Task HandleSingleMessageDownload( + IDownloadOrchestrationService orchestrationService, + Dictionary users, + StartupResult startupResult, + IDownloadEventHandler eventHandler) { - if (value.Equals("source", StringComparison.OrdinalIgnoreCase)) - return VideoResolution.source; + AnsiConsole.Markup( + "[red]To find an individual message URL, note that you can only do so for PPV messages that you have unlocked. Go the main OnlyFans timeline, click on the Purchased tab, find the relevant message, click on the ... at the top right corner of the message, and select 'Copy link to message'. For all other messages, you cannot scrape them individually, you must scrape all messages from that creator.\n\nTo return to the main menu, enter 'back' or 'exit' when prompted for the URL.\n\n[/]"); + string messageUrl = AnsiConsole.Prompt( + new TextPrompt("[red]Please enter a message URL: [/]") + .ValidationErrorMessage("[red]Please enter a valid message URL[/]") + .Validate(url => + { + Log.Debug($"Single Paid Message URL: {url}"); + Regex regex = new("https://onlyfans\\.com/my/chats/chat/[0-9]+/\\?firstId=[0-9]+$", + RegexOptions.IgnoreCase); + if (regex.IsMatch(url)) + { + return ValidationResult.Success(); + } - return Enum.Parse("_" + value, ignoreCase: true); + if (url == "" || url == "back" || url == "exit") + { + return ValidationResult.Success(); + } + + Log.Error("Message URL invalid"); + return ValidationResult.Error("[red]Please enter a valid message URL[/]"); + })); + + if (messageUrl != "" && messageUrl != "exit" && messageUrl != "back") + { + long messageId = Convert.ToInt64(messageUrl.Split("?firstId=")[1]); + long userId = Convert.ToInt64(messageUrl.Split("/")[6]); + + Log.Debug($"Message ID: {messageId}"); + Log.Debug($"User ID: {userId}"); + + string? username = await orchestrationService.ResolveUsernameAsync(userId); + Log.Debug("Content creator: {Username}", username); + + if (username == null) + { + Log.Error("Could not resolve username for user ID: {userId}", userId); + AnsiConsole.MarkupLine("[red]Could not resolve username for user ID[/]"); + return; + } + + string path = orchestrationService.ResolveDownloadPath(username); + Log.Debug($"Download path: {path}"); + + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + AnsiConsole.Markup($"[red]Created folder for {Markup.Escape(username)}\n[/]"); + Log.Debug($"Created folder for {username}"); + } + else + { + AnsiConsole.Markup($"[red]Folder for {Markup.Escape(username)} already created\n[/]"); + Log.Debug($"Folder for {username} already created"); + } + + IDbService dbService = serviceProvider.GetRequiredService(); + await dbService.CreateDb(path); + + await orchestrationService.DownloadSinglePaidMessageAsync(username, messageId, path, users, + startupResult.ClientIdBlobMissing, startupResult.DevicePrivateKeyMissing, eventHandler); + } + } + + public async Task<(bool IsExit, Dictionary? selectedUsers)> HandleUserSelection( + Dictionary users, Dictionary lists) + { + IConfigService configService = serviceProvider.GetRequiredService(); + IAuthService authService = serviceProvider.GetRequiredService(); + IApiService apiService = serviceProvider.GetRequiredService(); + ILoggingService loggingService = serviceProvider.GetRequiredService(); + + bool hasSelectedUsers = false; + Dictionary selectedUsers = new(); + Config currentConfig = configService.CurrentConfig; + + while (!hasSelectedUsers) + { + List mainMenuOptions = GetMainMenuOptions(users, lists); + + string mainMenuSelection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title( + "[red]Select Accounts to Scrape | Select All = All Accounts | List = Download content from users on List | Custom = Specific Account(s)[/]") + .AddChoices(mainMenuOptions) + ); + + switch (mainMenuSelection) + { + case "[red]Select All[/]": + selectedUsers = users; + hasSelectedUsers = true; + break; + case "[red]List[/]": + while (true) + { + MultiSelectionPrompt listSelectionPrompt = new(); + listSelectionPrompt.Title = "[red]Select List[/]"; + listSelectionPrompt.PageSize = 10; + listSelectionPrompt.AddChoice("[red]Go Back[/]"); + foreach (string key in lists.Keys.Select(k => $"[red]{k}[/]").ToList()) + { + listSelectionPrompt.AddChoice(key); + } + + List listSelection = AnsiConsole.Prompt(listSelectionPrompt); + + if (listSelection.Contains("[red]Go Back[/]")) + { + break; + } + + hasSelectedUsers = true; + List listUsernames = new(); + foreach (string item in listSelection) + { + long listId = lists[item.Replace("[red]", "").Replace("[/]", "")]; + List usernames = await apiService.GetListUsers($"/lists/{listId}/users") ?? []; + foreach (string user in usernames) + { + listUsernames.Add(user); + } + } + + selectedUsers = users.Where(x => listUsernames.Contains($"{x.Key}")) + .ToDictionary(x => x.Key, x => x.Value); + AnsiConsole.Markup(string.Format("[red]Downloading from List(s): {0}[/]", + string.Join(", ", listSelection))); + break; + } + + break; + case "[red]Custom[/]": + while (true) + { + MultiSelectionPrompt selectedNamesPrompt = new(); + selectedNamesPrompt.MoreChoicesText("[grey](Move up and down to reveal more choices)[/]"); + selectedNamesPrompt.InstructionsText( + "[grey](Press to select, to accept)[/]\n[grey](Press A-Z to easily navigate the list)[/]"); + selectedNamesPrompt.Title("[red]Select users[/]"); + selectedNamesPrompt.PageSize(10); + selectedNamesPrompt.AddChoice("[red]Go Back[/]"); + foreach (string key in users.Keys.OrderBy(k => k).Select(k => $"[red]{k}[/]").ToList()) + { + selectedNamesPrompt.AddChoice(key); + } + + List userSelection = AnsiConsole.Prompt(selectedNamesPrompt); + if (userSelection.Contains("[red]Go Back[/]")) + { + break; + } + + hasSelectedUsers = true; + selectedUsers = users.Where(x => userSelection.Contains($"[red]{x.Key}[/]")) + .ToDictionary(x => x.Key, x => x.Value); + break; + } + + break; + case "[red]Download Single Post[/]": + return (true, new Dictionary { { "SinglePost", 0 } }); + case "[red]Download Single Paid Message[/]": + return (true, new Dictionary { { "SingleMessage", 0 } }); + case "[red]Download Purchased Tab[/]": + return (true, new Dictionary { { "PurchasedTab", 0 } }); + case "[red]Edit config.conf[/]": + while (true) + { + List<(string Name, bool Value)> toggleableProps = configService.GetToggleableProperties(); + List<(string choice, bool isSelected)> choices = new() { ("[red]Go Back[/]", false) }; + foreach ((string Name, bool Value) prop in toggleableProps) + { + choices.Add(($"[red]{prop.Name}[/]", prop.Value)); + } + + MultiSelectionPrompt multiSelectionPrompt = new MultiSelectionPrompt() + .Title("[red]Edit config.conf[/]") + .PageSize(25); + + foreach ((string choice, bool isSelected) choice in choices) + { + multiSelectionPrompt.AddChoices(choice.choice, selectionItem => + { + if (choice.isSelected) + { + selectionItem.Select(); + } + }); + } + + List configOptions = AnsiConsole.Prompt(multiSelectionPrompt); + + if (configOptions.Contains("[red]Go Back[/]")) + { + break; + } + + // Extract plain names from selections + List selectedNames = configOptions + .Select(o => o.Replace("[red]", "").Replace("[/]", "")) + .ToList(); + + bool configChanged = configService.ApplyToggleableSelections(selectedNames); + await configService.SaveConfigurationAsync(); + currentConfig = configService.CurrentConfig; + + if (configChanged) + { + return (true, new Dictionary { { "ConfigChanged", 0 } }); + } + + break; + } + + break; + case "[red]Change logging level[/]": + while (true) + { + List<(string choice, bool isSelected)> choices = [("[red]Go Back[/]", false)]; + + foreach (string name in typeof(LoggingLevel).GetEnumNames()) + { + string itemLabel = $"[red]{name}[/]"; + choices.Add(new ValueTuple(itemLabel, + name == loggingService.GetCurrentLoggingLevel().ToString())); + } + + SelectionPrompt selectionPrompt = new SelectionPrompt() + .Title("[red]Select logging level[/]") + .PageSize(25); + + foreach ((string choice, bool isSelected) choice in choices) + { + selectionPrompt.AddChoice(choice.choice); + } + + string levelOption = AnsiConsole.Prompt(selectionPrompt); + + if (levelOption.Contains("[red]Go Back[/]")) + { + break; + } + + levelOption = levelOption.Replace("[red]", "").Replace("[/]", ""); + LoggingLevel newLogLevel = + (LoggingLevel)Enum.Parse(typeof(LoggingLevel), levelOption, true); + + Log.Debug($"Logging level changed to: {levelOption}"); + + Config newConfig = currentConfig; + newConfig.LoggingLevel = newLogLevel; + currentConfig = newConfig; + + configService.UpdateConfig(newConfig); + await configService.SaveConfigurationAsync(); + + break; + } + + break; + case "[red]Logout and Exit[/]": + authService.Logout(); + return (false, null); + case "[red]Exit[/]": + return (false, null); + } + } + + return (true, selectedUsers); + } + + public static List GetMainMenuOptions(Dictionary users, Dictionary lists) + { + if (lists.Count > 0) + { + return new List + { + "[red]Select All[/]", + "[red]List[/]", + "[red]Custom[/]", + "[red]Download Single Post[/]", + "[red]Download Single Paid Message[/]", + "[red]Download Purchased Tab[/]", + "[red]Edit config.conf[/]", + "[red]Change logging level[/]", + "[red]Logout and Exit[/]", + "[red]Exit[/]" + }; + } + + return new List + { + "[red]Select All[/]", + "[red]Custom[/]", + "[red]Download Single Post[/]", + "[red]Download Single Paid Message[/]", + "[red]Download Purchased Tab[/]", + "[red]Edit config.conf[/]", + "[red]Change logging level[/]", + "[red]Logout and Exit[/]", + "[red]Exit[/]" + }; + } + + private async Task HandleAuthFlow(IAuthService authService, IConfigService configService) + { + if (await authService.LoadFromFileAsync()) + { + AnsiConsole.Markup("[green]auth.json located successfully!\n[/]"); + } + 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) + { + AnsiConsole.MarkupLine( + "\n[red]auth.json has invalid JSON syntax. The file can be generated automatically when OF-DL is run in the standard, interactive mode.[/]\n"); + AnsiConsole.MarkupLine( + "[red]You may also want to try using the browser extension which is documented here:[/]\n"); + AnsiConsole.MarkupLine("[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); + Environment.Exit(2); + } + + if (!configService.CurrentConfig.DisableBrowserAuth) + { + await LoadAuthFromBrowser(); + } + else + { + ShowAuthMissingError(configService.CurrentConfig.NonInteractiveMode); + } + } + else + { + if (configService.CurrentConfig.NonInteractiveMode) + { + ShowAuthMissingError(configService.CurrentConfig.NonInteractiveMode); + } + else if (!configService.CurrentConfig.DisableBrowserAuth) + { + await LoadAuthFromBrowser(); + } + else + { + ShowAuthMissingError(configService.CurrentConfig.NonInteractiveMode); + } + } + } + + private static void ShowAuthMissingError(bool nonInteractiveMode) + { + AnsiConsole.MarkupLine( + "\n[red]auth.json is missing. The file can be generated automatically when OF-DL is run in the standard, interactive mode.[/]\n"); + AnsiConsole.MarkupLine( + "[red]You may also want to try using the browser extension which is documented here:[/]\n"); + AnsiConsole.MarkupLine("[link]https://docs.ofdl.tools/config/auth/#legacy-methods[/]"); + + if (!nonInteractiveMode) + { + AnsiConsole.MarkupLine("[red]Press any key to exit.[/]"); + Console.ReadKey(); + } + + Environment.Exit(2); + } + + private static void DisplayVersionResult(VersionCheckResult result) + { + if (result.TimedOut) + { + AnsiConsole.Markup("[yellow]Version check timed out after 30 seconds.\n[/]"); + return; + } + + if (result.CheckFailed) + { + AnsiConsole.Markup("[yellow]Failed to verify that OF-DL is up-to-date.\n[/]"); + return; + } + + if (result.LocalVersion == null || result.LatestVersion == null) + { + // Debug mode or no version info + AnsiConsole.Markup("[yellow]Running in Debug/Local mode. Version check skipped.\n[/]"); + return; + } + + if (result.IsUpToDate) + { + AnsiConsole.Markup("[green]You are running OF-DL version " + + $"{result.LocalVersion.Major}.{result.LocalVersion.Minor}.{result.LocalVersion.Build}\n[/]"); + AnsiConsole.Markup("[green]Latest Release version: " + + $"{result.LatestVersion.Major}.{result.LatestVersion.Minor}.{result.LatestVersion.Build}\n[/]"); + } + else + { + AnsiConsole.Markup("[red]You are running OF-DL version " + + $"{result.LocalVersion.Major}.{result.LocalVersion.Minor}.{result.LocalVersion.Build}\n[/]"); + AnsiConsole.Markup("[red]Please update to the current release, " + + $"{result.LatestVersion.Major}.{result.LatestVersion.Minor}.{result.LatestVersion.Build}: [link=https://git.ofdl.tools/sim0n00ps/OF-DL/releases]https://git.ofdl.tools/sim0n00ps/OF-DL/releases[/]\n[/]"); + } + } + + private static void DisplayStartupResult(StartupResult result) + { + // OS + if (result.IsWindowsVersionValid && result.OsVersionString != null && + Environment.OSVersion.Platform == PlatformID.Win32NT) + { + AnsiConsole.Markup("[green]Valid version of Windows found.\n[/]"); + } + + // FFmpeg + if (result.FfmpegFound) + { + if (result.FfmpegPathAutoDetected && result.FfmpegPath != null) + { + AnsiConsole.Markup( + $"[green]FFmpeg located successfully. Path auto-detected: {Markup.Escape(result.FfmpegPath)}\n[/]"); + } + else + { + AnsiConsole.Markup("[green]FFmpeg located successfully\n[/]"); + } + + if (result.FfmpegVersion != null) + { + AnsiConsole.Markup($"[green]ffmpeg version detected as {Markup.Escape(result.FfmpegVersion)}[/]\n"); + } + else + { + AnsiConsole.Markup("[yellow]ffmpeg version could not be parsed[/]\n"); + } + } + + // Widevine + if (!result.ClientIdBlobMissing) + { + AnsiConsole.Markup("[green]device_client_id_blob located successfully![/]\n"); + } + + if (!result.DevicePrivateKeyMissing) + { + AnsiConsole.Markup("[green]device_private_key located successfully![/]\n"); + } + + if (result.ClientIdBlobMissing || result.DevicePrivateKeyMissing) + { + AnsiConsole.Markup( + "[yellow]device_client_id_blob and/or device_private_key missing, https://ofdl.tools/ or https://cdrm-project.com/ will be used instead for DRM protected videos\n[/]"); + } + } + + private static void DisplayRulesJsonResult(StartupResult result, IConfigService configService) + { + if (result.RulesJsonExists) + { + if (result.RulesJsonValid) + { + AnsiConsole.Markup("[green]rules.json located successfully!\n[/]"); + } + else + { + AnsiConsole.MarkupLine("\n[red]rules.json is not valid, check your JSON syntax![/]\n"); + AnsiConsole.MarkupLine("[red]Please ensure you are using the latest version of the software.[/]\n"); + Log.Error("rules.json processing failed: {Error}", result.RulesJsonError); + + if (!configService.CurrentConfig.NonInteractiveMode) + { + AnsiConsole.MarkupLine("[red]Press any key to exit.[/]"); + Console.ReadKey(); + } + + Environment.Exit(2); + } + } } } diff --git a/OF DL/References/Spectre.Console.deps.json b/OF DL/References/Spectre.Console.deps.json index 5470288..3b1ab90 100644 --- a/OF DL/References/Spectre.Console.deps.json +++ b/OF DL/References/Spectre.Console.deps.json @@ -1,112 +1,112 @@ { - "runtimeTarget": { - "name": ".NETCoreApp,Version=v7.0", - "signature": "" - }, - "compilationOptions": {}, - "targets": { - ".NETCoreApp,Version=v7.0": { - "Spectre.Console/0.0.0-preview.0": { - "dependencies": { - "Microsoft.SourceLink.GitHub": "1.1.1", - "MinVer": "4.2.0", - "Roslynator.Analyzers": "4.1.2", - "StyleCop.Analyzers": "1.2.0-beta.435", - "System.Memory": "4.5.5", - "Wcwidth.Sources": "1.0.0" + "runtimeTarget": { + "name": ".NETCoreApp,Version=v7.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v7.0": { + "Spectre.Console/0.0.0-preview.0": { + "dependencies": { + "Microsoft.SourceLink.GitHub": "1.1.1", + "MinVer": "4.2.0", + "Roslynator.Analyzers": "4.1.2", + "StyleCop.Analyzers": "1.2.0-beta.435", + "System.Memory": "4.5.5", + "Wcwidth.Sources": "1.0.0" + }, + "runtime": { + "Spectre.Console.dll": {} + } + }, + "Microsoft.Build.Tasks.Git/1.1.1": {}, + "Microsoft.SourceLink.Common/1.1.1": {}, + "Microsoft.SourceLink.GitHub/1.1.1": { + "dependencies": { + "Microsoft.Build.Tasks.Git": "1.1.1", + "Microsoft.SourceLink.Common": "1.1.1" + } + }, + "MinVer/4.2.0": {}, + "Roslynator.Analyzers/4.1.2": {}, + "StyleCop.Analyzers/1.2.0-beta.435": { + "dependencies": { + "StyleCop.Analyzers.Unstable": "1.2.0.435" + } + }, + "StyleCop.Analyzers.Unstable/1.2.0.435": {}, + "System.Memory/4.5.5": {}, + "Wcwidth.Sources/1.0.0": {} + } + }, + "libraries": { + "Spectre.Console/0.0.0-preview.0": { + "type": "project", + "serviceable": false, + "sha512": "" }, - "runtime": { - "Spectre.Console.dll": {} + "Microsoft.Build.Tasks.Git/1.1.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==", + "path": "microsoft.build.tasks.git/1.1.1", + "hashPath": "microsoft.build.tasks.git.1.1.1.nupkg.sha512" + }, + "Microsoft.SourceLink.Common/1.1.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==", + "path": "microsoft.sourcelink.common/1.1.1", + "hashPath": "microsoft.sourcelink.common.1.1.1.nupkg.sha512" + }, + "Microsoft.SourceLink.GitHub/1.1.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "path": "microsoft.sourcelink.github/1.1.1", + "hashPath": "microsoft.sourcelink.github.1.1.1.nupkg.sha512" + }, + "MinVer/4.2.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Po4tv+sri1jsaebQ8F6+yD5ru9Gas5mR111F6HR2ULqwflvjjZXMstpeOc1GHMJeQa3g4E3b8MX8K2cShkuUAg==", + "path": "minver/4.2.0", + "hashPath": "minver.4.2.0.nupkg.sha512" + }, + "Roslynator.Analyzers/4.1.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-bNl3GRSBFjJymYnwq/IRDD9MOSZz9VKdGk9RsN0MWIXoSRnVKQv84f6s9nLE13y20lZgMZKlDqGw2uInBH4JgA==", + "path": "roslynator.analyzers/4.1.2", + "hashPath": "roslynator.analyzers.4.1.2.nupkg.sha512" + }, + "StyleCop.Analyzers/1.2.0-beta.435": { + "type": "package", + "serviceable": true, + "sha512": "sha512-TADk7vdGXtfTnYCV7GyleaaRTQjfoSfZXprQrVMm7cSJtJbFc1QIbWPyLvrgrfGdfHbGmUPvaN4ODKNxg2jgPQ==", + "path": "stylecop.analyzers/1.2.0-beta.435", + "hashPath": "stylecop.analyzers.1.2.0-beta.435.nupkg.sha512" + }, + "StyleCop.Analyzers.Unstable/1.2.0.435": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ouwPWZxbOV3SmCZxIRqHvljkSzkCyi1tDoMzQtDb/bRP8ctASV/iRJr+A2Gdj0QLaLmWnqTWDrH82/iP+X80Lg==", + "path": "stylecop.analyzers.unstable/1.2.0.435", + "hashPath": "stylecop.analyzers.unstable.1.2.0.435.nupkg.sha512" + }, + "System.Memory/4.5.5": { + "type": "package", + "serviceable": true, + "sha512": "sha512-XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "path": "system.memory/4.5.5", + "hashPath": "system.memory.4.5.5.nupkg.sha512" + }, + "Wcwidth.Sources/1.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-86tmwfGXRz7GJQXBnoTFoMvqSqd6irfkEkRzQFR54W/nweaR8cUvzY8x++z+B/+eUPSuqD2Ah1iPJHgthy4pzg==", + "path": "wcwidth.sources/1.0.0", + "hashPath": "wcwidth.sources.1.0.0.nupkg.sha512" } - }, - "Microsoft.Build.Tasks.Git/1.1.1": {}, - "Microsoft.SourceLink.Common/1.1.1": {}, - "Microsoft.SourceLink.GitHub/1.1.1": { - "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" - } - }, - "MinVer/4.2.0": {}, - "Roslynator.Analyzers/4.1.2": {}, - "StyleCop.Analyzers/1.2.0-beta.435": { - "dependencies": { - "StyleCop.Analyzers.Unstable": "1.2.0.435" - } - }, - "StyleCop.Analyzers.Unstable/1.2.0.435": {}, - "System.Memory/4.5.5": {}, - "Wcwidth.Sources/1.0.0": {} } - }, - "libraries": { - "Spectre.Console/0.0.0-preview.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Microsoft.Build.Tasks.Git/1.1.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==", - "path": "microsoft.build.tasks.git/1.1.1", - "hashPath": "microsoft.build.tasks.git.1.1.1.nupkg.sha512" - }, - "Microsoft.SourceLink.Common/1.1.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==", - "path": "microsoft.sourcelink.common/1.1.1", - "hashPath": "microsoft.sourcelink.common.1.1.1.nupkg.sha512" - }, - "Microsoft.SourceLink.GitHub/1.1.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", - "path": "microsoft.sourcelink.github/1.1.1", - "hashPath": "microsoft.sourcelink.github.1.1.1.nupkg.sha512" - }, - "MinVer/4.2.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Po4tv+sri1jsaebQ8F6+yD5ru9Gas5mR111F6HR2ULqwflvjjZXMstpeOc1GHMJeQa3g4E3b8MX8K2cShkuUAg==", - "path": "minver/4.2.0", - "hashPath": "minver.4.2.0.nupkg.sha512" - }, - "Roslynator.Analyzers/4.1.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-bNl3GRSBFjJymYnwq/IRDD9MOSZz9VKdGk9RsN0MWIXoSRnVKQv84f6s9nLE13y20lZgMZKlDqGw2uInBH4JgA==", - "path": "roslynator.analyzers/4.1.2", - "hashPath": "roslynator.analyzers.4.1.2.nupkg.sha512" - }, - "StyleCop.Analyzers/1.2.0-beta.435": { - "type": "package", - "serviceable": true, - "sha512": "sha512-TADk7vdGXtfTnYCV7GyleaaRTQjfoSfZXprQrVMm7cSJtJbFc1QIbWPyLvrgrfGdfHbGmUPvaN4ODKNxg2jgPQ==", - "path": "stylecop.analyzers/1.2.0-beta.435", - "hashPath": "stylecop.analyzers.1.2.0-beta.435.nupkg.sha512" - }, - "StyleCop.Analyzers.Unstable/1.2.0.435": { - "type": "package", - "serviceable": true, - "sha512": "sha512-ouwPWZxbOV3SmCZxIRqHvljkSzkCyi1tDoMzQtDb/bRP8ctASV/iRJr+A2Gdj0QLaLmWnqTWDrH82/iP+X80Lg==", - "path": "stylecop.analyzers.unstable/1.2.0.435", - "hashPath": "stylecop.analyzers.unstable.1.2.0.435.nupkg.sha512" - }, - "System.Memory/4.5.5": { - "type": "package", - "serviceable": true, - "sha512": "sha512-XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", - "path": "system.memory/4.5.5", - "hashPath": "system.memory.4.5.5.nupkg.sha512" - }, - "Wcwidth.Sources/1.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-86tmwfGXRz7GJQXBnoTFoMvqSqd6irfkEkRzQFR54W/nweaR8cUvzY8x++z+B/+eUPSuqD2Ah1iPJHgthy4pzg==", - "path": "wcwidth.sources/1.0.0", - "hashPath": "wcwidth.sources.1.0.0.nupkg.sha512" - } - } -} \ No newline at end of file +} diff --git a/OF DL/Utils.cs b/OF DL/Utils.cs deleted file mode 100644 index 86ce297..0000000 --- a/OF DL/Utils.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Diagnostics; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; - -namespace WidevineClient -{ - class Utils - { - public static double EvaluateEquation(string equation, int decimals = 3) - { - var dataTable = new DataTable(); - return Math.Round((double)dataTable.Compute(equation, ""), decimals); - } - - public static string RunCommand(string command, string args) - { - Process p = new Process(); - p.StartInfo.UseShellExecute = false; - p.StartInfo.RedirectStandardOutput = true; - p.StartInfo.FileName = command; - p.StartInfo.Arguments = args; - p.StartInfo.CreateNoWindow = true; - p.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; - p.Start(); - string output = p.StandardOutput.ReadToEnd(); - p.WaitForExit(); - return output; - } - - public static int RunCommandCode(string command, string args) - { - Process p = new Process(); - p.StartInfo.UseShellExecute = false; - p.StartInfo.RedirectStandardOutput = false; - p.StartInfo.FileName = command; - p.StartInfo.Arguments = args; - p.Start(); - p.WaitForExit(); - return p.ExitCode; - } - - public static byte[] Xor(byte[] a, byte[] b) - { - byte[] x = new byte[Math.Min(a.Length, b.Length)]; - - for (int i = 0; i < x.Length; i++) - { - x[i] = (byte)(a[i] ^ b[i]); - } - - return x; - } - - public static string GenerateRandomId() - { - return BytesToHex(RandomBytes(3)).ToLower(); - } - - public static byte[] RandomBytes(int length) - { - var bytes = new byte[length]; - new Random().NextBytes(bytes); - return bytes; - } - - public static string[] GetElementsInnerTextByAttribute(string html, string element, string attribute) - { - List content = new List(); - - foreach (string line in html.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None)) - { - if (line.Contains("<" + element) && line.Contains(attribute)) - { - string contentPart = line.Substring(0, line.LastIndexOf("<")); - if (contentPart.EndsWith(">")) - contentPart = contentPart[..^1]; - - contentPart = contentPart[(contentPart.LastIndexOf(">") + 1)..]; - - if (contentPart.Contains("<")) - contentPart = contentPart[..contentPart.IndexOf("<")]; - - content.Add(contentPart); - } - } - return content.ToArray(); - } - - public static string BytesToHex(byte[] data) - { - return BitConverter.ToString(data).Replace("-", ""); - } - public static byte[] HexToBytes(string hex) - { - hex = hex.Trim(); - byte[] bytes = new byte[hex.Length / 2]; - - for (int i = 0; i < hex.Length; i += 2) - bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); - - return bytes; - } - - public static bool IsBase64Encoded(string str) - { - try - { - byte[] data = Convert.FromBase64String(str); - return true; - } - catch - { - return false; - } - } - - public static string Base64Pad(string base64) - { - if (base64.Length % 4 != 0) - { - base64 = base64.PadRight(base64.Length + (4 - (base64.Length % 4)), '='); - } - return base64; - } - public static string Base64ToString(string base64) - { - return Encoding.UTF8.GetString(Convert.FromBase64String(base64)); - } - public static string StringToBase64(string str) - { - return Convert.ToBase64String(Encoding.UTF8.GetBytes(str)); - } - - public static void TitleProgress(long read, long length) - { - long readMB = read / 1024 / 1024; - long lengthMB = length / 1024 / 1024; - Console.Title = $"{readMB}/{lengthMB}MB"; - } - - public static void TitleProgressNoConversion(long read, long length) - { - Console.Title = $"{read}/{length}MB"; - } - - public static string Version() - { - return System.Reflection.Assembly.GetCallingAssembly().GetName().Version.ToString(); - } - - public static string? RemoveInvalidFileNameChars(string? fileName) - { - return string.IsNullOrEmpty(fileName) ? fileName : string.Concat(fileName.Split(Path.GetInvalidFileNameChars())); - } - - public static List CalculateFolderMD5(string folder) - { - List md5Hashes = new List(); - if (Directory.Exists(folder)) - { - string[] files = Directory.GetFiles(folder); - - foreach (string file in files) - { - md5Hashes.Add(CalculateMD5(file)); - } - } - - return md5Hashes; - } - - public static string CalculateMD5(string filePath) - { - using (var md5 = MD5.Create()) - { - using (var stream = File.OpenRead(filePath)) - { - byte[] hash = md5.ComputeHash(stream); - return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); - } - } - } - } -} diff --git a/OF DL/Utils/XmlUtils.cs b/OF DL/Utils/XmlUtils.cs deleted file mode 100644 index 55a68c6..0000000 --- a/OF DL/Utils/XmlUtils.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Xml.Linq; - -namespace OF_DL.Utils -{ - internal static class XmlUtils - { - // When true, return original text without parsing/stripping. - public static bool Passthrough { get; set; } = false; - - public static string EvaluateInnerText(string xmlValue) - { - if (Passthrough) - { - return xmlValue ?? string.Empty; - } - - try - { - var parsedText = XElement.Parse($"{xmlValue}"); - return parsedText.Value; - } - catch - { } - - return string.Empty; - } - } -} diff --git a/OF DL/Widevine/CDM.cs b/OF DL/Widevine/CDM.cs deleted file mode 100644 index bfb2841..0000000 --- a/OF DL/Widevine/CDM.cs +++ /dev/null @@ -1,581 +0,0 @@ -using ProtoBuf; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using WidevineClient.Crypto; - -namespace WidevineClient.Widevine -{ - public class CDM - { - static Dictionary Devices { get; } = new Dictionary() - { - [Constants.DEVICE_NAME] = new CDMDevice(Constants.DEVICE_NAME, null, null, null) - }; - static Dictionary Sessions { get; set; } = new Dictionary(); - - static byte[] CheckPSSH(string psshB64) - { - byte[] systemID = new byte[] { 237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237 }; - - if (psshB64.Length % 4 != 0) - { - psshB64 = psshB64.PadRight(psshB64.Length + (4 - (psshB64.Length % 4)), '='); - } - - byte[] pssh = Convert.FromBase64String(psshB64); - - if (pssh.Length < 30) - return pssh; - - if (!pssh[12..28].SequenceEqual(systemID)) - { - List newPssh = new List() { 0, 0, 0 }; - newPssh.Add((byte)(32 + pssh.Length)); - newPssh.AddRange(Encoding.UTF8.GetBytes("pssh")); - newPssh.AddRange(new byte[] { 0, 0, 0, 0 }); - newPssh.AddRange(systemID); - newPssh.AddRange(new byte[] { 0, 0, 0, 0 }); - newPssh[31] = (byte)(pssh.Length); - newPssh.AddRange(pssh); - - return newPssh.ToArray(); - } - else - { - return pssh; - } - } - - public static string OpenSession(string initDataB64, string deviceName, bool offline = false, bool raw = false) - { - byte[] initData = CheckPSSH(initDataB64); - - var device = Devices[deviceName]; - - byte[] sessionId = new byte[16]; - - if (device.IsAndroid) - { - string randHex = ""; - - Random rand = new Random(); - string choice = "ABCDEF0123456789"; - for (int i = 0; i < 16; i++) - randHex += choice[rand.Next(16)]; - - string counter = "01"; - string rest = "00000000000000"; - sessionId = Encoding.ASCII.GetBytes(randHex + counter + rest); - } - else - { - Random rand = new Random(); - rand.NextBytes(sessionId); - } - - Session session; - dynamic parsedInitData = ParseInitData(initData); - - if (parsedInitData != null) - { - session = new Session(sessionId, parsedInitData, device, offline); - } - else if (raw) - { - session = new Session(sessionId, initData, device, offline); - } - else - { - return null; - } - - Sessions.Add(Utils.BytesToHex(sessionId), session); - - return Utils.BytesToHex(sessionId); - } - - static WidevineCencHeader ParseInitData(byte[] initData) - { - WidevineCencHeader cencHeader; - - try - { - cencHeader = Serializer.Deserialize(new MemoryStream(initData[32..])); - } - catch - { - try - { - //needed for HBO Max - - PSSHBox psshBox = PSSHBox.FromByteArray(initData); - cencHeader = Serializer.Deserialize(new MemoryStream(psshBox.Data)); - } - catch - { - //Logger.Verbose("Unable to parse, unsupported init data format"); - return null; - } - } - - return cencHeader; - } - - public static bool CloseSession(string sessionId) - { - //Logger.Debug($"CloseSession(session_id={Utils.BytesToHex(sessionId)})"); - //Logger.Verbose("Closing CDM session"); - - if (Sessions.ContainsKey(sessionId)) - { - Sessions.Remove(sessionId); - //Logger.Verbose("CDM session closed"); - return true; - } - else - { - //Logger.Info($"Session {sessionId} not found"); - return false; - } - } - - public static bool SetServiceCertificate(string sessionId, byte[] certData) - { - //Logger.Debug($"SetServiceCertificate(sessionId={Utils.BytesToHex(sessionId)}, cert={certB64})"); - //Logger.Verbose($"Setting service certificate"); - - if (!Sessions.ContainsKey(sessionId)) - { - //Logger.Error("Session ID doesn't exist"); - return false; - } - - SignedMessage signedMessage = new SignedMessage(); - - try - { - signedMessage = Serializer.Deserialize(new MemoryStream(certData)); - } - catch - { - //Logger.Warn("Failed to parse cert as SignedMessage"); - } - - SignedDeviceCertificate serviceCertificate; - try - { - try - { - //Logger.Debug("Service cert provided as signedmessage"); - serviceCertificate = Serializer.Deserialize(new MemoryStream(signedMessage.Msg)); - } - catch - { - //Logger.Debug("Service cert provided as signeddevicecertificate"); - serviceCertificate = Serializer.Deserialize(new MemoryStream(certData)); - } - } - catch - { - //Logger.Error("Failed to parse service certificate"); - return false; - } - - Sessions[sessionId].ServiceCertificate = serviceCertificate; - Sessions[sessionId].PrivacyMode = true; - - return true; - } - - public static byte[] GetLicenseRequest(string sessionId) - { - //Logger.Debug($"GetLicenseRequest(sessionId={Utils.BytesToHex(sessionId)})"); - //Logger.Verbose($"Getting license request"); - - if (!Sessions.ContainsKey(sessionId)) - { - //Logger.Error("Session ID doesn't exist"); - return null; - } - - var session = Sessions[sessionId]; - - //Logger.Debug("Building license request"); - - dynamic licenseRequest; - - if (session.InitData is WidevineCencHeader) - { - licenseRequest = new SignedLicenseRequest - { - Type = SignedLicenseRequest.MessageType.LicenseRequest, - Msg = new LicenseRequest - { - Type = LicenseRequest.RequestType.New, - KeyControlNonce = 1093602366, - ProtocolVersion = ProtocolVersion.Current, - ContentId = new LicenseRequest.ContentIdentification - { - CencId = new LicenseRequest.ContentIdentification.Cenc - { - LicenseType = session.Offline ? LicenseType.Offline : LicenseType.Default, - RequestId = session.SessionId, - Pssh = session.InitData - } - } - } - }; - } - else - { - licenseRequest = new SignedLicenseRequestRaw - { - Type = SignedLicenseRequestRaw.MessageType.LicenseRequest, - Msg = new LicenseRequestRaw - { - Type = LicenseRequestRaw.RequestType.New, - KeyControlNonce = 1093602366, - ProtocolVersion = ProtocolVersion.Current, - ContentId = new LicenseRequestRaw.ContentIdentification - { - CencId = new LicenseRequestRaw.ContentIdentification.Cenc - { - LicenseType = session.Offline ? LicenseType.Offline : LicenseType.Default, - RequestId = session.SessionId, - Pssh = session.InitData - } - } - } - }; - } - - if (session.PrivacyMode) - { - //Logger.Debug("Privacy mode & serivce certificate loaded, encrypting client id"); - - EncryptedClientIdentification encryptedClientIdProto = new EncryptedClientIdentification(); - - //Logger.Debug("Unencrypted client id " + Utils.SerializeToString(clientId)); - - using var memoryStream = new MemoryStream(); - Serializer.Serialize(memoryStream, session.Device.ClientID); - byte[] data = Padding.AddPKCS7Padding(memoryStream.ToArray(), 16); - - using AesCryptoServiceProvider aesProvider = new AesCryptoServiceProvider - { - BlockSize = 128, - Padding = PaddingMode.PKCS7, - Mode = CipherMode.CBC - }; - aesProvider.GenerateKey(); - aesProvider.GenerateIV(); - - using MemoryStream mstream = new MemoryStream(); - using CryptoStream cryptoStream = new CryptoStream(mstream, aesProvider.CreateEncryptor(aesProvider.Key, aesProvider.IV), CryptoStreamMode.Write); - cryptoStream.Write(data, 0, data.Length); - encryptedClientIdProto.EncryptedClientId = mstream.ToArray(); - - using RSACryptoServiceProvider RSA = new RSACryptoServiceProvider(); - RSA.ImportRSAPublicKey(session.ServiceCertificate.DeviceCertificate.PublicKey, out int bytesRead); - encryptedClientIdProto.EncryptedPrivacyKey = RSA.Encrypt(aesProvider.Key, RSAEncryptionPadding.OaepSHA1); - encryptedClientIdProto.EncryptedClientIdIv = aesProvider.IV; - encryptedClientIdProto.ServiceId = Encoding.UTF8.GetString(session.ServiceCertificate.DeviceCertificate.ServiceId); - encryptedClientIdProto.ServiceCertificateSerialNumber = session.ServiceCertificate.DeviceCertificate.SerialNumber; - - licenseRequest.Msg.EncryptedClientId = encryptedClientIdProto; - } - else - { - licenseRequest.Msg.ClientId = session.Device.ClientID; - } - - //Logger.Debug("Signing license request"); - - using (var memoryStream = new MemoryStream()) - { - Serializer.Serialize(memoryStream, licenseRequest.Msg); - byte[] data = memoryStream.ToArray(); - session.LicenseRequest = data; - - licenseRequest.Signature = session.Device.Sign(data); - } - - //Logger.Verbose("License request created"); - - byte[] requestBytes; - using (var memoryStream = new MemoryStream()) - { - Serializer.Serialize(memoryStream, licenseRequest); - requestBytes = memoryStream.ToArray(); - } - - Sessions[sessionId] = session; - - //Logger.Debug($"license request b64: {Convert.ToBase64String(requestBytes)}"); - return requestBytes; - } - - public static void ProvideLicense(string sessionId, byte[] license) - { - //Logger.Debug($"ProvideLicense(sessionId={Utils.BytesToHex(sessionId)}, licenseB64={licenseB64})"); - //Logger.Verbose("Decrypting provided license"); - - if (!Sessions.ContainsKey(sessionId)) - { - throw new Exception("Session ID doesn't exist"); - } - - var session = Sessions[sessionId]; - - if (session.LicenseRequest == null) - { - throw new Exception("Generate a license request first"); - } - - SignedLicense signedLicense; - try - { - signedLicense = Serializer.Deserialize(new MemoryStream(license)); - } - catch - { - throw new Exception("Unable to parse license"); - } - - //Logger.Debug("License: " + Utils.SerializeToString(signedLicense)); - - session.License = signedLicense; - - //Logger.Debug($"Deriving keys from session key"); - - try - { - var sessionKey = session.Device.Decrypt(session.License.SessionKey); - - if (sessionKey.Length != 16) - { - throw new Exception("Unable to decrypt session key"); - } - - session.SessionKey = sessionKey; - } - catch - { - throw new Exception("Unable to decrypt session key"); - } - - //Logger.Debug("Session key: " + Utils.BytesToHex(session.SessionKey)); - - session.DerivedKeys = DeriveKeys(session.LicenseRequest, session.SessionKey); - - //Logger.Debug("Verifying license signature"); - - byte[] licenseBytes; - using (var memoryStream = new MemoryStream()) - { - Serializer.Serialize(memoryStream, signedLicense.Msg); - licenseBytes = memoryStream.ToArray(); - } - byte[] hmacHash = CryptoUtils.GetHMACSHA256Digest(licenseBytes, session.DerivedKeys.Auth1); - - if (!hmacHash.SequenceEqual(signedLicense.Signature)) - { - throw new Exception("License signature mismatch"); - } - - foreach (License.KeyContainer key in signedLicense.Msg.Keys) - { - string type = key.Type.ToString(); - - if (type == "Signing") - continue; - - byte[] keyId; - byte[] encryptedKey = key.Key; - byte[] iv = key.Iv; - keyId = key.Id; - if (keyId == null) - { - keyId = Encoding.ASCII.GetBytes(key.Type.ToString()); - } - - byte[] decryptedKey; - - using MemoryStream mstream = new MemoryStream(); - using AesCryptoServiceProvider aesProvider = new AesCryptoServiceProvider - { - Mode = CipherMode.CBC, - Padding = PaddingMode.PKCS7 - }; - using CryptoStream cryptoStream = new CryptoStream(mstream, aesProvider.CreateDecryptor(session.DerivedKeys.Enc, iv), CryptoStreamMode.Write); - cryptoStream.Write(encryptedKey, 0, encryptedKey.Length); - decryptedKey = mstream.ToArray(); - - List permissions = new List(); - if (type == "OperatorSession") - { - foreach (PropertyInfo perm in key._OperatorSessionKeyPermissions.GetType().GetProperties()) - { - if ((uint)perm.GetValue(key._OperatorSessionKeyPermissions) == 1) - { - permissions.Add(perm.Name); - } - } - } - session.ContentKeys.Add(new ContentKey - { - KeyID = keyId, - Type = type, - Bytes = decryptedKey, - Permissions = permissions - }); - } - - //Logger.Debug($"Key count: {session.Keys.Count}"); - - Sessions[sessionId] = session; - - //Logger.Verbose("Decrypted all keys"); - } - - public static DerivedKeys DeriveKeys(byte[] message, byte[] key) - { - byte[] encKeyBase = Encoding.UTF8.GetBytes("ENCRYPTION").Concat(new byte[] { 0x0, }).Concat(message).Concat(new byte[] { 0x0, 0x0, 0x0, 0x80 }).ToArray(); - byte[] authKeyBase = Encoding.UTF8.GetBytes("AUTHENTICATION").Concat(new byte[] { 0x0, }).Concat(message).Concat(new byte[] { 0x0, 0x0, 0x2, 0x0 }).ToArray(); - - byte[] encKey = new byte[] { 0x01 }.Concat(encKeyBase).ToArray(); - byte[] authKey1 = new byte[] { 0x01 }.Concat(authKeyBase).ToArray(); - byte[] authKey2 = new byte[] { 0x02 }.Concat(authKeyBase).ToArray(); - byte[] authKey3 = new byte[] { 0x03 }.Concat(authKeyBase).ToArray(); - byte[] authKey4 = new byte[] { 0x04 }.Concat(authKeyBase).ToArray(); - - byte[] encCmacKey = CryptoUtils.GetCMACDigest(encKey, key); - byte[] authCmacKey1 = CryptoUtils.GetCMACDigest(authKey1, key); - byte[] authCmacKey2 = CryptoUtils.GetCMACDigest(authKey2, key); - byte[] authCmacKey3 = CryptoUtils.GetCMACDigest(authKey3, key); - byte[] authCmacKey4 = CryptoUtils.GetCMACDigest(authKey4, key); - - byte[] authCmacCombined1 = authCmacKey1.Concat(authCmacKey2).ToArray(); - byte[] authCmacCombined2 = authCmacKey3.Concat(authCmacKey4).ToArray(); - - return new DerivedKeys - { - Auth1 = authCmacCombined1, - Auth2 = authCmacCombined2, - Enc = encCmacKey - }; - } - - public static List GetKeys(string sessionId) - { - if (Sessions.ContainsKey(sessionId)) - return Sessions[sessionId].ContentKeys; - else - { - throw new Exception("Session not found"); - } - } - } -} - - - -/* - public static List ProvideLicense(string requestB64, string licenseB64) - { - byte[] licenseRequest; - - var request = Serializer.Deserialize(new MemoryStream(Convert.FromBase64String(requestB64))); - - using (var ms = new MemoryStream()) - { - Serializer.Serialize(ms, request.Msg); - licenseRequest = ms.ToArray(); - } - - SignedLicense signedLicense; - try - { - signedLicense = Serializer.Deserialize(new MemoryStream(Convert.FromBase64String(licenseB64))); - } - catch - { - return null; - } - - byte[] sessionKey; - try - { - - sessionKey = Controllers.Adapter.OaepDecrypt(Convert.ToBase64String(signedLicense.SessionKey)); - - if (sessionKey.Length != 16) - { - return null; - } - } - catch - { - return null; - } - - byte[] encKeyBase = Encoding.UTF8.GetBytes("ENCRYPTION").Concat(new byte[] { 0x0, }).Concat(licenseRequest).Concat(new byte[] { 0x0, 0x0, 0x0, 0x80 }).ToArray(); - - byte[] encKey = new byte[] { 0x01 }.Concat(encKeyBase).ToArray(); - - byte[] encCmacKey = GetCmacDigest(encKey, sessionKey); - - byte[] encryptionKey = encCmacKey; - - List keys = new List(); - - foreach (License.KeyContainer key in signedLicense.Msg.Keys) - { - string type = key.Type.ToString(); - if (type == "Signing") - { - continue; - } - - byte[] keyId; - byte[] encryptedKey = key.Key; - byte[] iv = key.Iv; - keyId = key.Id; - if (keyId == null) - { - keyId = Encoding.ASCII.GetBytes(key.Type.ToString()); - } - - byte[] decryptedKey; - - using MemoryStream mstream = new MemoryStream(); - using AesCryptoServiceProvider aesProvider = new AesCryptoServiceProvider - { - Mode = CipherMode.CBC, - Padding = PaddingMode.PKCS7 - }; - using CryptoStream cryptoStream = new CryptoStream(mstream, aesProvider.CreateDecryptor(encryptionKey, iv), CryptoStreamMode.Write); - cryptoStream.Write(encryptedKey, 0, encryptedKey.Length); - decryptedKey = mstream.ToArray(); - - List permissions = new List(); - if (type == "OPERATOR_SESSION") - { - foreach (FieldInfo perm in key._OperatorSessionKeyPermissions.GetType().GetFields()) - { - if ((uint)perm.GetValue(key._OperatorSessionKeyPermissions) == 1) - { - permissions.Add(perm.Name); - } - } - } - keys.Add(BitConverter.ToString(keyId).Replace("-","").ToLower() + ":" + BitConverter.ToString(decryptedKey).Replace("-", "").ToLower()); - } - - return keys; - }*/ diff --git a/OF DL/Widevine/CDMDevice.cs b/OF DL/Widevine/CDMDevice.cs deleted file mode 100644 index 4259511..0000000 --- a/OF DL/Widevine/CDMDevice.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Org.BouncyCastle.Crypto; -using Org.BouncyCastle.Crypto.Digests; -using Org.BouncyCastle.Crypto.Encodings; -using Org.BouncyCastle.Crypto.Engines; -using Org.BouncyCastle.Crypto.Signers; -using Org.BouncyCastle.OpenSsl; -using ProtoBuf; -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; - -namespace WidevineClient.Widevine -{ - public class CDMDevice - { - public string DeviceName { get; set; } - public ClientIdentification ClientID { get; set; } - AsymmetricCipherKeyPair DeviceKeys { get; set; } - - public virtual bool IsAndroid { get; set; } = true; - - public CDMDevice(string deviceName, byte[] clientIdBlobBytes = null, byte[] privateKeyBytes = null, byte[] vmpBytes = null) - { - DeviceName = deviceName; - - string privateKeyPath = Path.Join(Constants.DEVICES_FOLDER, deviceName, "device_private_key"); - string vmpPath = Path.Join(Constants.DEVICES_FOLDER, deviceName, "device_vmp_blob"); - - if (clientIdBlobBytes == null) - { - string clientIDPath = Path.Join(Constants.DEVICES_FOLDER, deviceName, "device_client_id_blob"); - - if (!File.Exists(clientIDPath)) - throw new Exception("No client id blob found"); - - clientIdBlobBytes = File.ReadAllBytes(clientIDPath); - } - - ClientID = Serializer.Deserialize(new MemoryStream(clientIdBlobBytes)); - - if (privateKeyBytes != null) - { - using var reader = new StringReader(Encoding.UTF8.GetString(privateKeyBytes)); - DeviceKeys = (AsymmetricCipherKeyPair)new PemReader(reader).ReadObject(); - } - else if (File.Exists(privateKeyPath)) - { - using var reader = File.OpenText(privateKeyPath); - DeviceKeys = (AsymmetricCipherKeyPair)new PemReader(reader).ReadObject(); - } - - if (vmpBytes != null) - { - var vmp = Serializer.Deserialize(new MemoryStream(vmpBytes)); - ClientID.FileHashes = vmp; - } - else if (File.Exists(vmpPath)) - { - var vmp = Serializer.Deserialize(new MemoryStream(File.ReadAllBytes(vmpPath))); - ClientID.FileHashes = vmp; - } - } - - public virtual byte[] Decrypt(byte[] data) - { - OaepEncoding eng = new OaepEncoding(new RsaEngine()); - eng.Init(false, DeviceKeys.Private); - - int length = data.Length; - int blockSize = eng.GetInputBlockSize(); - - List plainText = new List(); - - for (int chunkPosition = 0; chunkPosition < length; chunkPosition += blockSize) - { - int chunkSize = Math.Min(blockSize, length - chunkPosition); - plainText.AddRange(eng.ProcessBlock(data, chunkPosition, chunkSize)); - } - - return plainText.ToArray(); - } - - public virtual byte[] Sign(byte[] data) - { - PssSigner eng = new PssSigner(new RsaEngine(), new Sha1Digest()); - - eng.Init(true, DeviceKeys.Private); - eng.BlockUpdate(data, 0, data.Length); - return eng.GenerateSignature(); - } - } -} diff --git a/OF DL/Widevine/Constants.cs b/OF DL/Widevine/Constants.cs deleted file mode 100644 index a206c2f..0000000 --- a/OF DL/Widevine/Constants.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace WidevineClient.Widevine -{ - public class Constants - { - public static string WORKING_FOLDER { get; set; } = System.IO.Path.GetFullPath(System.IO.Path.Join(System.IO.Directory.GetCurrentDirectory(), "cdm")); - public static string DEVICES_FOLDER { get; set; } = System.IO.Path.GetFullPath(System.IO.Path.Join(WORKING_FOLDER, "devices")); - public static string DEVICE_NAME { get; set; } = "chrome_1610"; - } -} diff --git a/OF DL/Widevine/ContentKey.cs b/OF DL/Widevine/ContentKey.cs deleted file mode 100644 index f10b9be..0000000 --- a/OF DL/Widevine/ContentKey.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using System.Text.Json.Serialization; - -namespace WidevineClient.Widevine -{ - [Serializable] - public class ContentKey - { - [JsonPropertyName("key_id")] - public byte[] KeyID { get; set; } - - [JsonPropertyName("type")] - public string Type { get; set; } - - [JsonPropertyName("bytes")] - public byte[] Bytes { get; set; } - - [NotMapped] - [JsonPropertyName("permissions")] - public List Permissions { - get - { - return PermissionsString.Split(",").ToList(); - } - set - { - PermissionsString = string.Join(",", value); - } - } - - [JsonIgnore] - public string PermissionsString { get; set; } - - public override string ToString() - { - return $"{BitConverter.ToString(KeyID).Replace("-", "").ToLower()}:{BitConverter.ToString(Bytes).Replace("-", "").ToLower()}"; - } - } -} diff --git a/OF DL/Widevine/DerivedKeys.cs b/OF DL/Widevine/DerivedKeys.cs deleted file mode 100644 index 27b95be..0000000 --- a/OF DL/Widevine/DerivedKeys.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace WidevineClient.Widevine -{ - public class DerivedKeys - { - public byte[] Auth1 { get; set; } - public byte[] Auth2 { get; set; } - public byte[] Enc { get; set; } - } -} diff --git a/OF DL/Widevine/PSSHBox.cs b/OF DL/Widevine/PSSHBox.cs deleted file mode 100644 index c735366..0000000 --- a/OF DL/Widevine/PSSHBox.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace WidevineClient.Widevine -{ - class PSSHBox - { - static readonly byte[] PSSH_HEADER = new byte[] { 0x70, 0x73, 0x73, 0x68 }; - - public List KIDs { get; set; } = new List(); - public byte[] Data { get; set; } - - PSSHBox(List kids, byte[] data) - { - KIDs = kids; - Data = data; - } - - public static PSSHBox FromByteArray(byte[] psshbox) - { - using var stream = new System.IO.MemoryStream(psshbox); - - stream.Seek(4, System.IO.SeekOrigin.Current); - byte[] header = new byte[4]; - stream.Read(header, 0, 4); - - if (!header.SequenceEqual(PSSH_HEADER)) - throw new Exception("Not a pssh box"); - - stream.Seek(20, System.IO.SeekOrigin.Current); - byte[] kidCountBytes = new byte[4]; - stream.Read(kidCountBytes, 0, 4); - - if (BitConverter.IsLittleEndian) - Array.Reverse(kidCountBytes); - uint kidCount = BitConverter.ToUInt32(kidCountBytes); - - List kids = new List(); - for (int i = 0; i < kidCount; i++) - { - byte[] kid = new byte[16]; - stream.Read(kid); - kids.Add(kid); - } - - byte[] dataLengthBytes = new byte[4]; - stream.Read(dataLengthBytes); - - if (BitConverter.IsLittleEndian) - Array.Reverse(dataLengthBytes); - uint dataLength = BitConverter.ToUInt32(dataLengthBytes); - - if (dataLength == 0) - return new PSSHBox(kids, null); - - byte[] data = new byte[dataLength]; - stream.Read(data); - - return new PSSHBox(kids, data); - } - } -} diff --git a/OF DL/Widevine/Session.cs b/OF DL/Widevine/Session.cs deleted file mode 100644 index 51cd618..0000000 --- a/OF DL/Widevine/Session.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Collections.Generic; - -namespace WidevineClient.Widevine -{ - class Session - { - public byte[] SessionId { get; set; } - public dynamic InitData { get; set; } - public bool Offline { get; set; } - public CDMDevice Device { get; set; } - public byte[] SessionKey { get; set; } - public DerivedKeys DerivedKeys { get; set; } - public byte[] LicenseRequest { get; set; } - public SignedLicense License { get; set; } - public SignedDeviceCertificate ServiceCertificate { get; set; } - public bool PrivacyMode { get; set; } - public List ContentKeys { get; set; } = new List(); - - public Session(byte[] sessionId, dynamic initData, CDMDevice device, bool offline) - { - SessionId = sessionId; - InitData = initData; - Offline = offline; - Device = device; - } - } -} \ No newline at end of file diff --git a/OF DL/rules.json b/OF DL/rules.json index 9cb9785..d524235 100644 --- a/OF DL/rules.json +++ b/OF DL/rules.json @@ -4,5 +4,38 @@ "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 ] + "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 + ] }