Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 0 additions & 60 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,74 +1,14 @@
name: CI

on:
pull_request_review:
types: [submitted]
workflow_dispatch:

permissions:
contents: read
pull-requests: read

jobs:
check-approval:
name: Check All Reviews Approved
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_review' && github.event.review.state == 'approved')
runs-on: ubuntu-latest
outputs:
approved: ${{ steps.check.outputs.approved || steps.dispatch.outputs.approved }}
steps:
- id: check
if: github.event_name == 'pull_request_review'
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;

// Get the current PR to check for pending review requests
const { data: currentPr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});

// If there are still pending review requests, not everyone has approved
if (currentPr.requested_reviewers.length > 0 || currentPr.requested_teams.length > 0) {
core.setOutput('approved', 'false');
core.info(`PR has ${currentPr.requested_reviewers.length} pending reviewer(s) and ${currentPr.requested_teams.length} pending team(s).`);
return;
}

// Get all reviews
const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});

// Get latest review state per reviewer (excluding the PR author and COMMENTED-only)
const latestByReviewer = new Map();
for (const review of reviews) {
if (review.user.login === currentPr.user.login) continue;
if (review.state === 'COMMENTED') continue;
latestByReviewer.set(review.user.login, review.state);
}

// All latest non-comment reviews must be APPROVED and there must be at least one
const states = [...latestByReviewer.values()];
const allApproved = states.length > 0 && states.every(s => s === 'APPROVED');
core.setOutput('approved', allApproved ? 'true' : 'false');
core.info(`Reviews: ${JSON.stringify(Object.fromEntries(latestByReviewer))} → allApproved=${allApproved}`);

- id: dispatch
if: github.event_name == 'workflow_dispatch'
run: echo "approved=true" >> "$GITHUB_OUTPUT"

build:
name: Build
needs: check-approval
if: needs.check-approval.outputs.approved == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down
70 changes: 2 additions & 68 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
name: Integration & Live Schema Tests

on:
pull_request_review:
types: [submitted]
workflow_dispatch:
inputs:
run_live_schema:
Expand All @@ -18,71 +16,11 @@ on:

permissions:
contents: read
pull-requests: read

jobs:
check-approval:
name: Check All Reviews Approved
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_review' && github.event.review.state == 'approved')
runs-on: ubuntu-latest
outputs:
approved: ${{ steps.check.outputs.approved || steps.dispatch.outputs.approved }}
steps:
- id: check
if: github.event_name == 'pull_request_review'
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;

// Get the current PR to check for pending review requests
const { data: currentPr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});

// If there are still pending review requests, not everyone has approved
if (currentPr.requested_reviewers.length > 0 || currentPr.requested_teams.length > 0) {
core.setOutput('approved', 'false');
core.info(`PR has ${currentPr.requested_reviewers.length} pending reviewer(s) and ${currentPr.requested_teams.length} pending team(s).`);
return;
}

// Get all reviews
const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});

// Get latest review state per reviewer (excluding the PR author and COMMENTED-only)
const latestByReviewer = new Map();
for (const review of reviews) {
if (review.user.login === currentPr.user.login) continue;
if (review.state === 'COMMENTED') continue;
latestByReviewer.set(review.user.login, review.state);
}

// All latest non-comment reviews must be APPROVED and there must be at least one
const states = [...latestByReviewer.values()];
const allApproved = states.length > 0 && states.every(s => s === 'APPROVED');
core.setOutput('approved', allApproved ? 'true' : 'false');
core.info(`Reviews: ${JSON.stringify(Object.fromEntries(latestByReviewer))} → allApproved=${allApproved}`);

- id: dispatch
if: github.event_name == 'workflow_dispatch'
run: echo "approved=true" >> "$GITHUB_OUTPUT"

integration-tests:
name: Integration Tests
needs: check-approval
if: >
needs.check-approval.outputs.approved == 'true' &&
(github.event_name == 'workflow_dispatch' && inputs.run_integration == true ||
github.event_name == 'pull_request_review')
if: inputs.run_integration == true
runs-on: ubuntu-latest
environment: testing
steps:
Expand Down Expand Up @@ -121,11 +59,7 @@ jobs:

live-schema-validation:
name: Live Schema Validation
needs: check-approval
if: >
needs.check-approval.outputs.approved == 'true' &&
(github.event_name == 'workflow_dispatch' && inputs.run_live_schema == true ||
github.event_name == 'pull_request_review')
if: inputs.run_live_schema == true
runs-on: ubuntu-latest
environment: testing
steps:
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,54 @@ catch (TorBoxException ex)
}
```

## Standalone Usage

For console apps, scripts, or scenarios where dependency injection is not needed, create the client directly:

```csharp
using TorBoxSDK;
using TorBoxSDK.Models.Common;
using TorBoxSDK.Models.User;

string apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY")
?? throw new InvalidOperationException("Set the TORBOX_API_KEY environment variable.");

using TorBoxClient client = new(apiKey);
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));

