diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38d9787..97d24a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,13 @@ on: push: branches: ["master"] tags: ["v*"] + pull_request: branches: ["master"] jobs: build-and-test: - name: Build & Test + name: Build & Unit Tests runs-on: ubuntu-latest steps: @@ -29,7 +30,7 @@ jobs: - name: Build run: dotnet build TiktokExplode.NET.slnx --no-restore --configuration Release - - name: Run tests + - name: Run Unit Tests run: dotnet test TiktokExplode.Tests/TiktokExplode.Tests.csproj --no-build --configuration Release --logger "console;verbosity=normal" publish: diff --git a/README.md b/README.md index 56e51ad..e895282 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ The library follows **Clean Architecture**: the domain layer (`TiktokExplode`) h - Fetch full video metadata: author, stats, duration, language, location, bitrates, and more - Download videos without watermark or with watermark - Download the static cover image (JPEG) or the animated cover (WebP) +- **Search TikTok by keyword** — streams results lazily via `IAsyncEnumerable` (videos and carousels) +- **Carousel/slideshow support** — `Carousel` entity with full image collection metadata - Progress reporting during download via `IProgress` - Automatic WAF/bot-detection retry with configurable backoff - **Strategy pattern** — choose between Playwright (reliable) or HTTP-only (lightweight) page fetching @@ -115,31 +117,82 @@ await using var client = new TiktokClient(myFetcher, new TikTokOptions()); #### Methods -| Method | Returns | Description | -| ---------------------------------------------------- | ------------ | --------------------------------- | -| `GetVideoAsync(string url, CancellationToken)` | `Video` | Fetches full video metadata | -| `DownloadAsync(Video, CancellationToken)` | `StreamInfo` | Downloads video without watermark | -| `DownloadWatermarkedAsync(Video, CancellationToken)` | `StreamInfo` | Downloads video with watermark | +| Method | Returns | Description | +| ------------------------------------------------------------ | ------------ | --------------------------------------- | +| `GetVideoAsync(string url, CancellationToken)` | `Video` | Fetches full video metadata | +| `DownloadAsync(Video, CancellationToken)` | `StreamInfo` | Downloads video without watermark | +| `DownloadWatermarkedAsync(Video, CancellationToken)` | `StreamInfo` | Downloads video with watermark | +| `DownloadImageAsync(CarouselImage, CancellationToken)` | `StreamInfo` | Downloads a carousel image stream | `TiktokClient` implements `IAsyncDisposable` — always use `await using`. -#### Extension methods (via `TiktokClientExtensions`) +--- + +### `TiktokSearchClient` -| Method | Description | -| --- | --- | -| `DownloadAsync(video, filePath, progress?, ct)` | Downloads video without watermark to a file, with optional progress | -| `DownloadWatermarkedAsync(video, filePath, progress?, ct)` | Downloads video with watermark to a file, with optional progress | -| `DownloadImageAsync(video, filePath, ct)` | Downloads the static cover image (JPEG) | -| `DownloadAnimatedImageAsync(video, filePath, ct)` | Downloads the animated cover (WebP) | +`TiktokSearchClient` searches TikTok by keyword and streams results as `Media` objects (either `Video` or `Carousel`) using `IAsyncEnumerable`. It **requires Playwright** because TikTok's search API signs requests with dynamic tokens that can only be generated by a real browser session. ```csharp -// Download to file path with optional progress -await client.DownloadAsync(video, "output.mp4", progress, cancellationToken); -await client.DownloadWatermarkedAsync(video, "output_wm.mp4", progress, cancellationToken); +await using var client = TiktokSearchClient.CreateWithBrowser(); + +await foreach (var media in client.SearchAsync("funny cats")) +{ + Console.WriteLine($"{media.Author.UniqueId}: {media.Description}"); + + if (media is Video video) + { + // Standard video post + await client.DownloadAsync(video, $"{video.Id}.mp4"); + } + else if (media is Carousel carousel) + { + // Slideshow post — contains a collection of images + Console.WriteLine($"Carousel with {carousel.Post.Images.Count} images"); + } +} +``` + +> **Note:** `TiktokSearchClient` implements `ISearchClient`, which extends `IDownloadClient`. This means all extension methods (`DownloadAsync(filePath)`, `DownloadWatermarkedAsync(filePath)`, `DownloadImageAsync`, `DownloadAnimatedImageAsync`, `DownloadCarouselImagesAsync`) are available directly on the search client — no need for a separate `TiktokClient` instance. + +> **Search scope:** The current implementation returns results from the **first page** of TikTok's search API (~10–20 results). + +#### Methods + +| Method | Returns | Description | +| -------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------ | +| `SearchAsync(string keyword, CancellationToken)` | `IAsyncEnumerable` | Streams media results (videos and carousels) for the keyword | +| `DownloadAsync(Video, CancellationToken)` | `StreamInfo` | Downloads video without watermark | +| `DownloadWatermarkedAsync(Video, CancellationToken)` | `StreamInfo` | Downloads video with watermark | +| `DownloadImageAsync(CarouselImage, CancellationToken)` | `StreamInfo` | Downloads a carousel image stream | + +`TiktokSearchClient` implements `IAsyncDisposable` — always use `await using`. + +#### Extension methods (via `DownloadClientExtensions`) + +| Method | Description | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `DownloadAsync(video, filePath, progress?, ct)` | Downloads video without watermark to a file, with optional progress | +| `DownloadWatermarkedAsync(video, filePath, progress?, ct)` | Downloads video with watermark to a file, with optional progress | +| `DownloadImageAsync(image, filePath, progress?, ct)` | Downloads a single carousel image to a file, with optional progress | +| `DownloadCarouselImagesAsync(carousel, directoryPath, progress?, ct)` | Downloads all carousel images to a directory with overall progress | +| `DownloadImageAsync(video, filePath, ct)` | Downloads the static cover image of a video (JPEG) | +| `DownloadAnimatedImageAsync(video, filePath, ct)` | Downloads the animated cover of a video (WebP) | + +```csharp +// Download video to file with optional progress +IProgress progress = new Progress(p => Console.Write($"\rProgress: {p:P0}")); +await client.DownloadAsync(video, "output.mp4", progress); +await client.DownloadWatermarkedAsync(video, "output_wm.mp4", progress); // Download cover images await client.DownloadImageAsync(video, "cover.jpg"); await client.DownloadAnimatedImageAsync(video, "cover.webp"); + +// Download a single carousel image +await client.DownloadImageAsync(carousel.Post.Images[0], "image_1.jpg", progress); + +// Download all carousel images to a directory (named image_1.jpg, image_2.jpg, ...) +await client.DownloadCarouselImagesAsync(carousel, "my_carousel_folder", progress); ``` > **Note:** Animated covers are served by TikTok as animated WebP files. Not all videos have an animated cover — if `Cover.AnimatedUrl` is empty, the video only has a static cover. @@ -183,21 +236,37 @@ Returned by `DownloadAsync` and `DownloadWatermarkedAsync`. Implements `IAsyncDi --- -### `Video` model +### `Media` model (base) + +All search results are `Media` objects. Use pattern matching (`is Video`, `is Carousel`) to access type-specific properties. | Property | Type | Description | | ------------- | ---------------- | -------------------------------------------------- | -| `Id` | `string` | TikTok video ID | +| `Id` | `string` | TikTok post ID | | `Description` | `string` | Caption / description | | `Author` | `Author` | Author entity | -| `Duration` | `VideoDuration` | Duration in seconds and precise seconds | -| `Stats` | `VideoStats` | Views, likes, comments, shares, favorites, reposts | -| `Info` | `VideoInfo` | Technical info, bitrates, and download URLs | -| `Language` | `VideoLanguage` | Detected content language | +| `Stats` | `MediaStats` | Views, likes, comments, shares, favorites, reposts | +| `Language` | `MediaLanguage` | Detected content language | | `Location` | `string` | Location tag (if any) | -| `Cover` | `VideoCover` | Static (JPEG) and animated (WebP) cover image URLs | +| `Cover` | `MediaCover` | Static (JPEG) and animated (WebP) cover image URLs | +| `Music` | `MediaMusic` | Track metadata and artwork | | `CreatedAt` | `DateTimeOffset` | Upload date | +### `Video : Media` model + +| Property | Type | Description | +| ---------- | --------------- | ------------------------------------------- | +| `Info` | `VideoInfo` | Technical info, bitrates, and download URLs | +| `Duration` | `VideoDuration` | Duration in seconds and precise seconds | + +### `Carousel : Media` model + +Represents a TikTok slideshow post (multiple images + audio). + +| Property | Type | Description | +| -------- | -------------- | ------------------------------------ | +| `Post` | `CarouselPost` | Collection of images with their URLs | + ### `Author` model | Property | Type | Description | @@ -227,6 +296,7 @@ services.AddTiktokExplode(b => b .UsePlaywrightFetcher(o => o.Headless = false)); // HTTP fetcher — lighter, no browser dependency +// Note: ISearchClient is NOT available with HTTP fetcher (see below) services.AddTiktokExplode(b => b .UseHttpFetcher(o => o.WarmupDelay = TimeSpan.Zero) .ConfigureTiktok(o => o.MaxWafRetries = 5)); @@ -234,12 +304,16 @@ services.AddTiktokExplode(b => b Registered services: -| Service | Implementation | Lifetime | -| --- | --- | --- | -| `IVideoClient` | `TiktokClient` | Singleton | -| `IPageFetcher` | `PlaywrightFetcher` or `HttpFetcher` | Singleton | -| `TikTokOptions` | — | Singleton | -| `PlaywrightFetcherOptions` or `HttpFetcherOptions` | — | Singleton | +| Service | Implementation | Lifetime | Fetcher | +| -------------------------------------------------- | ------------------------------------ | --------- | ------------------- | +| `IVideoClient` | `TiktokClient` | Singleton | Both | +| `ISearchClient` | `TiktokSearchClient` | Singleton | **Playwright only** | +| `IPageFetcher` | `PlaywrightFetcher` or `HttpFetcher` | Singleton | Both | +| `ISearchFetcher` | `PlaywrightSearchFetcher` | Singleton | **Playwright only** | +| `TikTokOptions` | — | Singleton | Both | +| `PlaywrightFetcherOptions` or `HttpFetcherOptions` | — | Singleton | Both | + +> **Important:** `ISearchClient` and `ISearchFetcher` are only registered when using the Playwright fetcher (the default). If you call `UseHttpFetcher()`, these services will **not** be present in the container. Attempting to inject `ISearchClient` with an HTTP-configured builder will throw a DI resolution error at runtime. This is by design — TikTok's search API requires a real browser to generate signed requests. ```csharp // Consume in your services via constructor injection @@ -289,18 +363,23 @@ catch (TiktokException ex) ``` TiktokExplode/ # Domain — zero external dependencies Domain/ - Entities/ # Video, Author - ValueObjects/ # VideoInfo, StreamInfo, VideoStats, VideoDuration, etc. - Abstractions/ # IVideoClient + Entities/ # Media (abstract), Video, Carousel, Author + ValueObjects/ + Media/ # MediaStats, MediaLanguage, MediaCover, MediaMusic, MediaDuration + Videos/ # VideoInfo, VideoDuration, VideoDownloadLinks, Bitrate + Carousels/ # CarouselPost and related types + Authors/ # AuthorStats, ProfileImageVariants + Abstractions/ # IVideoClient, ISearchClient, IDownloadClient Exceptions/ # TiktokException hierarchy Utilities/ # URL validation TiktokExplode.Infrastructure/ # HTTP + browser automation (Playwright + AngleSharp) - Clients/ # TiktokClient : IVideoClient + Clients/ # TiktokClient : IVideoClient, TiktokSearchClient : ISearchClient Fetchers/ # IPageFetcher, PlaywrightFetcher, HttpFetcher + Fetchers/Search/ # ISearchFetcher, PlaywrightSearchFetcher Http/ # TikTokDownloadClient — CDN download management Browser/ # TikTokBrowser — internal Playwright wrapper - Parsers/ # TikTokVideoParser — JSON extraction from hydration script + Parsers/ # TikTokVideoParser, TikTokSearchParser — JSON extraction Options/ # TikTokOptions, PlaywrightFetcherOptions, HttpFetcherOptions Common/ # StreamExtensions, TiktokClientExtensions diff --git a/TiktokExplode.All/TiktokExplode.All.csproj b/TiktokExplode.All/TiktokExplode.All.csproj index a08d921..763b0c4 100644 --- a/TiktokExplode.All/TiktokExplode.All.csproj +++ b/TiktokExplode.All/TiktokExplode.All.csproj @@ -10,7 +10,7 @@ TiktokExplode.All - 1.1.1 + 1.2.0 Ts-Pytham Ts-Pytham Meta-package that installs TiktokExplode (domain layer) and TiktokExplode.Infrastructure (HTTP/browser fetchers, parser, download client) in a single command. diff --git a/TiktokExplode.Console/Program.cs b/TiktokExplode.Console/Program.cs index 59581ca..74065da 100644 --- a/TiktokExplode.Console/Program.cs +++ b/TiktokExplode.Console/Program.cs @@ -1,83 +1,232 @@ +using TiktokExplode.Domain.Abstractions; +using TiktokExplode.Domain.Entities; using TiktokExplode.Domain.Exceptions; using TiktokExplode.Infrastructure.Clients; using TiktokExplode.Infrastructure.Common; using TiktokExplode.Infrastructure.Options; -const string Separator = "─────────────────────────────────────────"; -const string DefaultUrl = "https://www.tiktok.com/@js_nightwave/video/7579504710961548565"; +const string Sep = "─────────────────────────────────────────"; -Console.WriteLine("TiktokExplode — Video Downloader"); -Console.WriteLine(Separator); -Console.Write("Video URL (press Enter for default): "); -var url = Console.ReadLine(); +Console.WriteLine("TiktokExplode — Test Console"); +Console.WriteLine(Sep); +Console.WriteLine(" [1] Get video by URL"); +Console.WriteLine(" [2] Search by keyword"); +Console.WriteLine(Sep); +Console.Write("Choose (1/2): "); +var mode = Console.ReadLine()?.Trim(); -if (string.IsNullOrWhiteSpace(url)) +Console.WriteLine(Sep); + +var browserOptions = new PlaywrightFetcherOptions { BrowserChannel = "msedge" }; + +if (mode == "1") +{ + const string DefaultUrl = "https://www.tiktok.com/@js_nightwave/video/7579504710961548565"; + Console.Write($"Video URL (Enter for default): "); + var url = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(url)) + { + url = DefaultUrl; + Console.WriteLine($"Using default: {url}"); + } + + await using var client = TiktokClient.CreateWithBrowser(browserOptions); + await FetchAndDownloadAsync(client, url); +} +else if (mode == "2") +{ + Console.Write("Keyword: "); + var keyword = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(keyword)) + { + Console.WriteLine("No keyword entered."); + return; + } + + Console.Write("Max results (Enter for 5): "); + var maxStr = Console.ReadLine()?.Trim(); + var maxResults = int.TryParse(maxStr, out var n) && n > 0 ? n : 5; + + await using var searchClient = TiktokSearchClient.CreateWithBrowser(browserOptions); + await SearchAndDownloadAsync(searchClient, keyword, maxResults); +} +else { - url = DefaultUrl; - Console.WriteLine($"Using default: {url}"); + Console.WriteLine("Invalid option."); } -Console.WriteLine(Separator); +// ── helpers ──────────────────────────────────────────────────────────────── -await using var client = TiktokClient.CreateWithBrowser(new PlaywrightFetcherOptions +static async Task FetchAndDownloadAsync(IVideoClient client, string url) { - BrowserChannel = "msedge" -}); + const string Sep = "─────────────────────────────────────────"; + try + { + Console.WriteLine(Sep); + Console.Write("Fetching video metadata..."); + var video = await client.GetVideoAsync(url); + Console.WriteLine(" done."); + PrintVideo(video, 1, 1); + await PromptDownloadVideoAsync(client, video); + } + catch (TiktokWafException ex) { Console.WriteLine($"\n[WAF] {ex.Message}"); } + catch (VideoNotFoundException ex) { Console.WriteLine($"\n[404] {ex.Message}"); } + catch (TiktokException ex) { Console.WriteLine($"\n[Error] {ex.Message}"); } +} -try +static async Task SearchAndDownloadAsync(ISearchClient client, string keyword, int maxResults) { - Console.Write("Fetching video metadata..."); - var video = await client.GetVideoAsync(url); - Console.WriteLine(" done."); - Console.WriteLine(Separator); + const string Sep = "─────────────────────────────────────────"; + var mediaList = new List(); + + try + { + Console.Write($"Searching for \"{keyword}\"..."); + await foreach (var media in client.SearchAsync(keyword)) + { + mediaList.Add(media); + if (mediaList.Count >= maxResults) break; + } + Console.WriteLine($" found {mediaList.Count} result(s)."); + } + catch (TiktokWafException ex) { Console.WriteLine($"\n[WAF] {ex.Message}"); return; } + catch (TiktokException ex) { Console.WriteLine($"\n[Error] {ex.Message}"); return; } + + if (mediaList.Count == 0) + { + Console.WriteLine("No results."); + return; + } + + Console.WriteLine(Sep); + for (int i = 0; i < mediaList.Count; i++) + { + var media = mediaList[i]; + if(media is Video video) + PrintVideo(video, i + 1, mediaList.Count); + else if (media is Carousel carousel) + PrintCarousel(carousel, i + 1, mediaList.Count); + } + + + Console.Write($"\nDownload which video? (1-{mediaList.Count}, or Enter to skip): "); + var pick = Console.ReadLine()?.Trim(); + if (!int.TryParse(pick, out var idx) || idx < 1 || idx > mediaList.Count) + { + Console.WriteLine("Skipping download."); + return; + } + if (mediaList[idx - 1] is Video video2) + await PromptDownloadVideoAsync(client, video2); + else if (mediaList[idx - 1] is Carousel carousel2) + await PromptDownloadCarouselAsync(client, carousel2); +} +static void PrintVideo(Video video, int index, int total) +{ + const string Sep = "─────────────────────────────────────────"; + Console.WriteLine(Sep); + if (total > 1) Console.WriteLine($" [{index}/{total}]"); + Console.WriteLine($" Type Video"); + Console.WriteLine($" URL {video.Url}"); Console.WriteLine($" ID {video.Id}"); Console.WriteLine($" Author {video.Author.Name} (@{video.Author.UniqueId})"); Console.WriteLine($" Duration {video.Duration.Seconds}s"); Console.WriteLine($" Views {video.Stats.Views:N0}"); Console.WriteLine($" Likes {video.Stats.Likes:N0}"); Console.WriteLine($" Size {video.Info.DownloadLinks.OriginalSizeInBytes / (1_024 * 1_024):F2} MB"); - Console.WriteLine(Separator); +} - Console.Write("Download with watermark? [Y/n]: "); - var watermarkChoice = Console.ReadLine()?.Trim(); - var isWatermarked = string.IsNullOrEmpty(watermarkChoice) || - watermarkChoice.Equals("y", StringComparison.OrdinalIgnoreCase); +static void PrintCarousel(Carousel carousel, int index, int total) +{ + const string Sep = "─────────────────────────────────────────"; + Console.WriteLine(Sep); + if (total > 1) Console.WriteLine($" [{index}/{total}]"); + Console.WriteLine($" Type Carousel"); + Console.WriteLine($" URL {carousel.Url}"); + Console.WriteLine($" ID {carousel.Id}"); + Console.WriteLine($" Author {carousel.Author.Name} (@{carousel.Author.UniqueId})"); + Console.WriteLine($" Views {carousel.Stats.Views:N0}"); + Console.WriteLine($" Likes {carousel.Stats.Likes:N0}"); + Console.WriteLine($" Carousel items: {carousel.Post.Images.Count}"); +} +static async Task PromptDownloadVideoAsync(IDownloadClient client, Video video) +{ + const string Sep = "─────────────────────────────────────────"; + Console.WriteLine(Sep); Console.Write("Download? [Y/n]: "); - var downloadChoice = Console.ReadLine()?.Trim(); - if (!string.IsNullOrEmpty(downloadChoice) && !downloadChoice.Equals("y", StringComparison.OrdinalIgnoreCase)) + var dl = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(dl) && !dl.Equals("y", StringComparison.OrdinalIgnoreCase)) { - Console.WriteLine("Download cancelled."); + Console.WriteLine("Skipped."); return; } + Console.Write("With watermark? [Y/n]: "); + var wm = Console.ReadLine()?.Trim(); + var isWatermarked = string.IsNullOrEmpty(wm) || wm.Equals("y", StringComparison.OrdinalIgnoreCase); + var suffix = isWatermarked ? "watermarked" : "original"; var fileName = $"{video.Id}_{video.Author.UniqueId}_{suffix}.mp4"; - Console.WriteLine($"Saving to: {fileName}"); - IProgress progress = new Progress(p => - Console.Write($"\r Downloading... {p:P0} ") - ); + IProgress progress = new Progress(p => Console.Write($"\r Downloading... {p:P0} ")); + + try + { + if (isWatermarked) + await client.DownloadWatermarkedAsync(video, fileName, progress); + else + await client.DownloadAsync(video, fileName, progress); - if (isWatermarked) - await client.DownloadWatermarkedAsync(video, fileName, progress); - else - await client.DownloadAsync(video, fileName, progress); + Console.WriteLine($"\n Saved: {fileName}"); + } + catch (TiktokException ex) { Console.WriteLine($"\n[Error] {ex.Message}"); } - Console.WriteLine($"\n Saved: {fileName}"); - Console.WriteLine(Separator); -} -catch (TiktokWafException ex) -{ - Console.WriteLine($"\n[WAF] Bot detection triggered: {ex.Message}"); + Console.WriteLine(Sep); } -catch (VideoNotFoundException ex) -{ - Console.WriteLine($"\n[404] Video not found: {ex.Message}"); -} -catch (TiktokException ex) + +static async Task PromptDownloadCarouselAsync( + IDownloadClient client, + Carousel carousel) { - Console.WriteLine($"\n[Error] {ex.Message}"); -} + const string Sep = "─────────────────────────────────────────"; + + Console.WriteLine(Sep); + + Console.Write("Download images? [Y/n]: "); + var dl = Console.ReadLine()?.Trim(); + + if (!string.IsNullOrEmpty(dl) && + !dl.Equals("y", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Skipped."); + return; + } + + var directoryName = + $"{carousel.Id}_{carousel.Author.UniqueId}_images"; + + Console.WriteLine($"Saving to: {directoryName}"); + + IProgress progress = + new Progress(p => + Console.Write($"\r Downloading... {p:P0} ")); + + try + { + await client.DownloadCarouselImagesAsync( + carousel, + directoryName, + progress); + + Console.WriteLine($"\n Saved: {directoryName}"); + } + catch (TiktokException ex) + { + Console.WriteLine($"\n[Error] {ex.Message}"); + } + + Console.WriteLine(Sep); +} \ No newline at end of file diff --git a/TiktokExplode.Extensions.DependencyInjection/README.md b/TiktokExplode.Extensions.DependencyInjection/README.md index 4f66be2..a048578 100644 --- a/TiktokExplode.Extensions.DependencyInjection/README.md +++ b/TiktokExplode.Extensions.DependencyInjection/README.md @@ -6,7 +6,7 @@ `Microsoft.Extensions.DependencyInjection` integration for [TiktokExplode](https://github.com/Ts-Pytham/TiktokExplode). -Provides `AddTiktokExplode()` — a fluent extension method on `IServiceCollection` that registers `IVideoClient` and lets you choose between the Playwright (default) or HTTP fetcher strategy. +Provides `AddTiktokExplode()` — a fluent extension method on `IServiceCollection` that registers `IVideoClient` and `ISearchClient`, and lets you choose between the Playwright (default) or HTTP fetcher strategy. --- @@ -29,21 +29,28 @@ services.AddTiktokExplode(b => b .UsePlaywrightFetcher(o => o.Headless = false)); // HTTP fetcher — no browser dependency, lighter footprint +// Note: ISearchClient is NOT registered with this strategy (requires Playwright) services.AddTiktokExplode(b => b .UseHttpFetcher(o => o.WarmupDelay = TimeSpan.Zero) .ConfigureTiktok(o => o.MaxWafRetries = 5)); ``` -Then inject `IVideoClient` normally: +Then inject `IVideoClient` or `ISearchClient` normally: ```csharp -public class MyService(IVideoClient client) +public class MyService(IVideoClient client, ISearchClient search) { public async Task GetTitleAsync(string url) { var video = await client.GetVideoAsync(url); return video.Description; } + + public async IAsyncEnumerable SearchTitlesAsync(string keyword) + { + await foreach (var video in search.SearchAsync(keyword)) + yield return video.Description; + } } ``` @@ -51,22 +58,26 @@ public class MyService(IVideoClient client) ## Registered services -| Service | Implementation | Lifetime | -| --- | --- | --- | -| `IVideoClient` | `TiktokClient` | Singleton | -| `IPageFetcher` | `PlaywrightFetcher` or `HttpFetcher` | Singleton | -| `TikTokOptions` | — | Singleton | -| `PlaywrightFetcherOptions` or `HttpFetcherOptions` | — | Singleton | +| Service | Implementation | Lifetime | Fetcher | +| -------------------------------------------------- | ------------------------------------ | --------- | ------------------- | +| `IVideoClient` | `TiktokClient` | Singleton | Both | +| `ISearchClient` | `TiktokSearchClient` | Singleton | **Playwright only** | +| `IPageFetcher` | `PlaywrightFetcher` or `HttpFetcher` | Singleton | Both | +| `ISearchFetcher` | `PlaywrightSearchFetcher` | Singleton | **Playwright only** | +| `TikTokOptions` | — | Singleton | Both | +| `PlaywrightFetcherOptions` or `HttpFetcherOptions` | — | Singleton | Both | + +> **Important:** `ISearchClient` and `ISearchFetcher` are only registered when using the Playwright fetcher. TikTok's search API requires a real browser to sign requests — it cannot be replicated with plain HTTP. If you call `UseHttpFetcher()` and attempt to inject `ISearchClient`, the DI container will throw a resolution error at runtime. --- ## Builder API -| Method | Description | -| --- | --- | -| `ConfigureTiktok(Action?)` | Configures WAF retry count and base delay | -| `UsePlaywrightFetcher(Action?)` | Uses a real Chromium browser via Playwright (default) | -| `UseHttpFetcher(Action?)` | Uses a plain `HttpClient` — faster start, may be WAF-blocked | +| Method | Description | +| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `ConfigureTiktok(Action?)` | Configures WAF retry count and base delay | +| `UsePlaywrightFetcher(Action?)` | Uses a real Chromium browser via Playwright (default). Also registers `ISearchClient` and `ISearchFetcher` | +| `UseHttpFetcher(Action?)` | Uses a plain `HttpClient` — faster start, may be WAF-blocked. `ISearchClient` is **not** available | > **Note:** `UsePlaywrightFetcher` and `UseHttpFetcher` are mutually exclusive — the last one called wins. @@ -74,11 +85,11 @@ public class MyService(IVideoClient client) ## Related packages -| Package | Description | -| --- | --- | -| [`TiktokExplode`](https://www.nuget.org/packages/TiktokExplode) | Domain layer — models, interfaces, exceptions | -| [`TiktokExplode.Infrastructure`](https://www.nuget.org/packages/TiktokExplode.Infrastructure) | HTTP + Playwright fetchers, parser, download client | -| [`TiktokExplode.All`](https://www.nuget.org/packages/TiktokExplode.All) | Meta-package — installs domain + infrastructure together | +| Package | Description | +| --------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| [`TiktokExplode`](https://www.nuget.org/packages/TiktokExplode) | Domain layer — models, interfaces, exceptions | +| [`TiktokExplode.Infrastructure`](https://www.nuget.org/packages/TiktokExplode.Infrastructure) | HTTP + Playwright fetchers, parser, download client | +| [`TiktokExplode.All`](https://www.nuget.org/packages/TiktokExplode.All) | Meta-package — installs domain + infrastructure together | --- diff --git a/TiktokExplode.Extensions.DependencyInjection/TiktokExplode.Extensions.DependencyInjection.csproj b/TiktokExplode.Extensions.DependencyInjection/TiktokExplode.Extensions.DependencyInjection.csproj index 21ac446..af76ab9 100644 --- a/TiktokExplode.Extensions.DependencyInjection/TiktokExplode.Extensions.DependencyInjection.csproj +++ b/TiktokExplode.Extensions.DependencyInjection/TiktokExplode.Extensions.DependencyInjection.csproj @@ -12,7 +12,7 @@ TiktokExplode.Extensions.DependencyInjection - 1.0.0 + 1.1.0 Ts-Pytham Ts-Pytham Microsoft.Extensions.DependencyInjection integration for TiktokExplode. Provides AddTiktokExplode() with a fluent builder to register IVideoClient and choose between Playwright or HTTP fetcher strategies. diff --git a/TiktokExplode.Extensions.DependencyInjection/TiktokExplodeBuilder.cs b/TiktokExplode.Extensions.DependencyInjection/TiktokExplodeBuilder.cs index 45d034f..ffbb2c4 100644 --- a/TiktokExplode.Extensions.DependencyInjection/TiktokExplodeBuilder.cs +++ b/TiktokExplode.Extensions.DependencyInjection/TiktokExplodeBuilder.cs @@ -2,6 +2,7 @@ using TiktokExplode.Domain.Abstractions; using TiktokExplode.Infrastructure.Clients; using TiktokExplode.Infrastructure.Fetchers; +using TiktokExplode.Infrastructure.Fetchers.Search; using TiktokExplode.Infrastructure.Options; namespace TiktokExplode.Extensions.DependencyInjection; @@ -13,15 +14,15 @@ namespace TiktokExplode.Extensions.DependencyInjection; /// public sealed class TiktokExplodeBuilder(IServiceCollection services) { - private readonly TikTokOptions _tiktokOptions = new(); + private readonly TiktokOptions _tiktokOptions = new(); private Action _fetcherRegistration = RegisterPlaywright(new()); /// /// Configures the WAF-retry behaviour of TiktokClient. /// - /// Delegate that mutates a instance. + /// Delegate that mutates a instance. /// The same builder for chaining. - public TiktokExplodeBuilder ConfigureTiktok(Action? options = null) + public TiktokExplodeBuilder ConfigureTiktok(Action? options = null) { options?.Invoke(_tiktokOptions); return this; @@ -31,6 +32,12 @@ public TiktokExplodeBuilder ConfigureTiktok(Action? options = nul /// Configures the Playwright-based page fetcher as the active . /// This is the default strategy — call this only when you need to customise the options. /// + /// + /// Registering this fetcher also makes and + /// available in the container, because TikTok's search API requires a real browser session + /// to generate signed requests. These services are not registered when + /// is used instead. + /// /// Delegate that mutates a instance. /// The same builder for chaining. public TiktokExplodeBuilder UsePlaywrightFetcher(Action? options = null) @@ -57,7 +64,7 @@ public TiktokExplodeBuilder UseHttpFetcher(Action? options = /// /// Applies all pending registrations to the underlying . - /// Registers , the chosen , + /// Registers , the chosen , /// and as singletons. /// /// The service collection for further chaining. @@ -75,6 +82,8 @@ private static Action RegisterPlaywright(PlaywrightFetcherOp { services.AddSingleton(options); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); }; } diff --git a/TiktokExplode.Infrastructure/Browser/TikTokBrowser.cs b/TiktokExplode.Infrastructure/Browser/TikTokBrowser.cs index 53f46ec..b18f4a3 100644 --- a/TiktokExplode.Infrastructure/Browser/TikTokBrowser.cs +++ b/TiktokExplode.Infrastructure/Browser/TikTokBrowser.cs @@ -1,6 +1,7 @@ using Microsoft.Playwright; using TiktokExplode.Domain.Exceptions; using TiktokExplode.Infrastructure.Fetchers; +using TiktokExplode.Infrastructure.Fetchers.Search; using TiktokExplode.Infrastructure.Options; namespace TiktokExplode.Infrastructure.Browser; @@ -11,7 +12,7 @@ namespace TiktokExplode.Infrastructure.Browser; /// Resource-heavy assets (images, media, fonts, stylesheets) are intercepted and aborted /// to speed up page load times. /// -internal sealed class TikTokBrowser : IAsyncDisposable +internal sealed class TiktokBrowser : IAsyncDisposable { /// The top-level Playwright instance. Must be disposed last. private readonly IPlaywright _playwright; @@ -28,7 +29,11 @@ internal sealed class TikTokBrowser : IAsyncDisposable private readonly PlaywrightFetcherOptions _options; /// Private constructor — use to instantiate. - private TikTokBrowser(IPlaywright playwright, IBrowser browser, IBrowserContext context, PlaywrightFetcherOptions options) + private TiktokBrowser( + IPlaywright playwright, + IBrowser browser, + IBrowserContext context, + PlaywrightFetcherOptions options) { _playwright = playwright; _browser = browser; @@ -37,12 +42,12 @@ private TikTokBrowser(IPlaywright playwright, IBrowser browser, IBrowserContext } /// - /// Creates and fully initializes a new instance. + /// Creates and fully initializes a new instance. /// Launches Chromium with the settings from and creates /// a new browser context with a realistic user-agent and locale. /// /// Browser launch and navigation options. - public static async Task CreateAsync(PlaywrightFetcherOptions options) + public static async Task CreateAsync(PlaywrightFetcherOptions options) { var playwright = await Playwright.CreateAsync(); @@ -62,7 +67,7 @@ public static async Task CreateAsync(PlaywrightFetcherOptions opt } }); - return new TikTokBrowser(playwright, browser, context, options); + return new TiktokBrowser(playwright, browser, context, options); } /// @@ -119,6 +124,166 @@ await page.RouteAsync("**/*", async route => } } + /// + /// Opens a new page in the shared context, navigates to the search results page for , + /// and returns the full HTML content of the loaded page. + /// + /// The search keyword to query on TikTok. + /// The full HTML content of the search results page. + /// Thrown if the page fails to load (non-OK HTTP status). + /// Thrown if WAF challenge markers are found in the page content. + public async Task GetSearchPageAsync(string keyword) + { + var page = await _context.NewPageAsync(); + + await page.GotoAsync( + $"https://www.tiktok.com/search?q={Uri.EscapeDataString(keyword)}", + new PageGotoOptions + { + WaitUntil = WaitUntilState.DOMContentLoaded, + Timeout = _options.PageTimeoutMs + }); + + await page.WaitForFunctionAsync( + """ + () => document.cookie.includes('msToken') + """, + new PageWaitForFunctionOptions + { + Timeout = _options.PageTimeoutMs + }); + + var responseTask = page.WaitForResponseAsync( + r => r.Url.Contains("/api/search/general/full"), + new PageWaitForResponseOptions + { + Timeout = _options.PageTimeoutMs + }); + + var pageResponse = await page.ReloadAsync( + new PageReloadOptions + { + WaitUntil = WaitUntilState.Load, + Timeout = _options.PageTimeoutMs + }); + + if (pageResponse is null || !pageResponse.Ok) + throw new TiktokParsingException($"Failed to load page. Status: {pageResponse?.Status}"); + + var content = await page.ContentAsync(); + + if (content.Contains("_wafchallengeid", StringComparison.OrdinalIgnoreCase)) + throw new TiktokWafException("TikTok WAF challenge detected."); + + IResponse response; + try + { + response = await responseTask; + } + catch (TimeoutException) + { + throw new TiktokParsingException("Search API request was not intercepted within the timeout period. TikTok may not have issued the search request."); + } + + if (!response.Ok) + throw new TiktokParsingException($"Failed to load search results. Status: {response.Status}"); + + string result; + + try + { + result = await response.TextAsync(); + } + catch + { + result = string.Empty; + } + + if (string.IsNullOrWhiteSpace(result)) + { + result = await page.EvaluateAsync( + """ + async (url) => { + const resp = await fetch(url, { + credentials: 'include', + headers: { + accept: 'application/json, text/plain, */*' + } + }); + + return await resp.text(); + } + """, + response.Url); + } + + return new SearchPageResult + { + JsonContent = result, + Page = page + }; + } + + /// + /// Scrolls the search results container to trigger loading of the next page of results, waits for the API response, + /// + /// The page containing the search results. Must be the same page returned + /// by . + /// The raw JSON string returned by the search API for the next page of results. + /// Thrown if the search results container is not found, if the API response + /// is not OK, or if the API response cannot be retrieved within the timeout period. + public async Task GetSearchNextPageAsync(IPage page) + { + var responseTask = page.WaitForResponseAsync( + r => r.Url.Contains("/api/search/general/full"), + new PageWaitForResponseOptions + { + Timeout = _options.PageTimeoutMs + }); + + var scrolled = await page.EvaluateAsync( + """ + () => { + const main = document.querySelector('#grid-main'); + + if (!main) + return false; + + main.scrollTop = main.scrollHeight; + return true; + } + """); + + if (!scrolled) + { + throw new TiktokParsingException( + "Search results container '#grid-main' was not found."); + } + + var response = await responseTask; + + if (!response.Ok) + { + throw new TiktokParsingException( + $"Failed to load next search page. Status: {response.Status}"); + } + + return await page.EvaluateAsync( + """ + async (url) => { + const resp = await fetch(url, { + credentials: 'include', + headers: { + accept: 'application/json, text/plain, */*' + } + }); + + return await resp.text(); + } + """, + response.Url); + } + /// /// Returns all cookies currently set in the browser context as a list of /// records, ready to be injected into the download client. @@ -136,6 +301,13 @@ public async Task> GetCookiesAsync() })]; } + /// + /// Creates and returns a new page in the shared browser context for manual navigation and interaction. + /// + /// + internal async Task CreatePageAsync() + => await _context.NewPageAsync(); + /// /// Disposes the browser context, the browser process, and the Playwright instance /// in the correct reverse-dependency order. diff --git a/TiktokExplode.Infrastructure/Clients/TiktokClient.cs b/TiktokExplode.Infrastructure/Clients/TiktokClient.cs index d21fd3a..d7c6552 100644 --- a/TiktokExplode.Infrastructure/Clients/TiktokClient.cs +++ b/TiktokExplode.Infrastructure/Clients/TiktokClient.cs @@ -4,6 +4,7 @@ using TiktokExplode.Domain.Exceptions; using TiktokExplode.Domain.Utilities; using TiktokExplode.Domain.ValueObjects; +using TiktokExplode.Domain.ValueObjects.Carousels; using TiktokExplode.Infrastructure.Fetchers; using TiktokExplode.Infrastructure.Http; using TiktokExplode.Infrastructure.Options; @@ -14,23 +15,23 @@ namespace TiktokExplode.Infrastructure.Clients; /// /// Default implementation of that fetches and parses TikTok video pages, /// then streams video files from TikTok's CDN. -/// Supports automatic WAF-retry logic configured via . +/// Supports automatic WAF-retry logic configured via . /// /// The strategy used to fetch TikTok video page HTML. /// Retry and delay settings for WAF bypass attempts. -public sealed class TiktokClient(IPageFetcher fetcher, TikTokOptions options) : IVideoClient, IAsyncDisposable +public sealed class TiktokClient(IPageFetcher fetcher, TiktokOptions options) : IVideoClient, IAsyncDisposable { /// Reusable HTTP client for CDN video downloads. Receives cookies from the fetcher. - private readonly TikTokDownloadClient _downloadClient = new(); + private readonly TiktokDownloadClient _downloadClient = new(); /// Stateless parser that extracts a from raw page HTML. - private readonly TikTokVideoParser _parser = new(); + private readonly TiktokVideoParser _parser = new(); /// /// Initializes a new with a Playwright-based page fetcher /// and default retry options. /// - public TiktokClient() : this(new PlaywrightFetcher(), new TikTokOptions()) { } + public TiktokClient() : this(new PlaywrightFetcher(), new TiktokOptions()) { } /// /// @@ -40,7 +41,7 @@ public sealed class TiktokClient(IPageFetcher fetcher, TikTokOptions options) : /// public async Task private static BitrateFormat ParseBitrateFormat(JsonNode bitrateNode) { - var raw = bitrateNode.GetString("Format"); + var raw = bitrateNode.GetOptionalString("Format"); + return Enum.TryParse(raw, ignoreCase: true, out var format) ? format : BitrateFormat.Unknown; } - /// Maps the textLanguage and textTranslatable fields to a . - private static VideoLanguage ParseVideoLanguage(JsonNode node) + /// Maps the textLanguage and textTranslatable fields to a . + internal static MediaLanguage ParseVideoLanguage(JsonNode node) { - return new VideoLanguage + return new MediaLanguage { PrimaryLanguage = node.GetStringOrEmpty("textLanguage"), - IsTranslatable = node.GetBool("textTranslatable") + IsTranslatable = node.GetBool("textTranslatable") }; } - private static VideoCover ParseVideoCover(JsonNode node) + /// Maps the cover and dynamicCover fields to a . + internal static MediaCover ParseVideoCover(JsonNode node) { - return new VideoCover + return new MediaCover { - AnimatedUrl = node.GetString("dynamicCover"), + AnimatedUrl = node.GetStringOrEmpty("dynamicCover"), StaticUrl = node.GetString("cover") }; } -} \ No newline at end of file + + /// Maps the music sub-tree to a . + internal static MediaMusic ParseVideoMusic(JsonNode node) + { + return new MediaMusic + { + Id = node.GetString("id"), + Title = node.GetString("title"), + AuthorName = node.GetString("authorName"), + AlbumName = node.GetStringOrEmpty("album"), + Images = ParseMusicImageVariants(node), + Duration = ParseMediaDuration(node), + IsCopyrighted = node.GetBool("isCopyrighted"), + IsOriginal = node.GetBool("original"), + IsPrivate = node.GetBool("private"), + PlayUrl = node.GetStringOrEmpty("playUrl"), + }; + } + + /// Extracts the music duration information, handling both precise and non-precise formats. + private static MediaDuration ParseMediaDuration(JsonNode node) + { + var seconds = node.GetNumber("duration"); + var preciseDurationNode = node["preciseDuration"]; + + return new MediaDuration + { + Seconds = seconds, + PreciseSeconds = preciseDurationNode?.GetOptionalNumber("preciseDuration") ?? seconds + }; + } + + /// Extracts the three music cover URL variants (Large, Medium, Thumb) from the music node. + private static ProfileImageVariants ParseMusicImageVariants(JsonNode music) + { + return new ProfileImageVariants + { + Larger = music.GetString("coverLarge"), + Medium = music.GetString("coverMedium"), + Small = music.GetString("coverThumb") + }; + } +} diff --git a/TiktokExplode.Infrastructure/TiktokExplode.Infrastructure.csproj b/TiktokExplode.Infrastructure/TiktokExplode.Infrastructure.csproj index 45a3b5c..d26eba5 100644 --- a/TiktokExplode.Infrastructure/TiktokExplode.Infrastructure.csproj +++ b/TiktokExplode.Infrastructure/TiktokExplode.Infrastructure.csproj @@ -12,7 +12,7 @@ TiktokExplode.Infrastructure - 1.1.1 + 1.2.0 Ts-Pytham Ts-Pytham Infrastructure layer for TiktokExplode: HTTP and Playwright-based page fetchers, TikTok video parser, CDN download client, and WAF-retry logic. diff --git a/TiktokExplode.IntegrationTests/Fixtures/PlaywrightFixture.cs b/TiktokExplode.IntegrationTests/Fixtures/PlaywrightFixture.cs new file mode 100644 index 0000000..2326e41 --- /dev/null +++ b/TiktokExplode.IntegrationTests/Fixtures/PlaywrightFixture.cs @@ -0,0 +1,40 @@ +namespace TiktokExplode.IntegrationTests.Fixtures; + +public sealed class PlaywrightFixture +{ + public PlaywrightFixture() + { + try + { + using var playwright = Microsoft.Playwright.Playwright.CreateAsync() + .GetAwaiter() + .GetResult(); + + var browser = playwright.Chromium + .LaunchAsync(new() + { + Headless = true + }) + .GetAwaiter() + .GetResult(); + + browser.CloseAsync() + .GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + """ + Playwright is installed but Chromium is missing. + + Run: + + pwsh playwright.ps1 install + + before executing integration tests. + """, + ex); + } + } +} diff --git a/TiktokExplode.IntegrationTests/PlaywrightCollection.cs b/TiktokExplode.IntegrationTests/PlaywrightCollection.cs new file mode 100644 index 0000000..54518cb --- /dev/null +++ b/TiktokExplode.IntegrationTests/PlaywrightCollection.cs @@ -0,0 +1,6 @@ +namespace TiktokExplode.IntegrationTests; + +[CollectionDefinition("Playwright")] +public sealed class PlaywrightCollection : ICollectionFixture +{ +} diff --git a/TiktokExplode.IntegrationTests/Search/PlaywrightSearchFetcherTests.cs b/TiktokExplode.IntegrationTests/Search/PlaywrightSearchFetcherTests.cs new file mode 100644 index 0000000..2afd5dc --- /dev/null +++ b/TiktokExplode.IntegrationTests/Search/PlaywrightSearchFetcherTests.cs @@ -0,0 +1,62 @@ +namespace TiktokExplode.IntegrationTests.Search; + +[Collection("Playwright")] +public class PlaywrightSearchFetcherTests() +{ + [Fact] + public async Task FetchSearchAsync_Should_Return_First_Page() + { + var fetcher = new PlaywrightSearchFetcher(); + + await foreach (var page in fetcher.FetchSearchAsync("lalala")) + { + page.JsonContent.Should().NotBeNullOrWhiteSpace(); + + var root = JsonNode.Parse(page.JsonContent); + + root.Should().NotBeNull(); + + break; + } + } + + [Fact] + public async Task FetchSearchAsync_Should_Load_Next_Page_When_HasMore_Is_True() + { + var fetcher = new PlaywrightSearchFetcher(); + + var pages = new List(); + + await foreach (var page in fetcher.FetchSearchAsync("lalala")) + { + pages.Add(page); + + if (pages.Count >= 2) + break; + } + + var firstRoot = JsonNode.Parse(pages[0].JsonContent); + + var hasMore = firstRoot?["has_more"]?.GetValue() ?? 0; + + if (hasMore == 1) + { + pages.Should().HaveCountGreaterThan(1); + } + } + + [Fact] + public async Task FetchSearchAsync_Should_Return_Cookies() + { + var fetcher = new PlaywrightSearchFetcher(); + + await foreach (var page in fetcher.FetchSearchAsync("lalala")) + { + page.Cookies.Should().NotBeEmpty(); + page.Cookies.Should().Contain( + c => c.Name.Equals("msToken", StringComparison.OrdinalIgnoreCase)); + + break; + } + } +} diff --git a/TiktokExplode.IntegrationTests/Search/TiktokSearchClientTests.cs b/TiktokExplode.IntegrationTests/Search/TiktokSearchClientTests.cs new file mode 100644 index 0000000..c8fa3cc --- /dev/null +++ b/TiktokExplode.IntegrationTests/Search/TiktokSearchClientTests.cs @@ -0,0 +1,25 @@ +namespace TiktokExplode.IntegrationTests.Search; + +[Collection("Playwright")] +public class TiktokSearchClientTests +{ + [Fact] + public async Task SearchAsync_Should_Return_Media() + { + var client = new TiktokSearchClient(); + + var media = new List(); + + await foreach (var item in client.SearchAsync("lalala")) + { + media.Add(item); + + if (media.Count >= 5) + break; + } + + media.Should().NotBeEmpty(); + media.Should().OnlyContain(m => + !string.IsNullOrWhiteSpace(m.Id)); + } +} diff --git a/TiktokExplode.IntegrationTests/TiktokExplode.IntegrationTests.csproj b/TiktokExplode.IntegrationTests/TiktokExplode.IntegrationTests.csproj new file mode 100644 index 0000000..4b908a4 --- /dev/null +++ b/TiktokExplode.IntegrationTests/TiktokExplode.IntegrationTests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/TiktokExplode.IntegrationTests/Usings.cs b/TiktokExplode.IntegrationTests/Usings.cs new file mode 100644 index 0000000..fa76cd1 --- /dev/null +++ b/TiktokExplode.IntegrationTests/Usings.cs @@ -0,0 +1,6 @@ +global using FluentAssertions; +global using TiktokExplode.Domain.Entities; +global using System.Text.Json.Nodes; +global using TiktokExplode.Infrastructure.Fetchers.Search; +global using TiktokExplode.IntegrationTests.Fixtures; +global using TiktokExplode.Infrastructure.Clients; \ No newline at end of file diff --git a/TiktokExplode.NET.slnx b/TiktokExplode.NET.slnx index c36b719..d4d0750 100644 --- a/TiktokExplode.NET.slnx +++ b/TiktokExplode.NET.slnx @@ -1,8 +1,9 @@ - - - - + + + + + diff --git a/TiktokExplode.Tests/Domain/Utilities/TikTokUrlValidatorTests.cs b/TiktokExplode.Tests/Domain/Utilities/TikTokUrlValidatorTests.cs index 34e044f..909b1f4 100644 --- a/TiktokExplode.Tests/Domain/Utilities/TikTokUrlValidatorTests.cs +++ b/TiktokExplode.Tests/Domain/Utilities/TikTokUrlValidatorTests.cs @@ -6,61 +6,61 @@ namespace TiktokExplode.Tests.Domain.Utilities; public class TikTokUrlValidatorTests { [Theory] - [InlineData("https://www.tiktok.com/@user/video/1234567890123456789", "standard www video")] - [InlineData("https://tiktok.com/@user/video/1234567890123456789", "no-www video")] - [InlineData("https://vm.tiktok.com/ZMkAbCdEf/", "vm short link")] - [InlineData("https://vt.tiktok.com/ZMkAbCdEf/", "vt short link")] - [InlineData("https://www.tiktok.com/@user/video/1234567890123456789?lang=en","with query string")] - [InlineData("https://www.tiktok.com/@user/video/1234567890123456789#comments","with fragment")] - [InlineData("https://www.tiktok.com/@user.name/video/1234567890123456789", "username with dots")] - [InlineData("https://www.tiktok.com/@user_123/video/1234567890123456789", "username with underscores")] + [InlineData("https://www.tiktok.com/@user/video/1234567890123456789", "standard www video")] + [InlineData("https://tiktok.com/@user/video/1234567890123456789", "no-www video")] + [InlineData("https://vm.tiktok.com/ZMkAbCdEf/", "vm short link")] + [InlineData("https://vt.tiktok.com/ZMkAbCdEf/", "vt short link")] + [InlineData("https://www.tiktok.com/@user/video/1234567890123456789?lang=en", "with query string")] + [InlineData("https://www.tiktok.com/@user/video/1234567890123456789#comments", "with fragment")] + [InlineData("https://www.tiktok.com/@user.name/video/1234567890123456789", "username with dots")] + [InlineData("https://www.tiktok.com/@user_123/video/1234567890123456789", "username with underscores")] [InlineData("https://www.tiktok.com/@user/video/1234567890123456789?lang=en&source=share", "multiple query params")] public void Should_NotThrow_When_UrlIsValid(string url, string _) { - var act = () => TikTokUrlValidator.Validate(url); + var act = () => TiktokUrlValidator.Validate(url); act.Should().NotThrow(); } [Theory] - [InlineData("http://www.tiktok.com/@user/video/123", "plain HTTP")] - [InlineData("ftp://www.tiktok.com/@user/video/123", "FTP")] - [InlineData("ws://www.tiktok.com/@user/video/123", "WebSocket")] + [InlineData("http://www.tiktok.com/@user/video/123", "plain HTTP")] + [InlineData("ftp://www.tiktok.com/@user/video/123", "FTP")] + [InlineData("ws://www.tiktok.com/@user/video/123", "WebSocket")] [InlineData("file:///www.tiktok.com/@user/video/123", "file scheme")] public void Should_ThrowArgumentException_When_SchemeIsNotHttps(string url, string _) { - var act = () => TikTokUrlValidator.Validate(url); + var act = () => TiktokUrlValidator.Validate(url); act.Should().ThrowExactly() .WithParameterName(nameof(url)); } [Theory] - [InlineData("https://youtube.com/watch?v=abc", "YouTube")] - [InlineData("https://instagram.com/reel/abc", "Instagram")] - [InlineData("https://twitter.com/user/status/123", "Twitter")] - [InlineData("https://tiktok.com.evil.com/@user/video/123", "domain spoofing suffix")] - [InlineData("https://eviltiktok.com/@user/video/123", "domain spoofing prefix")] - [InlineData("https://subdomain.tiktok.com/@user/video/123", "unknown subdomain")] - [InlineData("https://subdomain.www.tiktok.com/@user/video/123", "double subdomain")] - [InlineData("https://m.tiktok.com/@user/video/123", "mobile subdomain (not registered)")] + [InlineData("https://youtube.com/watch?v=abc", "YouTube")] + [InlineData("https://instagram.com/reel/abc", "Instagram")] + [InlineData("https://twitter.com/user/status/123", "Twitter")] + [InlineData("https://tiktok.com.evil.com/@user/video/123", "domain spoofing suffix")] + [InlineData("https://eviltiktok.com/@user/video/123", "domain spoofing prefix")] + [InlineData("https://subdomain.tiktok.com/@user/video/123", "unknown subdomain")] + [InlineData("https://subdomain.www.tiktok.com/@user/video/123", "double subdomain")] + [InlineData("https://m.tiktok.com/@user/video/123", "mobile subdomain (not registered)")] public void Should_ThrowArgumentException_When_HostIsNotRecognizedTikTokDomain(string url, string _) { - var act = () => TikTokUrlValidator.Validate(url); + var act = () => TiktokUrlValidator.Validate(url); act.Should().ThrowExactly() .WithParameterName(nameof(url)); } [Theory] - [InlineData("not-a-url", "plain string")] - [InlineData("/relative/path", "relative path")] - [InlineData("@user/video/123", "relative tiktok-like path")] - [InlineData("", "empty string")] - [InlineData(" ", "whitespace only")] + [InlineData("not-a-url", "plain string")] + [InlineData("/relative/path", "relative path")] + [InlineData("@user/video/123", "relative tiktok-like path")] + [InlineData("", "empty string")] + [InlineData(" ", "whitespace only")] public void Should_ThrowArgumentException_When_UrlIsNotAbsolute(string url, string _) { - var act = () => TikTokUrlValidator.Validate(url); + var act = () => TiktokUrlValidator.Validate(url); act.Should().ThrowExactly() .WithParameterName(nameof(url)); @@ -69,7 +69,7 @@ public void Should_ThrowArgumentException_When_UrlIsNotAbsolute(string url, stri [Fact] public void Should_ThrowArgumentException_When_UrlIsNull() { - var act = () => TikTokUrlValidator.Validate(null!); + var act = () => TiktokUrlValidator.Validate(null!); act.Should().ThrowExactly() .WithParameterName("url"); diff --git a/TiktokExplode.Tests/Infrastructure/Clients/TiktokClientTests.cs b/TiktokExplode.Tests/Infrastructure/Clients/TiktokClientTests.cs index 8b8b2dd..fe97f0d 100644 --- a/TiktokExplode.Tests/Infrastructure/Clients/TiktokClientTests.cs +++ b/TiktokExplode.Tests/Infrastructure/Clients/TiktokClientTests.cs @@ -17,7 +17,7 @@ public class TiktokClientTests public async Task Should_ThrowArgumentException_When_UrlIsInvalid() { var fetcher = Substitute.For(); - await using var client = new TiktokClient(fetcher, new TikTokOptions()); + await using var client = new TiktokClient(fetcher, new TiktokOptions()); var act = async () => await client.GetVideoAsync("not-a-tiktok-url"); @@ -31,7 +31,7 @@ public async Task Should_ThrowArgumentException_When_UrlIsInvalid() public async Task Should_RetryAndSucceed_When_FetcherThrowsWafOnFirstAttemptOnly() { var fetcher = Substitute.For(); - var options = new TikTokOptions { MaxWafRetries = 2, RetryBaseDelay = TimeSpan.Zero }; + var options = new TiktokOptions { MaxWafRetries = 2, RetryBaseDelay = TimeSpan.Zero }; fetcher.FetchPageAsync(Arg.Any(), Arg.Any()) .Returns( @@ -51,7 +51,7 @@ public async Task Should_RetryAndSucceed_When_FetcherThrowsWafOnFirstAttemptOnly public async Task Should_ThrowTiktokWafException_When_AllRetriesAreExhausted() { var fetcher = Substitute.For(); - var options = new TikTokOptions { MaxWafRetries = 2, RetryBaseDelay = TimeSpan.Zero }; + var options = new TiktokOptions { MaxWafRetries = 2, RetryBaseDelay = TimeSpan.Zero }; fetcher.FetchPageAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromException(new TiktokWafException())); @@ -72,7 +72,7 @@ public async Task Should_CallFetcherExactlyMaxRetriesPlusOneTimes_When_WafAlways int expectedCalls) { var fetcher = Substitute.For(); - var options = new TikTokOptions { MaxWafRetries = maxWafRetries, RetryBaseDelay = TimeSpan.Zero }; + var options = new TiktokOptions { MaxWafRetries = maxWafRetries, RetryBaseDelay = TimeSpan.Zero }; fetcher.FetchPageAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromException(new TiktokWafException())); diff --git a/TiktokExplode.Tests/Infrastructure/Parsers/TikTokSearchParserTests.cs b/TiktokExplode.Tests/Infrastructure/Parsers/TikTokSearchParserTests.cs new file mode 100644 index 0000000..06e2e15 --- /dev/null +++ b/TiktokExplode.Tests/Infrastructure/Parsers/TikTokSearchParserTests.cs @@ -0,0 +1,274 @@ +using FluentAssertions; +using NSubstitute; +using System.Runtime.CompilerServices; +using TiktokExplode.Domain.Entities; +using TiktokExplode.Domain.Exceptions; +using TiktokExplode.Infrastructure.Clients; +using TiktokExplode.Infrastructure.Fetchers.Search; + +namespace TiktokExplode.Tests.Infrastructure.Parsers; + +public class TikTokSearchParserTests +{ + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static TiktokSearchClient CreateClient(string jsonContent) + { + var fetcher = Substitute.For(); + fetcher + .FetchSearchAsync(Arg.Any(), Arg.Any()) + .Returns(YieldResult(new SearchFetchResult { JsonContent = jsonContent, Cookies = [] })); + return new TiktokSearchClient(fetcher); + } + + private static async IAsyncEnumerable YieldResult( + SearchFetchResult result, + [EnumeratorCancellation] CancellationToken _ = default) + { + await Task.Yield(); + yield return result; + } + + private static async Task> CollectAsync(IAsyncEnumerable source) + { + var list = new List(); + await foreach (var item in source) + list.Add(item); + return list; + } + + // ── Type dispatch ───────────────────────────────────────────────────────── + + [Fact] + public async Task Should_ReturnVideo_When_SearchItemHasNoImagePost() + { + await using var client = CreateClient(TikTokFixtures.ValidVideoSearchJson); + + var results = await CollectAsync(client.SearchAsync("test")); + + results.Should().ContainSingle() + .Which.Should().BeOfType -public interface IVideoClient : IAsyncDisposable +public interface IVideoClient : IDownloadClient { /// /// Retrieves full metadata for the TikTok video at . @@ -15,33 +14,9 @@ public interface IVideoClient : IAsyncDisposable /// The absolute TikTok video URL (e.g. https://www.tiktok.com/@user/video/123). /// Token to cancel the operation. /// A instance containing all available metadata. - /// Thrown when is not a valid TikTok URL. + /// Thrown when is not a valid TikTok URL. /// Thrown when all WAF retry attempts are exhausted. /// Thrown when the video does not exist or is private. /// Thrown when the page structure cannot be parsed. Task public bool IsPrivate { get; init; } - /// The date and time (UTC) when the account was created. - public DateTimeOffset CreatedAt { get; init; } + /// The date and time (UTC) when the account was created, if available. + public DateTimeOffset? CreatedAt { get; init; } /// Avatar image URLs in multiple resolutions. public ProfileImageVariants Avatar { get; init; } = new(); diff --git a/TiktokExplode/Domain/Entities/Carousel.cs b/TiktokExplode/Domain/Entities/Carousel.cs new file mode 100644 index 0000000..ebcfd8f --- /dev/null +++ b/TiktokExplode/Domain/Entities/Carousel.cs @@ -0,0 +1,19 @@ +using TiktokExplode.Domain.ValueObjects.Carousels; + +namespace TiktokExplode.Domain.Entities; + +/// +/// Represents a TikTok image carousel post, which contains multiple images in a single post. +/// +public sealed class Carousel : Media +{ + /// + /// The content of the carousel post, including all images and related metadata + /// such as the total number of images, the index of the currently displayed image, + /// and any captions or descriptions for each image. + /// + public CarouselPost Post { get; init; } = new(); + + /// The URL of the carousel post on TikTok's website. + public override string Url => $"https://www.tiktok.com/@{Author.UniqueId}/photo/{Id}"; +} diff --git a/TiktokExplode/Domain/Entities/Media.cs b/TiktokExplode/Domain/Entities/Media.cs new file mode 100644 index 0000000..401ada5 --- /dev/null +++ b/TiktokExplode/Domain/Entities/Media.cs @@ -0,0 +1,43 @@ +using TiktokExplode.Domain.ValueObjects.Media; + +namespace TiktokExplode.Domain.Entities; + +/// +/// Base type for all TikTok media content, exposing metadata shared across videos +/// and slideshow (carousel) posts. +/// +public abstract class Media +{ + /// The unique TikTok-assigned numeric identifier for this video. + public string Id { get; init; } = string.Empty; + + /// The URL of the media on TikTok's website. + public abstract string Url { get; } + + /// The caption or description text of the video, as written by the author. + public string Description { get; init; } = string.Empty; + + /// The author who posted the video. + public Author Author { get; init; } = new(); + + /// The detected primary language of the video's text content. + public MediaLanguage Language { get; init; } = new(); + + /// Engagement statistics for this video (views, likes, comments, etc.). + public MediaStats Stats { get; init; } = new(); + + /// The location tag associated with the video. Empty string if not set. + public string Location { get; init; } = string.Empty; + + /// The date and time (UTC) when the video was published. + public DateTimeOffset CreatedAt { get; init; } + + /// Cover thumbnail URLs for this video — static frame and animated (looping) preview. + public MediaCover Cover { get; init; } = new(); + + /// + /// The music or sound associated with the video, including track metadata and artwork. + /// + public MediaMusic Music { get; init; } = new(); + +} diff --git a/TiktokExplode/Domain/Entities/Video.cs b/TiktokExplode/Domain/Entities/Video.cs index 61efa5f..ee27e32 100644 --- a/TiktokExplode/Domain/Entities/Video.cs +++ b/TiktokExplode/Domain/Entities/Video.cs @@ -6,35 +6,14 @@ namespace TiktokExplode.Domain.Entities; /// Represents a TikTok video with all its associated metadata. /// This is the root entity returned by IVideoClient.GetVideoAsync. /// -public sealed class Video +public sealed class Video : Media { - /// The unique TikTok-assigned numeric identifier for this video. - public string Id { get; init; } = string.Empty; - - /// The caption or description text of the video, as written by the author. - public string Description { get; init; } = string.Empty; - - /// The author who posted the video. - public Author Author { get; init; } = new(); - - /// The detected primary language of the video's text content. - public VideoLanguage Language { get; init; } = new(); - - /// Engagement statistics for this video (views, likes, comments, etc.). - public VideoStats Stats { get; init; } = new(); - /// Technical information about the video (resolution, bitrates, download URLs). public VideoInfo Info { get; init; } = new(); - /// The location tag associated with the video. Empty string if not set. - public string Location { get; init; } = string.Empty; - - /// The date and time (UTC) when the video was published. - public DateTimeOffset CreatedAt { get; init; } - /// The playback duration of the video. public VideoDuration Duration { get; init; } - /// Cover thumbnail URLs for this video — static frame and animated (looping) preview. - public VideoCover Cover { get; init; } = new(); + /// The URL of the video on TikTok's website. + public override string Url => $"https://www.tiktok.com/@{Author.UniqueId}/video/{Id}"; } diff --git a/TiktokExplode/Domain/Utilities/TikTokUrlValidator.cs b/TiktokExplode/Domain/Utilities/TikTokUrlValidator.cs index 4ce6626..1317e25 100644 --- a/TiktokExplode/Domain/Utilities/TikTokUrlValidator.cs +++ b/TiktokExplode/Domain/Utilities/TikTokUrlValidator.cs @@ -3,7 +3,7 @@ namespace TiktokExplode.Domain.Utilities; /// /// Validates TikTok video URLs before they are used for network requests. /// -public static class TikTokUrlValidator +public static class TiktokUrlValidator { /// /// Set of recognized TikTok hostnames, including short-link variants diff --git a/TiktokExplode/Domain/ValueObjects/Carousels/CarouselImage.cs b/TiktokExplode/Domain/ValueObjects/Carousels/CarouselImage.cs new file mode 100644 index 0000000..f378774 --- /dev/null +++ b/TiktokExplode/Domain/ValueObjects/Carousels/CarouselImage.cs @@ -0,0 +1,23 @@ +namespace TiktokExplode.Domain.ValueObjects.Carousels; + +/// +/// Represents an individual image within a TikTok carousel post, +/// including its dimensions and available URLs for different resolutions. +/// +public sealed class CarouselImage +{ + /// + /// The width of the image in pixels. This can be used to determine the aspect ratio. + /// + public uint Width { get; init; } + + /// + /// The height of the image in pixels. This can be used to determine the aspect ratio. + /// + public uint Height { get; init; } + + /// + /// A list of URLs for the image at different resolutions. + /// + public IReadOnlyList Urls { get; init; } = []; +} diff --git a/TiktokExplode/Domain/ValueObjects/Carousels/CarouselPost.cs b/TiktokExplode/Domain/ValueObjects/Carousels/CarouselPost.cs new file mode 100644 index 0000000..3167710 --- /dev/null +++ b/TiktokExplode/Domain/ValueObjects/Carousels/CarouselPost.cs @@ -0,0 +1,23 @@ +namespace TiktokExplode.Domain.ValueObjects.Carousels; + +/// +/// Represents the content of a TikTok image carousel post, including all images and related metadata +/// +public sealed class CarouselPost +{ + /// + /// The total number of images in the carousel. This indicates how many images are included in the post. + /// + public string Title { get; init; } = string.Empty; + + /// + /// The index of the currently displayed image in the carousel. This is a 1-based index + /// indicating which image is currently being viewed by the user. + /// + public CarouselImage Cover { get; init; } = new(); + + /// + /// A list of all images included in the carousel post, along with their metadata such as dimensions and URLs. + /// + public IReadOnlyList Images { get; init; } = []; +} diff --git a/TiktokExplode/Domain/ValueObjects/Videos/VideoCover.cs b/TiktokExplode/Domain/ValueObjects/Media/MediaCover.cs similarity index 87% rename from TiktokExplode/Domain/ValueObjects/Videos/VideoCover.cs rename to TiktokExplode/Domain/ValueObjects/Media/MediaCover.cs index 689672f..d3e9238 100644 --- a/TiktokExplode/Domain/ValueObjects/Videos/VideoCover.cs +++ b/TiktokExplode/Domain/ValueObjects/Media/MediaCover.cs @@ -1,11 +1,11 @@ -namespace TiktokExplode.Domain.ValueObjects.Videos; +namespace TiktokExplode.Domain.ValueObjects.Media; /// -/// Represents the cover images for a video, including both static and animated versions. +/// Represents the cover images for a media, including both static and animated versions. /// /// The StaticUrl property holds the URL for the static cover image, while the AnimatedUrl property holds /// the URL for the animated cover image. Both properties are initialized to empty strings. -public sealed record VideoCover +public sealed record MediaCover { /// /// Gets the URL for static resources used by the application. diff --git a/TiktokExplode/Domain/ValueObjects/Media/MediaDuration.cs b/TiktokExplode/Domain/ValueObjects/Media/MediaDuration.cs new file mode 100644 index 0000000..d18dc98 --- /dev/null +++ b/TiktokExplode/Domain/ValueObjects/Media/MediaDuration.cs @@ -0,0 +1,19 @@ +namespace TiktokExplode.Domain.ValueObjects.Media; + +/// +/// Represents the duration of a TikTok media resource, including a whole-second value +/// and a precise fractional value when TikTok provides one. +/// +public readonly record struct MediaDuration +{ + /// + /// Gets the duration in whole seconds. + /// + public int Seconds { get; init; } + + /// + /// Gets the duration in seconds, including fractional precision when TikTok provides it; + /// otherwise the same value as . + /// + public double PreciseSeconds { get; init; } +} diff --git a/TiktokExplode/Domain/ValueObjects/Videos/VideoLanguage.cs b/TiktokExplode/Domain/ValueObjects/Media/MediaLanguage.cs similarity index 63% rename from TiktokExplode/Domain/ValueObjects/Videos/VideoLanguage.cs rename to TiktokExplode/Domain/ValueObjects/Media/MediaLanguage.cs index ec01bd5..b713ce5 100644 --- a/TiktokExplode/Domain/ValueObjects/Videos/VideoLanguage.cs +++ b/TiktokExplode/Domain/ValueObjects/Media/MediaLanguage.cs @@ -1,18 +1,18 @@ -namespace TiktokExplode.Domain.ValueObjects.Videos; +namespace TiktokExplode.Domain.ValueObjects.Media; /// -/// Represents the language properties detected for a TikTok video's text content. +/// Represents the language properties detected for a TikTok media's text content. /// -public sealed record VideoLanguage +public sealed record MediaLanguage { /// - /// The BCP-47 language code of the primary language detected in the video + /// The BCP-47 language code of the primary language detected in the media /// (e.g. "en", "es"). Empty string if not detected. /// public string PrimaryLanguage { get; init; } = string.Empty; /// - /// if TikTok considers the video's text eligible + /// if TikTok considers the media's text eligible /// for automatic translation; otherwise . /// public bool IsTranslatable { get; init; } diff --git a/TiktokExplode/Domain/ValueObjects/Media/MediaMusic.cs b/TiktokExplode/Domain/ValueObjects/Media/MediaMusic.cs new file mode 100644 index 0000000..61ac24d --- /dev/null +++ b/TiktokExplode/Domain/ValueObjects/Media/MediaMusic.cs @@ -0,0 +1,48 @@ +using TiktokExplode.Domain.ValueObjects.Authors; + +namespace TiktokExplode.Domain.ValueObjects.Media; + +/// +/// Represents the music or sound associated with a TikTok media. +/// +public sealed class MediaMusic +{ + /// The unique TikTok-assigned identifier for the music or sound. + public string Id { get; init; } = string.Empty; + + /// The album name associated with the music, if available. + public string AlbumName { get; init; } = string.Empty; + + /// The title of the music or original sound. + public string Title { get; init; } = string.Empty; + + /// The display name of the music author or original sound creator. + public string AuthorName { get; init; } = string.Empty; + + /// Image URLs associated with the music in multiple resolutions. + public ProfileImageVariants Images { get; init; } = new(); + + /// The duration of the associated music or sound. + public MediaDuration Duration { get; init; } + + /// + /// if TikTok marks the music as copyrighted; + /// otherwise . + /// + public bool IsCopyrighted { get; init; } + + /// + /// if this is an original sound; + /// otherwise . + /// + public bool IsOriginal { get; init; } + + /// + /// if the music is private or unavailable; + /// otherwise . + /// + public bool IsPrivate { get; init; } + + /// CDN URL used to play the music or sound audio, if available. + public string PlayUrl { get; init; } = string.Empty; +} diff --git a/TiktokExplode/Domain/ValueObjects/Media/MediaStats.cs b/TiktokExplode/Domain/ValueObjects/Media/MediaStats.cs new file mode 100644 index 0000000..dd1056f --- /dev/null +++ b/TiktokExplode/Domain/ValueObjects/Media/MediaStats.cs @@ -0,0 +1,25 @@ +namespace TiktokExplode.Domain.ValueObjects.Media; + +/// +/// Aggregated engagement statistics for a TikTok media. +/// +public sealed record MediaStats +{ + /// Total number of times the media has been played. + public long Views { get; init; } + + /// Total number of likes ("hearts") the media has received. + public long Likes { get; init; } + + /// Total number of comments posted on the media. + public long Comments { get; init; } + + /// Total number of times the media has been shared. + public long Shares { get; init; } + + /// Total number of times the media has been saved to a favorites collection. + public long Favorites { get; init; } + + /// Total number of times the media has been reposted by other users. + public long Reposts { get; init; } +} diff --git a/TiktokExplode/Domain/ValueObjects/Videos/Bitrate.cs b/TiktokExplode/Domain/ValueObjects/Videos/Bitrate.cs index 348a90d..d2f79bc 100644 --- a/TiktokExplode/Domain/ValueObjects/Videos/Bitrate.cs +++ b/TiktokExplode/Domain/ValueObjects/Videos/Bitrate.cs @@ -4,24 +4,27 @@ namespace TiktokExplode.Domain.ValueObjects.Videos; /// /// Describes a single encoding variant available for a TikTok video. -/// Each video typically has multiple bitrate entries at different quality levels. +/// TikTok may return partial bitrate metadata depending on the source endpoint. /// public readonly record struct Bitrate { /// The bitrate of this encoding variant in bits per second. public int Value { get; init; } - /// The frames-per-second rate of this encoding variant. - public int FPS { get; init; } + /// + /// The frames-per-second rate of this encoding variant, if TikTok provides it. + /// + public int? FPS { get; init; } /// The parsed container format of this encoding variant. public BitrateFormat Format { get; init; } /// - /// The raw format string as returned by TikTok's API (e.g. "mp4", "mp3"). + /// The raw format string as returned by TikTok's API, if available + /// (e.g. "mp4", "mp3"). /// Use this when returns . /// - public string RawFormat { get; init; } + public string? RawFormat { get; init; } /// The codec type identifier string (e.g. "h264"). public string CodecType { get; init; } diff --git a/TiktokExplode/Domain/ValueObjects/Videos/VideoDuration.cs b/TiktokExplode/Domain/ValueObjects/Videos/VideoDuration.cs index 4fd7659..348e624 100644 --- a/TiktokExplode/Domain/ValueObjects/Videos/VideoDuration.cs +++ b/TiktokExplode/Domain/ValueObjects/Videos/VideoDuration.cs @@ -9,8 +9,8 @@ public readonly record struct VideoDuration public int Seconds { get; init; } /// - /// Precise playback duration in seconds as a fractional value (e.g. 15.48). - /// Sourced from the music.preciseDuration.preciseDuration field in TikTok's JSON. + /// Precise playback duration in seconds when TikTok provides fractional precision; + /// otherwise the same value as . /// public double PreciseSeconds { get; init; } } diff --git a/TiktokExplode/Domain/ValueObjects/Videos/VideoStats.cs b/TiktokExplode/Domain/ValueObjects/Videos/VideoStats.cs deleted file mode 100644 index a1833e4..0000000 --- a/TiktokExplode/Domain/ValueObjects/Videos/VideoStats.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace TiktokExplode.Domain.ValueObjects.Videos; - -/// -/// Aggregated engagement statistics for a TikTok video. -/// -public sealed record VideoStats -{ - /// Total number of times the video has been played. - public long Views { get; init; } - - /// Total number of likes ("hearts") the video has received. - public long Likes { get; init; } - - /// Total number of comments posted on the video. - public long Comments { get; init; } - - /// Total number of times the video has been shared. - public long Shares { get; init; } - - /// Total number of times the video has been saved to a favorites collection. - public long Favorites { get; init; } - - /// Total number of times the video has been reposted by other users. - public long Reposts { get; init; } -} diff --git a/TiktokExplode/TiktokExplode.csproj b/TiktokExplode/TiktokExplode.csproj index 293199c..44620ea 100644 --- a/TiktokExplode/TiktokExplode.csproj +++ b/TiktokExplode/TiktokExplode.csproj @@ -11,7 +11,7 @@ TiktokExplode - 1.1.1 + 1.2.0 Ts-Pytham Ts-Pytham Domain layer for TiktokExplode: strongly-typed models, interfaces, and utilities for working with TikTok video metadata. Zero external dependencies. @@ -25,8 +25,8 @@ - - + +