try
{
TorBoxResponse<UserProfile> me = await client.Main.User.GetMeAsync(cancellationToken: cts.Token);
Console.WriteLine($"Authenticated as: {me.Data?.Email}");
}
catch (TorBoxException ex)
{
Console.Error.WriteLine($"API error [{ex.ErrorCode}]: {ex.Detail ?? ex.Message}");
}
```

For more control over options:

```csharp
using TorBoxClient client = new(new TorBoxClientOptions
{
ApiKey = apiKey,
Timeout = TimeSpan.FromSeconds(60)
});
```

Or use the builder pattern:

```csharp
using TorBoxClient client = new(options =>
{
options.ApiKey = apiKey;
options.Timeout = TimeSpan.FromSeconds(60);
});
```

`TorBoxClient` implements `IDisposable`. Always use a `using` statement to ensure HTTP resources are released. In DI mode, the container manages disposal automatically.

## Client Hierarchy

The SDK is structured around a single root client with three API families:
Expand Down
24 changes: 23 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ flowchart TD
- **Search API**: search-oriented endpoints for torrents, usenet, metadata, Torznab, and Newznab
- **Relay API**: relay status and inactivity checks

## DI and instantiation
## Instantiation

`AddTorBox()` registers only `ITorBoxClient` in the DI container. All sub-clients (`MainApiClient`, `SearchApiClient`, `RelayApiClient`, and resource clients like `TorrentsClient`) are `internal` and instantiated by `TorBoxClient` itself. They are **not** individually resolvable from the service provider.

Expand All @@ -62,6 +62,28 @@ provider.GetService<IMainApiClient>(); // null
provider.GetService<ISearchApiClient>(); // null
```

`TorBoxClient` also supports standalone instantiation when dependency injection is not needed:

```csharp
using TorBoxClient client = new("your-api-key");

using TorBoxClient configuredClient = new(new TorBoxClientOptions
{
ApiKey = "your-api-key",
Timeout = TimeSpan.FromSeconds(60)
});

using TorBoxClient builtClient = new(options =>
{
options.ApiKey = "your-api-key";
options.Timeout = TimeSpan.FromSeconds(60);
});
```

The DI-focused constructor is marked with `[ActivatorUtilitiesConstructor]` so ASP.NET Core and other `Microsoft.Extensions.DependencyInjection` consumers choose the `IHttpClientFactory` + `IOptions<TorBoxClientOptions>` path automatically when resolving `ITorBoxClient`.

`TorBoxClient` implements `IDisposable`. In standalone mode, it owns and disposes the underlying `HttpClient` instances, so it should be wrapped in a `using` statement. In DI mode, `Dispose()` is a no-op because the container manages the HTTP client lifecycle.

## Cross-cutting behavior

- Authentication uses a Bearer token attached by an internal `DelegatingHandler`
Expand Down
34 changes: 32 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ TorBoxSDK is configured through `TorBoxClientOptions`.
| Property | Required | Default | Notes |
|---|---|---|---|
| `ApiKey` | Yes | — | Required for authenticated TorBox requests |
| `MainApiBaseUrl` | No | `https://api.torbox.app/v1/api/` | Trailing slash should be preserved |
| `MainApiBaseUrl` | No | `https://api.torbox.app/` | Host URL for the Main API. Trailing slash should be preserved. |
| `ApiVersion` | Yes | `v1` | Version segment used to compute versioned Main and Relay API URLs |
| `MainApiVersionedUrl` | — | Computed | Full Main API URL with version (e.g. `https://api.torbox.app/v1/api/`). Read-only. |
| `SearchApiBaseUrl` | No | `https://search-api.torbox.app/` | Trailing slash should be preserved |
| `RelayApiBaseUrl` | No | `https://relay.torbox.app/` | Trailing slash should be preserved |
| `RelayApiBaseUrl` | No | `https://relay.torbox.app/` | Host URL for the Relay API. Trailing slash should be preserved. |
| `RelayApiVersionedUrl` | — | Computed | Full Relay API URL with version (e.g. `https://relay.torbox.app/v1/`). Read-only. |
| `Timeout` | No | `00:00:30` | Applied to all configured `HttpClient` instances |

## Configure with code
Expand Down Expand Up @@ -43,6 +46,33 @@ The SDK binds from the `TorBox` section:
}
```

## Configure without DI

When using standalone mode, pass options directly to the constructor:

```csharp
// API key only (default settings)
using TorBoxClient client = new("your-api-key");

// Full options object
using TorBoxClient client = new(new TorBoxClientOptions
{
ApiKey = "your-api-key",
MainApiBaseUrl = "https://api.torbox.app/",
ApiVersion = "v1",
SearchApiBaseUrl = "https://search-api.torbox.app/",
RelayApiBaseUrl = "https://relay.torbox.app/",
Timeout = TimeSpan.FromSeconds(60)
});

// Configuration delegate
using TorBoxClient client = new(options =>
{
options.ApiKey = "your-api-key";
options.Timeout = TimeSpan.FromMinutes(2);
});
```

## Registration overloads

- `AddTorBox(Action<TorBoxClientOptions>)`
Expand Down
28 changes: 28 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,34 @@ using ServiceProvider provider = services.BuildServiceProvider();
ITorBoxClient client = provider.GetRequiredService<ITorBoxClient>();
```

## Use without dependency injection

For console apps, scripts, or environments without a DI container, create the client directly:

```csharp
using TorBoxSDK;

string apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY")
?? throw new InvalidOperationException("Set the TORBOX_API_KEY environment variable.");

using TorBoxClient client = new(apiKey);
```

You can also pass a `TorBoxClientOptions` instance or a configuration delegate:

```csharp
using TorBoxClient client = new(new TorBoxClientOptions
{
ApiKey = apiKey,
Timeout = TimeSpan.FromSeconds(60)
});
```

`TorBoxClient` implements `IDisposable`. Always use a `using` statement to ensure HTTP clients are properly released. In DI mode, the container manages the lifecycle automatically.

> **When to choose standalone vs DI?**
> Use standalone for simple console tools, scripts, and one-off programs. Use DI for ASP.NET Core apps, hosted services, and anything with `IServiceCollection`.

## Make your first requests

```csharp
Expand Down
Loading