feat: implement TorrentClaw Go API client v0.1.0
Some checks failed
CI / Test (push) Failing after 1s
CI / Test-1 (push) Failing after 1s
CI / Test-2 (push) Failing after 1s
CI / Lint (push) Failing after 1s
CI / Vet (push) Failing after 1s

This commit is contained in:
Deivid Soto 2026-03-28 11:28:48 +01:00
commit f6f24c2c3f
39 changed files with 5067 additions and 0 deletions

77
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,77 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.22", "1.23", "1.24"]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Run tests
run: go test -v -race -count=1 ./...
- name: Run tests with coverage
if: matrix.go-version == '1.24'
run: |
go test -race -coverprofile=coverage.out -covermode=atomic ./...
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Total coverage: ${COVERAGE}%"
if [ "$(echo "$COVERAGE < 90" | bc)" -eq 1 ]; then
echo "::error::Coverage ${COVERAGE}% is below 90% threshold"
exit 1
fi
- name: Upload coverage
if: matrix.go-version == '1.24'
uses: codecov/codecov-action@v4
with:
files: coverage.out
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
vet:
name: Vet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Run go vet
run: go vet ./...

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary
*.test
# Output of go coverage
*.out
coverage.html
# Go workspace
go.work
go.work.sum
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

View file

@ -0,0 +1,37 @@
#!/bin/bash
# Validate commit message follows Conventional Commits format.
# Allowed types: feat, fix, docs, test, chore, refactor, ci, style, perf, build
#
# Valid examples:
# feat: add search by genre
# fix(client): handle nil response
# docs: update README
# feat!: breaking change in API
commit_msg_file="$1"
commit_msg=$(head -1 "$commit_msg_file")
# Allow merge commits
if echo "$commit_msg" | grep -qE '^Merge '; then
exit 0
fi
# Conventional Commits regex:
# type(optional-scope)optional-!: description
pattern='^(feat|fix|docs|test|chore|refactor|ci|style|perf|build)(\([a-zA-Z0-9_-]+\))?!?: .+'
if ! echo "$commit_msg" | grep -qE "$pattern"; then
echo "ERROR: Commit message does not follow Conventional Commits format."
echo ""
echo " Expected: <type>[optional scope]: <description>"
echo ""
echo " Allowed types: feat, fix, docs, test, chore, refactor, ci, style, perf, build"
echo ""
echo " Examples:"
echo " feat: add search by genre"
echo " fix(client): handle nil response body"
echo " docs: update API reference"
echo ""
echo " Your message: $commit_msg"
exit 1
fi

29
CHANGELOG.md Normal file
View file

@ -0,0 +1,29 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2025-01-15
### Added
- Initial release of the TorrentClaw Go client library.
- `Search` — full-text search with advanced filtering (type, genre, year, quality, language, audio, HDR, sort, pagination, country, locale, availability).
- `Autocomplete` — title suggestions for search-as-you-type.
- `Popular` — trending content by community engagement.
- `Recent` — recently added movies and TV shows.
- `WatchProviders` — streaming availability (flatrate, rent, buy, free) with VPN suggestions.
- `Credits` — director and top cast members.
- `Stats` — aggregator statistics (content counts, torrent counts, ingestion history).
- `GetTorrentFile` — download raw `.torrent` file bytes.
- `TorrentDownloadURL` — construct download URL without making an HTTP call.
- Functional options pattern for client configuration (`WithAPIKey`, `WithBaseURL`, `WithTimeout`, `WithRetry`, `WithHTTPClient`, `WithUserAgent`).
- Exponential backoff retry for transient errors (429, 5xx).
- Custom `APIError` type with helper methods (`IsRetryable`, `IsRateLimited`, `IsNotFound`).
- Context support on all methods.
- Zero external dependencies (stdlib only).
- Comprehensive test suite with `httptest`.
- Example tests for godoc.
- CI workflow with GitHub Actions.

148
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,148 @@
# Contributing to TorrentClaw Go Client
Thank you for your interest in contributing! This guide will help you get started.
## Getting Started
1. **Fork** the repository on GitHub
2. **Clone** your fork locally:
```bash
git clone https://github.com/YOUR-USERNAME/torrentclaw-go-client.git
cd torrentclaw-go-client
```
3. **Create a branch** for your change:
```bash
git checkout -b feature/my-feature
```
4. **Make your changes**, write tests, and ensure everything passes
5. **Commit** with a clear message (see [Commit Messages](#commit-messages))
6. **Push** to your fork and [open a Pull Request](https://github.com/torrentclaw/go-client/compare)
## Development Setup
You need **Go 1.22+** installed.
### Git Hooks (Lefthook)
This project uses [Lefthook](https://github.com/evilmartians/lefthook) to run pre-commit checks and validate commit messages automatically.
```bash
# Install lefthook (pick one):
brew install lefthook # macOS
go install github.com/evilmartians/lefthook@latest # Go
npm install -g lefthook # npm
# Activate hooks in your local clone:
make install-hooks
# or: lefthook install
```
Once installed, every commit will automatically:
- **pre-commit**: check `gofmt`, run `go vet`, and run `golangci-lint` (if installed)
- **commit-msg**: validate the message follows [Conventional Commits](#commit-messages)
### Make Targets
```bash
make test # Run tests
make coverage # Run tests with coverage (90% threshold enforced)
make lint # Run golangci-lint
make fmt # Format code (gofmt -s -w)
make check # Verify formatting (no write, CI-friendly)
make vet # Run go vet
make all # fmt + vet + lint + test
make install-hooks # Install lefthook git hooks
```
## Code Style
- Run `gofmt` on all code (or `make fmt`)
- Run `golangci-lint` (or `make lint`)
- This project has **zero external dependencies** — keep it that way. Only use the Go standard library.
- Follow existing patterns in the codebase:
- Functional options for configuration (`WithXxx`)
- `context.Context` as the first parameter on all public methods
- Custom error types with helper methods
## Running Tests
```bash
# All tests
make test
# Specific test
go test -run TestSearch -v ./...
# With coverage report
make coverage
```
Tests run in CI against Go 1.22, 1.23, and 1.24. A minimum of **90% code coverage** is enforced.
## Commit Messages
This project enforces [Conventional Commits](https://www.conventionalcommits.org/) via a git hook. Format:
```
<type>[optional scope]: <description>
```
Allowed types: `feat`, `fix`, `docs`, `test`, `chore`, `refactor`, `ci`, `style`, `perf`, `build`
Examples:
```
feat: add support for filtering by audio codec
fix(client): handle nil response body on 204
docs: update Quick Start example
test: add edge case tests for retry logic
chore: update CI matrix to Go 1.24
refactor: extract retry logic into helper
```
## Pull Request Guidelines
- Keep PRs focused — one feature or fix per PR
- Include tests for new functionality
- Update documentation if the public API changes
- Ensure all CI checks pass before requesting review
- Link related issues in the PR description
## Reporting Bugs
[Open an issue](https://github.com/torrentclaw/go-client/issues/new?labels=bug) with:
- **Description** — what went wrong
- **Steps to reproduce** — minimal code or commands to trigger the bug
- **Expected behavior** — what you expected to happen
- **Actual behavior** — what actually happened
- **Environment** — Go version, OS, client version
## Requesting Features
[Open an issue](https://github.com/torrentclaw/go-client/issues/new?labels=enhancement) with:
- **Problem** — what are you trying to solve?
- **Proposed solution** — how do you think it should work?
- **Alternatives considered** — other approaches you thought about
## Code of Conduct
This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
In short:
- **Be respectful** — treat everyone with dignity regardless of background or experience level
- **Be constructive** — focus on what's best for the project and community
- **Be collaborative** — welcome newcomers, help others learn
- **No harassment** — unacceptable behavior includes trolling, insults, and unwelcome attention
Violations can be reported to the project maintainers. All complaints will be reviewed and investigated promptly and fairly.
## Questions?
If you're unsure about something, [open a discussion](https://github.com/torrentclaw/go-client/issues) or reach out on Discord (coming soon).
---
Thank you for helping make TorrentClaw better!

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 TorrentClaw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

43
Makefile Normal file
View file

@ -0,0 +1,43 @@
.PHONY: all test lint coverage clean fmt vet check install-hooks
all: fmt vet lint test
## Run all tests
test:
go test -v -race -count=1 ./...
## Run linter (requires golangci-lint)
lint:
golangci-lint run ./...
## Run tests with coverage report
coverage:
go test -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out -o coverage.html
## Format code
fmt:
gofmt -s -w .
## Check formatting (no write, exits non-zero if unformatted)
check:
@test -z "$$(gofmt -l .)" || { echo "Files not formatted:"; gofmt -l .; exit 1; }
## Run go vet
vet:
go vet ./...
## Install lefthook git hooks
install-hooks:
lefthook install
## Remove generated files
clean:
rm -f coverage.out coverage.html
# Release with goreleaser (future):
# 1. Install goreleaser: https://goreleaser.com/install/
# 2. Create .goreleaser.yml config
# 3. Tag a version: git tag -a v0.x.0 -m "release v0.x.0"
# 4. Run: goreleaser release --clean

278
README.md Normal file
View file

@ -0,0 +1,278 @@
# TorrentClaw Go Client
[![Go Reference](https://pkg.go.dev/badge/github.com/torrentclaw/go-client.svg)](https://pkg.go.dev/github.com/torrentclaw/go-client)
[![CI](https://github.com/torrentclaw/go-client/actions/workflows/ci.yml/badge.svg)](https://github.com/torrentclaw/go-client/actions/workflows/ci.yml)
[![Go Report Card](https://goreportcard.com/badge/github.com/torrentclaw/go-client)](https://goreportcard.com/report/github.com/torrentclaw/go-client)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-coming%20soon-7289da)](https://torrentclaw.com)
Go client library for the [TorrentClaw](https://torrentclaw.com) API.
## About TorrentClaw
[TorrentClaw](https://torrentclaw.com) is a torrent search engine that aggregates movies and TV shows from **30+ international sources** into a single, clean API. No ads, no tracking.
- **Quality scoring** — torrents ranked by real quality metrics, not just seeders
- **TMDB metadata** — posters, ratings, genres, cast, and crew for every result
- **Watch providers** — see where content is streaming (Netflix, Amazon, Disney+, etc.)
- **Semantic search** — natural language queries in 11+ languages
- **TrueSpec verification** — verify actual media specs from info hashes
### Links
| | |
|---|---|
| **Website** | [torrentclaw.com](https://torrentclaw.com) |
| **API Docs (OpenAPI)** | [torrentclaw.com/api/openapi.json](https://torrentclaw.com/api/openapi.json) |
| **Discord** | Coming soon |
| **GitHub** | [github.com/torrentclaw](https://github.com/torrentclaw) |
## Ecosystem
TorrentClaw is more than just an API. It's a growing ecosystem of tools:
| Project | Language | Description |
|---|---|---|
| [torrentclaw-go-client](https://github.com/torrentclaw/go-client) | Go | API client library **(this repo)** |
| torrentclaw-cli | Go | Command-line interface *(coming soon)* |
| [torrentclaw-mcp](https://github.com/torrentclaw/torrentclaw-mcp) | TypeScript | MCP server for AI agents |
| [truespec](https://github.com/torrentclaw/truespec) | Go | Verify real media specs from info hashes |
| [torrentclaw-skill](https://github.com/torrentclaw/torrentclaw-skill) | - | Agent skill for OpenClaw |
## Features
- **Zero external dependencies** — stdlib only
- **Context support** on all methods
- **Functional options** for client configuration
- **Exponential backoff retry** for transient errors (429, 5xx)
- **Custom error types** with helper methods (`IsRetryable`, `IsRateLimited`, `IsNotFound`)
- **Full API coverage** — search, autocomplete, popular, recent, watch providers, credits, stats, torrent download
## Installation
```bash
go get github.com/torrentclaw/go-client
```
Requires Go 1.22 or later.
## Quick Start
```go
package main
import (
"context"
"fmt"
"log"
torrentclaw "github.com/torrentclaw/go-client"
)
func main() {
client := torrentclaw.NewClient(
torrentclaw.WithAPIKey("your-api-key"),
)
resp, err := client.Search(context.Background(), torrentclaw.SearchParams{
Query: "Inception",
Type: "movie",
Quality: "1080p",
Sort: "seeders",
})
if err != nil {
log.Fatal(err)
}
for _, result := range resp.Results {
fmt.Printf("%s (%d)\n", result.Title, *result.Year)
for _, t := range result.Torrents {
fmt.Printf(" %s — %d seeders\n", t.InfoHash, t.Seeders)
}
}
}
```
## Client Configuration
```go
client := torrentclaw.NewClient(
torrentclaw.WithAPIKey("your-api-key"), // API key (X-API-Key header)
torrentclaw.WithBaseURL("https://custom.example"), // Custom base URL
torrentclaw.WithTimeout(30 * time.Second), // HTTP timeout (default: 15s)
torrentclaw.WithUserAgent("my-app/1.0"), // Custom User-Agent
torrentclaw.WithHTTPClient(customHTTPClient), // Custom *http.Client
torrentclaw.WithRetry(5, 2*time.Second, 60*time.Second), // Retry policy
)
```
## API Reference
### Search
```go
resp, err := client.Search(ctx, torrentclaw.SearchParams{
Query: "Breaking Bad",
Type: "show", // "movie" or "show"
Genre: "Drama", // exact genre match
YearMin: 2008,
YearMax: 2013,
MinRating: 8.0, // 0-10
Quality: "1080p", // "480p", "720p", "1080p", "2160p"
Language: "en", // ISO 639 code
Audio: "atmos", // audio codec substring
HDR: "dolby_vision", // HDR format
Sort: "seeders", // "relevance", "seeders", "year", "rating", "added"
Page: 1,
Limit: 20,
Country: "US", // includes streaming availability
Locale: "es", // localized titles/overviews
Availability: "available", // "all", "available", "unavailable"
})
```
### Autocomplete
```go
suggestions, err := client.Autocomplete(ctx, "incep")
```
### Popular Content
```go
resp, err := client.Popular(ctx, 10, 1) // limit, page (0 = server default)
```
### Recent Content
```go
resp, err := client.Recent(ctx, 12, 1)
```
### Watch Providers
```go
resp, err := client.WatchProviders(ctx, contentID, "US")
// resp.Providers.Flatrate — subscription (Netflix, Disney+, etc.)
// resp.Providers.Rent — available for rental
// resp.Providers.Buy — available for purchase
// resp.Providers.Free — free with ads
// resp.VPNSuggestion — available in other countries
```
### Credits
```go
credits, err := client.Credits(ctx, contentID)
// credits.Director — director name
// credits.Cast — top 10 cast members
```
### Stats
```go
stats, err := client.Stats(ctx)
// stats.Content.Movies, stats.Content.Shows
// stats.Torrents.Total, stats.Torrents.BySource
// stats.RecentIngestions
```
### Torrent File
```go
// Get the download URL (no HTTP call)
url := client.TorrentDownloadURL("abc123...")
// Download the .torrent file
data, err := client.GetTorrentFile(ctx, "abc123...")
```
## Error Handling
All API errors are returned as `*torrentclaw.APIError`:
```go
resp, err := client.Search(ctx, params)
if err != nil {
var apiErr *torrentclaw.APIError
if errors.As(err, &apiErr) {
fmt.Printf("HTTP %d: %s\n", apiErr.StatusCode, apiErr.Message)
if apiErr.IsRateLimited() {
// Handle 429
}
if apiErr.IsNotFound() {
// Handle 404
}
if apiErr.IsRetryable() {
// 429, 500, 502, 503 — retries are automatic by default
}
}
}
```
Transient errors (429, 500, 502, 503) are automatically retried with exponential backoff. Configure the retry policy with `WithRetry`:
```go
// Disable retries
client := torrentclaw.NewClient(torrentclaw.WithRetry(0, 0, 0))
// Custom: 5 retries, starting at 2s, capped at 60s
client := torrentclaw.NewClient(torrentclaw.WithRetry(5, 2*time.Second, 60*time.Second))
```
## Development
```bash
# Install git hooks (requires lefthook)
make install-hooks
# Run tests
make test
# Run linter
make lint
# Run tests with coverage
make coverage
# Format code
make fmt
# Check formatting (CI-friendly, no write)
make check
# Run all checks
make all
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for full setup instructions including lefthook installation.
## Contributing
Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) before submitting a pull request.
## Reporting Bugs
Found a bug? [Open an issue](https://github.com/torrentclaw/go-client/issues/new?labels=bug&template=bug_report.md) on GitHub with:
- A clear description of the problem
- Steps to reproduce
- Expected vs actual behavior
- Go version and OS
## Requesting Features
Have an idea? [Open a feature request](https://github.com/torrentclaw/go-client/issues/new?labels=enhancement&template=feature_request.md) on GitHub. We'd love to hear from you.
## License
[MIT](LICENSE)
---
<p align="center">
Made with ❤️ by the <a href="https://github.com/torrentclaw">TorrentClaw</a> community
<br>
<a href="https://torrentclaw.com">Website</a> · <a href="https://github.com/torrentclaw">GitHub</a>
</p>

367
client.go Normal file
View file

@ -0,0 +1,367 @@
package torrentclaw
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"time"
)
// Version is the library version, used in the default User-Agent header.
const Version = "0.2.0"
const (
defaultBaseURL = "https://torrentclaw.com"
defaultTimeout = 15 * time.Second
defaultUserAgent = "torrentclaw-go-client/" + Version
defaultMaxRetries = 3
defaultRetryBaseWait = 1 * time.Second
defaultRetryMaxWait = 30 * time.Second
headerAPIKey = "X-API-Key"
headerAuthorization = "Authorization"
headerSearchSource = "X-Search-Source"
headerUserAgent = "User-Agent"
headerDebridProvider = "X-Debrid-Provider"
headerDebridKey = "X-Debrid-Key"
searchSource = "go-client"
)
// Client is a TorrentClaw API client. Use [NewClient] to create one.
type Client struct {
baseURL string
apiKey string
bearerToken string
userAgent string
httpClient *http.Client
maxRetries int
retryBaseWait time.Duration
retryMaxWait time.Duration
}
// Option configures a [Client].
type Option func(*Client)
// WithBaseURL sets a custom API base URL. The default is https://torrentclaw.com.
func WithBaseURL(u string) Option {
return func(c *Client) { c.baseURL = u }
}
// WithAPIKey sets the API key sent as the X-API-Key header.
func WithAPIKey(key string) Option {
return func(c *Client) { c.apiKey = key }
}
// WithBearerToken sets a bearer token sent as the Authorization header.
// If both WithBearerToken and WithAPIKey are used, the bearer token takes precedence.
func WithBearerToken(token string) Option {
return func(c *Client) { c.bearerToken = token }
}
// WithUserAgent sets a custom User-Agent header.
func WithUserAgent(ua string) Option {
return func(c *Client) { c.userAgent = ua }
}
// WithHTTPClient sets a custom *http.Client for all requests.
func WithHTTPClient(hc *http.Client) Option {
return func(c *Client) { c.httpClient = hc }
}
// WithTimeout sets the HTTP client timeout. The default is 15 seconds.
// This option is safe to use regardless of option ordering; it sets the
// timeout on the client's internal HTTP client.
func WithTimeout(d time.Duration) Option {
return func(c *Client) {
if c.httpClient == nil {
c.httpClient = &http.Client{}
}
c.httpClient.Timeout = d
}
}
// WithRetry configures the retry policy for transient errors (429, 5xx).
// maxRetries is the maximum number of retries (0 disables retrying).
// baseWait is the initial wait duration before the first retry.
// maxWait caps the exponential backoff duration.
func WithRetry(maxRetries int, baseWait, maxWait time.Duration) Option {
return func(c *Client) {
c.maxRetries = maxRetries
c.retryBaseWait = baseWait
c.retryMaxWait = maxWait
}
}
// NewClient creates a new TorrentClaw API client with the given options.
func NewClient(opts ...Option) *Client {
c := &Client{
baseURL: defaultBaseURL,
userAgent: defaultUserAgent,
httpClient: &http.Client{
Timeout: defaultTimeout,
},
maxRetries: defaultMaxRetries,
retryBaseWait: defaultRetryBaseWait,
retryMaxWait: defaultRetryMaxWait,
}
for _, opt := range opts {
opt(c)
}
return c
}
// doJSON performs an HTTP GET request, retries on transient errors, and
// decodes the JSON response into dst.
func (c *Client) doJSON(ctx context.Context, path string, query url.Values, dst any) error {
u, err := url.Parse(c.baseURL)
if err != nil {
return fmt.Errorf("torrentclaw: invalid base URL: %w", err)
}
u.Path = path
if query != nil {
u.RawQuery = query.Encode()
}
var lastErr error
attempts := 1 + c.maxRetries
for i := range attempts {
if err := ctx.Err(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return fmt.Errorf("torrentclaw: failed to create request: %w", err)
}
c.setHeaders(req)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("torrentclaw: request failed: %w", err)
}
if resp.StatusCode == http.StatusOK {
err := json.NewDecoder(resp.Body).Decode(dst)
resp.Body.Close()
if err != nil {
return fmt.Errorf("torrentclaw: failed to decode response: %w", err)
}
return nil
}
body := readErrorBody(resp)
resp.Body.Close()
apiErr := newAPIError(resp.StatusCode, body)
lastErr = apiErr
if !apiErr.IsRetryable() || i == attempts-1 {
return apiErr
}
wait := c.backoffDuration(i)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
return lastErr
}
// doRaw performs an HTTP GET request, retries on transient errors, and
// returns the raw response body bytes.
func (c *Client) doRaw(ctx context.Context, path string, query url.Values) ([]byte, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return nil, fmt.Errorf("torrentclaw: invalid base URL: %w", err)
}
u.Path = path
if query != nil {
u.RawQuery = query.Encode()
}
var lastErr error
attempts := 1 + c.maxRetries
for i := range attempts {
if err := ctx.Err(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("torrentclaw: failed to create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("torrentclaw: request failed: %w", err)
}
if resp.StatusCode == http.StatusOK {
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("torrentclaw: failed to read response body: %w", err)
}
return data, nil
}
body := readErrorBody(resp)
resp.Body.Close()
apiErr := newAPIError(resp.StatusCode, body)
lastErr = apiErr
if !apiErr.IsRetryable() || i == attempts-1 {
return nil, apiErr
}
wait := c.backoffDuration(i)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
return nil, lastErr
}
// setHeaders applies common headers to an outgoing request.
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set(headerUserAgent, c.userAgent)
req.Header.Set(headerSearchSource, searchSource)
if c.bearerToken != "" {
req.Header.Set(headerAuthorization, "Bearer "+c.bearerToken)
} else if c.apiKey != "" {
req.Header.Set(headerAPIKey, c.apiKey)
}
}
// backoffDuration computes the wait time for retry attempt i using
// exponential backoff capped at retryMaxWait.
func (c *Client) backoffDuration(attempt int) time.Duration {
wait := time.Duration(float64(c.retryBaseWait) * math.Pow(2, float64(attempt)))
if wait > c.retryMaxWait {
wait = c.retryMaxWait
}
return wait
}
// readErrorBody reads up to 512 bytes of the response body for error context.
// For server errors (5xx), an empty string is returned to avoid leaking internals.
func readErrorBody(resp *http.Response) string {
if resp.StatusCode >= 500 {
return ""
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 512))
if err != nil {
return ""
}
return string(b)
}
// addIntParam adds an integer query parameter if the value is non-zero.
func addIntParam(q url.Values, key string, val int) {
if val != 0 {
q.Set(key, strconv.Itoa(val))
}
}
// addFloatParam adds a float query parameter if the value is non-zero.
func addFloatParam(q url.Values, key string, val float64) {
if val != 0 {
q.Set(key, strconv.FormatFloat(val, 'f', -1, 64))
}
}
// addStringParam adds a string query parameter if the value is non-empty.
func addStringParam(q url.Values, key, val string) {
if val != "" {
q.Set(key, val)
}
}
// addBoolParam adds a boolean query parameter if the value is true.
func addBoolParam(q url.Values, key string, val bool) {
if val {
q.Set(key, "true")
}
}
// doPost performs an HTTP POST request with a JSON body, retries on transient
// errors, and decodes the JSON response into dst. Extra headers (e.g. debrid
// provider credentials) are applied on top of the common headers.
func (c *Client) doPost(ctx context.Context, path string, body any, dst any, extraHeaders map[string]string) error {
u, err := url.Parse(c.baseURL)
if err != nil {
return fmt.Errorf("torrentclaw: invalid base URL: %w", err)
}
u.Path = path
payload, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("torrentclaw: failed to marshal request body: %w", err)
}
var lastErr error
attempts := 1 + c.maxRetries
for i := range attempts {
if err := ctx.Err(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("torrentclaw: failed to create request: %w", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("torrentclaw: request failed: %w", err)
}
if resp.StatusCode == http.StatusOK {
err := json.NewDecoder(resp.Body).Decode(dst)
resp.Body.Close()
if err != nil {
return fmt.Errorf("torrentclaw: failed to decode response: %w", err)
}
return nil
}
errBody := readErrorBody(resp)
resp.Body.Close()
apiErr := newAPIError(resp.StatusCode, errBody)
lastErr = apiErr
if !apiErr.IsRetryable() || i == attempts-1 {
return apiErr
}
wait := c.backoffDuration(i)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
return lastErr
}

691
client_test.go Normal file
View file

@ -0,0 +1,691 @@
package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestNewClientDefaults(t *testing.T) {
c := NewClient()
if c.baseURL != defaultBaseURL {
t.Errorf("baseURL = %q, want %q", c.baseURL, defaultBaseURL)
}
if c.userAgent != defaultUserAgent {
t.Errorf("userAgent = %q, want %q", c.userAgent, defaultUserAgent)
}
if c.apiKey != "" {
t.Errorf("apiKey = %q, want empty", c.apiKey)
}
if c.maxRetries != defaultMaxRetries {
t.Errorf("maxRetries = %d, want %d", c.maxRetries, defaultMaxRetries)
}
}
func TestNewClientOptions(t *testing.T) {
hc := &http.Client{Timeout: 5 * time.Second}
c := NewClient(
WithBaseURL("https://custom.example.com"),
WithAPIKey("test-key-123"),
WithUserAgent("my-app/1.0"),
WithHTTPClient(hc),
WithRetry(5, 2*time.Second, 60*time.Second),
)
if c.baseURL != "https://custom.example.com" {
t.Errorf("baseURL = %q", c.baseURL)
}
if c.apiKey != "test-key-123" {
t.Errorf("apiKey = %q", c.apiKey)
}
if c.userAgent != "my-app/1.0" {
t.Errorf("userAgent = %q", c.userAgent)
}
if c.httpClient != hc {
t.Error("httpClient not set")
}
if c.maxRetries != 5 {
t.Errorf("maxRetries = %d, want 5", c.maxRetries)
}
}
func TestWithTimeout(t *testing.T) {
c := NewClient(WithTimeout(30 * time.Second))
if c.httpClient.Timeout != 30*time.Second {
t.Errorf("timeout = %v, want 30s", c.httpClient.Timeout)
}
}
func TestSetHeaders(t *testing.T) {
tests := []struct {
name string
apiKey string
want map[string]string
}{
{
name: "without API key",
apiKey: "",
want: map[string]string{
headerUserAgent: defaultUserAgent,
headerSearchSource: searchSource,
},
},
{
name: "with API key",
apiKey: "my-key",
want: map[string]string{
headerUserAgent: defaultUserAgent,
headerSearchSource: searchSource,
headerAPIKey: "my-key",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := NewClient(WithAPIKey(tt.apiKey))
req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
c.setHeaders(req)
for key, want := range tt.want {
got := req.Header.Get(key)
if got != want {
t.Errorf("header %q = %q, want %q", key, got, want)
}
}
if tt.apiKey == "" && req.Header.Get(headerAPIKey) != "" {
t.Error("API key header should not be set when apiKey is empty")
}
})
}
}
func TestDoJSON_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get(headerUserAgent); got != defaultUserAgent {
t.Errorf("User-Agent = %q, want %q", got, defaultUserAgent)
}
if got := r.Header.Get(headerSearchSource); got != searchSource {
t.Errorf("X-Search-Source = %q, want %q", got, searchSource)
}
if got := r.Header.Get("Accept"); got != "application/json" {
t.Errorf("Accept = %q, want application/json", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"total": 42})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL))
var dst struct{ Total int }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dst.Total != 42 {
t.Errorf("Total = %d, want 42", dst.Total)
}
}
func TestDoJSON_APIError(t *testing.T) {
tests := []struct {
name string
statusCode int
body string
wantBody bool
}{
{name: "400 bad request", statusCode: 400, body: "invalid query", wantBody: true},
{name: "404 not found", statusCode: 404, body: "not found", wantBody: true},
{name: "500 server error", statusCode: 500, body: "internal details", wantBody: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.body))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != tt.statusCode {
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.statusCode)
}
if tt.wantBody && apiErr.Body == "" {
t.Error("expected Body to be non-empty for 4xx")
}
if !tt.wantBody && apiErr.Body != "" {
t.Errorf("expected empty Body for 5xx, got %q", apiErr.Body)
}
})
}
}
func TestDoJSON_RetryOn429(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 3 {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(3, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{ Status string }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dst.Status != "ok" {
t.Errorf("Status = %q, want ok", dst.Status)
}
if attempts != 3 {
t.Errorf("attempts = %d, want 3", attempts)
}
}
func TestDoJSON_RetryExhausted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(2, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error after retries exhausted")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 429 {
t.Errorf("StatusCode = %d, want 429", apiErr.StatusCode)
}
}
func TestDoJSON_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
ctx, cancel := context.WithCancel(context.Background())
cancel()
var dst struct{}
err := c.doJSON(ctx, "/test", nil, &dst)
if err == nil {
t.Fatal("expected error for canceled context")
}
}
func TestBackoffDuration(t *testing.T) {
c := NewClient(WithRetry(5, 1*time.Second, 30*time.Second))
tests := []struct {
attempt int
want time.Duration
}{
{0, 1 * time.Second},
{1, 2 * time.Second},
{2, 4 * time.Second},
{3, 8 * time.Second},
{4, 16 * time.Second},
{5, 30 * time.Second}, // capped
}
for _, tt := range tests {
got := c.backoffDuration(tt.attempt)
if got != tt.want {
t.Errorf("backoffDuration(%d) = %v, want %v", tt.attempt, got, tt.want)
}
}
}
func TestAPIKeyHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := r.Header.Get(headerAPIKey)
if got != "secret-key" {
t.Errorf("X-API-Key = %q, want %q", got, "secret-key")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithAPIKey("secret-key"))
var dst struct{ OK bool }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDoJSON_InvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("not json"))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "decode") {
t.Errorf("error = %q, want to contain 'decode'", err.Error())
}
}
func TestDoJSON_ContextCanceledDuringBackoff(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(5, 1*time.Second, 10*time.Second), // long backoff
)
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
var dst struct{}
err := c.doJSON(ctx, "/test", nil, &dst)
if err == nil {
t.Fatal("expected error")
}
if err != context.Canceled {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestDoJSON_RetryOn502(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 2 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(2, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{ Ok string }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestDoJSON_RetryOn503(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 2 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(2, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{ Ok string }
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestDoJSON_NoRetryOn401(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("unauthorized"))
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(3, 1*time.Millisecond, 10*time.Millisecond),
)
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error")
}
if attempts != 1 {
t.Errorf("attempts = %d, want 1 (no retries for 401)", attempts)
}
}
func TestDoJSON_WithQueryParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("foo"); got != "bar" {
t.Errorf("foo = %q, want bar", got)
}
if got := r.URL.Query().Get("num"); got != "42" {
t.Errorf("num = %q, want 42", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
q := url.Values{}
q.Set("foo", "bar")
q.Set("num", "42")
var dst struct{ Ok bool }
err := c.doJSON(context.Background(), "/test", q, &dst)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDoJSON_InvalidBaseURL(t *testing.T) {
c := NewClient(WithBaseURL("://invalid"), WithRetry(0, 0, 0))
var dst struct{}
err := c.doJSON(context.Background(), "/test", nil, &dst)
if err == nil {
t.Fatal("expected error for invalid base URL")
}
if !strings.Contains(err.Error(), "invalid base URL") {
t.Errorf("error = %q, want to contain 'invalid base URL'", err.Error())
}
}
func TestDoRaw_InvalidBaseURL(t *testing.T) {
c := NewClient(WithBaseURL("://invalid"), WithRetry(0, 0, 0))
_, err := c.doRaw(context.Background(), "/test", nil)
if err == nil {
t.Fatal("expected error for invalid base URL")
}
if !strings.Contains(err.Error(), "invalid base URL") {
t.Errorf("error = %q, want to contain 'invalid base URL'", err.Error())
}
}
func TestAddIntParam(t *testing.T) {
q := url.Values{}
addIntParam(q, "zero", 0)
if q.Has("zero") {
t.Error("zero value should not be added")
}
addIntParam(q, "val", 42)
if q.Get("val") != "42" {
t.Errorf("val = %q, want 42", q.Get("val"))
}
}
func TestAddFloatParam(t *testing.T) {
q := url.Values{}
addFloatParam(q, "zero", 0)
if q.Has("zero") {
t.Error("zero value should not be added")
}
addFloatParam(q, "rating", 7.5)
if q.Get("rating") != "7.5" {
t.Errorf("rating = %q, want 7.5", q.Get("rating"))
}
}
func TestAddStringParam(t *testing.T) {
q := url.Values{}
addStringParam(q, "empty", "")
if q.Has("empty") {
t.Error("empty value should not be added")
}
addStringParam(q, "key", "value")
if q.Get("key") != "value" {
t.Errorf("key = %q, want value", q.Get("key"))
}
}
func TestVersion(t *testing.T) {
if Version == "" {
t.Error("Version should not be empty")
}
}
func TestDoRaw_ContextCanceledDuringBackoff(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(5, 1*time.Second, 10*time.Second),
)
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err := c.doRaw(ctx, "/test", nil)
if err == nil {
t.Fatal("expected error")
}
if err != context.Canceled {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestWithTimeout_NilHTTPClient(t *testing.T) {
// Simulate the edge case where httpClient is nil when WithTimeout is applied.
c := &Client{}
opt := WithTimeout(10 * time.Second)
opt(c)
if c.httpClient == nil {
t.Fatal("httpClient should be initialized")
}
if c.httpClient.Timeout != 10*time.Second {
t.Errorf("timeout = %v, want 10s", c.httpClient.Timeout)
}
}
func TestWithBearerToken(t *testing.T) {
c := NewClient(WithBearerToken("my-token"))
if c.bearerToken != "my-token" {
t.Errorf("bearerToken = %q, want my-token", c.bearerToken)
}
}
func TestSetHeaders_BearerToken(t *testing.T) {
c := NewClient(WithBearerToken("tok123"))
req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
c.setHeaders(req)
if got := req.Header.Get(headerAuthorization); got != "Bearer tok123" {
t.Errorf("Authorization = %q, want %q", got, "Bearer tok123")
}
if got := req.Header.Get(headerAPIKey); got != "" {
t.Errorf("X-API-Key should be empty when bearer token is set, got %q", got)
}
}
func TestSetHeaders_BearerTokenPrecedence(t *testing.T) {
c := NewClient(WithAPIKey("api-key"), WithBearerToken("bearer-tok"))
req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
c.setHeaders(req)
if got := req.Header.Get(headerAuthorization); got != "Bearer bearer-tok" {
t.Errorf("Authorization = %q, want Bearer bearer-tok", got)
}
if got := req.Header.Get(headerAPIKey); got != "" {
t.Errorf("X-API-Key should be empty when bearer token takes precedence, got %q", got)
}
}
func TestAddBoolParam(t *testing.T) {
q := url.Values{}
addBoolParam(q, "disabled", false)
if q.Has("disabled") {
t.Error("false value should not be added")
}
addBoolParam(q, "enabled", true)
if q.Get("enabled") != "true" {
t.Errorf("enabled = %q, want true", q.Get("enabled"))
}
}
func TestDoPost_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
if got := r.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
if got := r.Header.Get("Accept"); got != "application/json" {
t.Errorf("Accept = %q, want application/json", got)
}
if got := r.Header.Get("X-Custom"); got != "val" {
t.Errorf("X-Custom = %q, want val", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"result":"ok"}`))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
var dst struct{ Result string }
err := c.doPost(context.Background(), "/test", map[string]string{"key": "value"}, &dst, map[string]string{"X-Custom": "val"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dst.Result != "ok" {
t.Errorf("Result = %q, want ok", dst.Result)
}
}
func TestDoPost_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("bad request"))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
var dst struct{}
err := c.doPost(context.Background(), "/test", nil, &dst, nil)
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 400 {
t.Errorf("StatusCode = %d, want 400", apiErr.StatusCode)
}
}
func TestDoPost_RetryOnTransient(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 2 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(2, 1*time.Millisecond, 10*time.Millisecond))
var dst struct{ Ok bool }
err := c.doPost(context.Background(), "/test", nil, &dst, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestDoPost_InvalidBaseURL(t *testing.T) {
c := NewClient(WithBaseURL("://invalid"), WithRetry(0, 0, 0))
var dst struct{}
err := c.doPost(context.Background(), "/test", nil, &dst, nil)
if err == nil {
t.Fatal("expected error for invalid base URL")
}
if !strings.Contains(err.Error(), "invalid base URL") {
t.Errorf("error = %q, want to contain 'invalid base URL'", err.Error())
}
}
func TestDoRaw_WithQueryParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("t"); got != "caps" {
t.Errorf("t = %q, want caps", got)
}
w.Write([]byte("<xml/>"))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
q := url.Values{}
q.Set("t", "caps")
data, err := c.doRaw(context.Background(), "/test", q)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(data) != "<xml/>" {
t.Errorf("data = %q, want <xml/>", string(data))
}
}

80
collections.go Normal file
View file

@ -0,0 +1,80 @@
package torrentclaw
import (
"context"
"fmt"
"net/url"
)
// CollectionListParams holds the parameters for listing collections.
type CollectionListParams struct {
// Limit sets the number of items (1-48, default 24).
Limit int
// Page is the page number (starts at 1).
Page int
// Locale sets the language for localized names.
Locale string
}
// CollectionListItem represents a movie collection in a list.
type CollectionListItem struct {
ID int `json:"id"`
TMDbID int `json:"tmdbId"`
Name string `json:"name"`
PosterURL *string `json:"posterUrl,omitempty"`
BackdropURL *string `json:"backdropUrl,omitempty"`
MovieCount int `json:"movieCount"`
TotalSeeders int `json:"totalSeeders"`
PartCount int `json:"partCount"`
}
// CollectionListResponse is the paginated response from the collections endpoint.
type CollectionListResponse struct {
Items []CollectionListItem `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// CollectionDetail contains full details about a movie collection.
type CollectionDetail struct {
ID int `json:"id"`
TMDbID int `json:"tmdbId"`
Name string `json:"name"`
PosterURL *string `json:"posterUrl,omitempty"`
BackdropURL *string `json:"backdropUrl,omitempty"`
MovieCount int `json:"movieCount"`
TotalSeeders int `json:"totalSeeders"`
PartCount int `json:"partCount"`
Overview *string `json:"overview,omitempty"`
Movies []PopularItem `json:"movies"`
}
// Collections returns a paginated list of movie collections (sagas).
func (c *Client) Collections(ctx context.Context, params CollectionListParams) (*CollectionListResponse, error) {
q := url.Values{}
addIntParam(q, "limit", params.Limit)
addIntParam(q, "page", params.Page)
addStringParam(q, "locale", params.Locale)
var resp CollectionListResponse
if err := c.doJSON(ctx, "/api/v1/collections", q, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// CollectionByID returns full details for a single movie collection.
func (c *Client) CollectionByID(ctx context.Context, id int, locale string) (*CollectionDetail, error) {
q := url.Values{}
addStringParam(q, "locale", locale)
path := fmt.Sprintf("/api/v1/collections/%d", id)
var resp CollectionDetail
if err := c.doJSON(ctx, path, q, &resp); err != nil {
return nil, err
}
return &resp, nil
}

128
collections_test.go Normal file
View file

@ -0,0 +1,128 @@
package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestCollections(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/collections" {
t.Errorf("path = %q, want /api/v1/collections", r.URL.Path)
}
if got := r.URL.Query().Get("limit"); got != "12" {
t.Errorf("limit = %q, want 12", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CollectionListResponse{
Items: []CollectionListItem{
{ID: 1, TMDbID: 10, Name: "Star Wars", MovieCount: 9, TotalSeeders: 5000, PartCount: 9},
},
Total: 50,
Page: 1,
PageSize: 12,
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Collections(context.Background(), CollectionListParams{Limit: 12})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Total != 50 {
t.Errorf("Total = %d, want 50", resp.Total)
}
if len(resp.Items) != 1 {
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
}
if resp.Items[0].Name != "Star Wars" {
t.Errorf("Name = %q, want Star Wars", resp.Items[0].Name)
}
if resp.Items[0].MovieCount != 9 {
t.Errorf("MovieCount = %d, want 9", resp.Items[0].MovieCount)
}
}
func TestCollectionByID(t *testing.T) {
overview := "The epic saga"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/collections/10" {
t.Errorf("path = %q, want /api/v1/collections/10", r.URL.Path)
}
if got := r.URL.Query().Get("locale"); got != "es" {
t.Errorf("locale = %q, want es", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CollectionDetail{
ID: 1,
TMDbID: 10,
Name: "Star Wars",
MovieCount: 9,
TotalSeeders: 5000,
PartCount: 9,
Overview: &overview,
Movies: []PopularItem{
{ID: 100, Title: "A New Hope", ContentType: "movie", MaxSeeders: 800},
},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.CollectionByID(context.Background(), 10, "es")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Name != "Star Wars" {
t.Errorf("Name = %q, want Star Wars", resp.Name)
}
if resp.Overview == nil || *resp.Overview != "The epic saga" {
t.Errorf("Overview = %v", resp.Overview)
}
if len(resp.Movies) != 1 {
t.Fatalf("len(Movies) = %d, want 1", len(resp.Movies))
}
if resp.Movies[0].Title != "A New Hope" {
t.Errorf("Title = %q, want A New Hope", resp.Movies[0].Title)
}
}
func TestCollectionByID_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"Collection not found"}`))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.CollectionByID(context.Background(), 999, "")
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if !apiErr.IsNotFound() {
t.Errorf("expected 404, got %d", apiErr.StatusCode)
}
}
func TestCollections_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Collections(context.Background(), CollectionListParams{})
if err == nil {
t.Fatal("expected error")
}
}

189
content.go Normal file
View file

@ -0,0 +1,189 @@
package torrentclaw
import (
"context"
"fmt"
"net/url"
)
// ─── Popular ────
// PopularParams holds the parameters for a popular content request.
type PopularParams struct {
// Limit sets the number of items (1-24, default 12).
Limit int
// Page is the page number (starts at 1).
Page int
// Locale sets the language for localized titles.
Locale string
}
// PopularItem represents a popular content item.
type PopularItem struct {
ID int `json:"id"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
ContentType string `json:"contentType"`
PosterURL *string `json:"posterUrl,omitempty"`
RatingIMDb *string `json:"ratingImdb,omitempty"`
RatingTMDb *string `json:"ratingTmdb,omitempty"`
Overview *string `json:"overview,omitempty"`
MaxSeeders int `json:"maxSeeders"`
Genres []string `json:"genres,omitempty"`
BestQuality *string `json:"bestQuality,omitempty"`
HasHDR *bool `json:"hasHdr,omitempty"`
TopInfoHash *string `json:"topInfoHash,omitempty"`
}
// PopularResponse is the paginated response from the popular endpoint.
type PopularResponse struct {
Items []PopularItem `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// ─── Recent ────
// RecentParams holds the parameters for a recent content request.
type RecentParams struct {
// Limit sets the number of items (1-24, default 12).
Limit int
// Page is the page number (starts at 1).
Page int
// Locale sets the language for localized titles.
Locale string
}
// RecentItem represents a recently added content item.
type RecentItem struct {
ID int `json:"id"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
ContentType string `json:"contentType"`
PosterURL *string `json:"posterUrl,omitempty"`
RatingIMDb *string `json:"ratingImdb,omitempty"`
RatingTMDb *string `json:"ratingTmdb,omitempty"`
Overview *string `json:"overview,omitempty"`
CreatedAt string `json:"createdAt"`
}
// RecentResponse is the paginated response from the recent endpoint.
type RecentResponse struct {
Items []RecentItem `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// ─── Watch Providers ────
// WatchProviderItem represents a single watch/streaming provider.
type WatchProviderItem struct {
ProviderID int `json:"providerId"`
Name string `json:"name"`
Logo *string `json:"logo,omitempty"`
Link *string `json:"link,omitempty"`
DisplayPriority int `json:"displayPriority,omitempty"`
}
// WatchProviders contains streaming availability grouped by access type.
type WatchProviders struct {
Flatrate []WatchProviderItem `json:"flatrate,omitempty"`
Rent []WatchProviderItem `json:"rent,omitempty"`
Buy []WatchProviderItem `json:"buy,omitempty"`
Free []WatchProviderItem `json:"free,omitempty"`
}
// VPNSuggestion is included when content is available in other countries
// via subscription but not in the user's country.
type VPNSuggestion struct {
AvailableIn []string `json:"availableIn"`
AffiliateURL string `json:"affiliateUrl"`
}
// WatchProvidersResponse contains watch providers for a content item.
type WatchProvidersResponse struct {
ContentID int `json:"contentId"`
Country string `json:"country"`
Providers WatchProviders `json:"providers"`
VPNSuggestion *VPNSuggestion `json:"vpnSuggestion,omitempty"`
Attribution string `json:"attribution"`
}
// ─── Credits ────
// CastMember represents an actor in a movie or TV show.
type CastMember struct {
TmdbID *int `json:"tmdbId,omitempty"`
Name string `json:"name"`
Character string `json:"character"`
ProfileURL *string `json:"profileUrl,omitempty"`
}
// CreditsResponse contains the director and cast for a content item.
type CreditsResponse struct {
ContentID int `json:"contentId"`
Director *string `json:"director,omitempty"`
DirectorTmdbID *int `json:"directorTmdbId,omitempty"`
Cast []CastMember `json:"cast"`
}
// ─── Methods ────
// Popular returns the most popular content ranked by community engagement.
func (c *Client) Popular(ctx context.Context, params PopularParams) (*PopularResponse, error) {
q := url.Values{}
addIntParam(q, "limit", params.Limit)
addIntParam(q, "page", params.Page)
addStringParam(q, "locale", params.Locale)
var resp PopularResponse
if err := c.doJSON(ctx, "/api/v1/popular", q, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Recent returns the most recently added content.
func (c *Client) Recent(ctx context.Context, params RecentParams) (*RecentResponse, error) {
q := url.Values{}
addIntParam(q, "limit", params.Limit)
addIntParam(q, "page", params.Page)
addStringParam(q, "locale", params.Locale)
var resp RecentResponse
if err := c.doJSON(ctx, "/api/v1/recent", q, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// WatchProviders returns streaming/watch providers for a content item.
// The country parameter is an ISO 3166-1 code (e.g. "US", "ES"). Pass an
// empty string to use the server default ("US").
func (c *Client) WatchProviders(ctx context.Context, contentID int, country string) (*WatchProvidersResponse, error) {
q := url.Values{}
addStringParam(q, "country", country)
path := fmt.Sprintf("/api/v1/content/%d/watch-providers", contentID)
var resp WatchProvidersResponse
if err := c.doJSON(ctx, path, q, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Credits returns the director and top cast members for a content item.
func (c *Client) Credits(ctx context.Context, contentID int) (*CreditsResponse, error) {
path := fmt.Sprintf("/api/v1/content/%d/credits", contentID)
var resp CreditsResponse
if err := c.doJSON(ctx, path, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}

472
content_test.go Normal file
View file

@ -0,0 +1,472 @@
package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestPopular(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/popular" {
t.Errorf("path = %q, want /api/v1/popular", r.URL.Path)
}
if got := r.URL.Query().Get("limit"); got != "5" {
t.Errorf("limit = %q, want 5", got)
}
if got := r.URL.Query().Get("page"); got != "2" {
t.Errorf("page = %q, want 2", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PopularResponse{
Items: []PopularItem{
{ID: 1, Title: "The Matrix", ContentType: "movie", MaxSeeders: 500},
},
Total: 100,
Page: 2,
PageSize: 5,
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Popular(context.Background(), PopularParams{Limit: 5, Page: 2})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Total != 100 {
t.Errorf("Total = %d, want 100", resp.Total)
}
if len(resp.Items) != 1 {
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
}
if resp.Items[0].MaxSeeders != 500 {
t.Errorf("MaxSeeders = %d, want 500", resp.Items[0].MaxSeeders)
}
}
func TestPopular_DefaultParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Has("limit") {
t.Errorf("unexpected limit param: %q", q.Get("limit"))
}
if q.Has("page") {
t.Errorf("unexpected page param: %q", q.Get("page"))
}
if q.Has("locale") {
t.Errorf("unexpected locale param: %q", q.Get("locale"))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PopularResponse{Total: 0, Page: 1, PageSize: 12})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Popular(context.Background(), PopularParams{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPopular_WithLocale(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("locale"); got != "es" {
t.Errorf("locale = %q, want es", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PopularResponse{Total: 0, Page: 1, PageSize: 12})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Popular(context.Background(), PopularParams{Locale: "es"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRecent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/recent" {
t.Errorf("path = %q, want /api/v1/recent", r.URL.Path)
}
overview := "A great movie"
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(RecentResponse{
Items: []RecentItem{
{ID: 10, Title: "New Movie", ContentType: "movie", Overview: &overview, CreatedAt: "2025-01-15T10:00:00Z"},
},
Total: 50,
Page: 1,
PageSize: 12,
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Recent(context.Background(), RecentParams{Limit: 12, Page: 1})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Total != 50 {
t.Errorf("Total = %d, want 50", resp.Total)
}
if len(resp.Items) != 1 {
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
}
if resp.Items[0].CreatedAt != "2025-01-15T10:00:00Z" {
t.Errorf("CreatedAt = %q", resp.Items[0].CreatedAt)
}
if resp.Items[0].Overview == nil || *resp.Items[0].Overview != "A great movie" {
t.Errorf("Overview = %v, want 'A great movie'", resp.Items[0].Overview)
}
}
func TestRecent_WithLocale(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("locale"); got != "fr" {
t.Errorf("locale = %q, want fr", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(RecentResponse{Total: 0, Page: 1, PageSize: 12})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Recent(context.Background(), RecentParams{Locale: "fr"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWatchProviders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/content/42/watch-providers" {
t.Errorf("path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("country"); got != "US" {
t.Errorf("country = %q, want US", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(WatchProvidersResponse{
ContentID: 42,
Country: "US",
Providers: WatchProviders{
Flatrate: []WatchProviderItem{
{ProviderID: 8, Name: "Netflix"},
},
},
Attribution: "Powered by JustWatch",
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.WatchProviders(context.Background(), 42, "US")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.ContentID != 42 {
t.Errorf("ContentID = %d, want 42", resp.ContentID)
}
if resp.Country != "US" {
t.Errorf("Country = %q, want US", resp.Country)
}
if len(resp.Providers.Flatrate) != 1 {
t.Fatalf("len(Flatrate) = %d, want 1", len(resp.Providers.Flatrate))
}
if resp.Providers.Flatrate[0].Name != "Netflix" {
t.Errorf("Name = %q, want Netflix", resp.Providers.Flatrate[0].Name)
}
}
func TestWatchProviders_WithVPN(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(WatchProvidersResponse{
ContentID: 42,
Country: "AR",
Providers: WatchProviders{},
VPNSuggestion: &VPNSuggestion{
AvailableIn: []string{"US", "GB"},
AffiliateURL: "https://example.com/vpn",
},
Attribution: "Powered by JustWatch",
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.WatchProviders(context.Background(), 42, "AR")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.VPNSuggestion == nil {
t.Fatal("expected VPNSuggestion")
}
if len(resp.VPNSuggestion.AvailableIn) != 2 {
t.Errorf("len(AvailableIn) = %d, want 2", len(resp.VPNSuggestion.AvailableIn))
}
}
func TestCredits(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/content/42/credits" {
t.Errorf("path = %q", r.URL.Path)
}
director := "Christopher Nolan"
directorID := 525
tmdbID := 6193
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CreditsResponse{
ContentID: 42,
Director: &director,
DirectorTmdbID: &directorID,
Cast: []CastMember{
{TmdbID: &tmdbID, Name: "Leonardo DiCaprio", Character: "Cobb"},
},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Credits(context.Background(), 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.ContentID != 42 {
t.Errorf("ContentID = %d, want 42", resp.ContentID)
}
if resp.Director == nil || *resp.Director != "Christopher Nolan" {
t.Errorf("Director = %v", resp.Director)
}
if resp.DirectorTmdbID == nil || *resp.DirectorTmdbID != 525 {
t.Errorf("DirectorTmdbID = %v, want 525", resp.DirectorTmdbID)
}
if len(resp.Cast) != 1 {
t.Fatalf("len(Cast) = %d, want 1", len(resp.Cast))
}
if resp.Cast[0].Name != "Leonardo DiCaprio" {
t.Errorf("Name = %q", resp.Cast[0].Name)
}
if resp.Cast[0].TmdbID == nil || *resp.Cast[0].TmdbID != 6193 {
t.Errorf("TmdbID = %v, want 6193", resp.Cast[0].TmdbID)
}
}
func TestStats(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/stats" {
t.Errorf("path = %q, want /api/v1/stats", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(StatsResponse{
Content: ContentStats{
Movies: 50000,
Shows: 10000,
TMDbEnriched: 45000,
},
Torrents: TorrentStats{
Total: 200000,
WithSeeders: 150000,
Orphans: 5000,
DailyAverage: 1200,
BySource: map[string]int{
"yts": 50000,
"eztv": 30000,
},
},
RecentIngestions: []IngestionRecord{
{
Source: "yts",
Status: "completed",
StartedAt: "2025-01-15T10:00:00Z",
Fetched: 100,
New: 10,
Updated: 5,
},
},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Stats(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content.Movies != 50000 {
t.Errorf("Movies = %d, want 50000", resp.Content.Movies)
}
if resp.Torrents.Total != 200000 {
t.Errorf("Total = %d, want 200000", resp.Torrents.Total)
}
if resp.Torrents.Orphans != 5000 {
t.Errorf("Orphans = %d, want 5000", resp.Torrents.Orphans)
}
if resp.Torrents.DailyAverage != 1200 {
t.Errorf("DailyAverage = %d, want 1200", resp.Torrents.DailyAverage)
}
if resp.Torrents.BySource["yts"] != 50000 {
t.Errorf("BySource[yts] = %d, want 50000", resp.Torrents.BySource["yts"])
}
if len(resp.RecentIngestions) != 1 {
t.Fatalf("len(RecentIngestions) = %d, want 1", len(resp.RecentIngestions))
}
}
func TestTorrentDownloadURL(t *testing.T) {
c := NewClient()
got := c.TorrentDownloadURL("abc123def456")
want := "https://torrentclaw.com/api/v1/torrent/abc123def456"
if got != want {
t.Errorf("TorrentDownloadURL = %q, want %q", got, want)
}
}
func TestGetTorrentFile(t *testing.T) {
torrentData := []byte{0x64, 0x38, 0x3A, 0x61} // fake torrent bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/torrent/abc123" {
t.Errorf("path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/x-bittorrent")
w.Write(torrentData)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
data, err := c.GetTorrentFile(context.Background(), "abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(data) != len(torrentData) {
t.Errorf("len(data) = %d, want %d", len(data), len(torrentData))
}
}
func TestGetTorrentFile_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.GetTorrentFile(context.Background(), "nonexistent")
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if !apiErr.IsNotFound() {
t.Errorf("expected IsNotFound, got StatusCode=%d", apiErr.StatusCode)
}
}
func TestPopular_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Popular(context.Background(), PopularParams{Limit: 10, Page: 1})
if err == nil {
t.Fatal("expected error")
}
}
func TestRecent_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Recent(context.Background(), RecentParams{Limit: 10, Page: 1})
if err == nil {
t.Fatal("expected error")
}
}
func TestWatchProviders_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.WatchProviders(context.Background(), 42, "US")
if err == nil {
t.Fatal("expected error")
}
}
func TestWatchProviders_NoCountry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Has("country") {
t.Error("country param should not be set when empty")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(WatchProvidersResponse{
ContentID: 42,
Country: "US",
Providers: WatchProviders{},
Attribution: "Powered by JustWatch",
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.WatchProviders(context.Background(), 42, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCredits_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Credits(context.Background(), 999)
if err == nil {
t.Fatal("expected error")
}
}
func TestCredits_NilDirector(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CreditsResponse{
ContentID: 42,
Director: nil,
Cast: []CastMember{},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Credits(context.Background(), 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Director != nil {
t.Errorf("Director = %v, want nil", resp.Director)
}
}

57
debrid.go Normal file
View file

@ -0,0 +1,57 @@
package torrentclaw
import "context"
// DebridCheckCacheRequest is the request body for checking debrid cache.
type DebridCheckCacheRequest struct {
InfoHashes []string `json:"infoHashes"`
}
// DebridCheckCacheResponse contains the cache status for each info hash.
type DebridCheckCacheResponse struct {
Cached map[string]bool `json:"cached"`
}
// DebridAddMagnetRequest is the request body for adding a magnet to debrid.
type DebridAddMagnetRequest struct {
InfoHash string `json:"infoHash"`
}
// DebridAddMagnetResponse contains the result of adding a magnet.
type DebridAddMagnetResponse struct {
ID string `json:"id"`
Cached bool `json:"cached"`
Name string `json:"name,omitempty"`
}
// DebridCheckCache checks which info hashes are cached in the user's debrid
// service. Requires a Pro tier API key.
func (c *Client) DebridCheckCache(ctx context.Context, provider, debridKey string, infoHashes []string) (*DebridCheckCacheResponse, error) {
body := DebridCheckCacheRequest{InfoHashes: infoHashes}
headers := map[string]string{
headerDebridProvider: provider,
headerDebridKey: debridKey,
}
var resp DebridCheckCacheResponse
if err := c.doPost(ctx, "/api/v1/debrid/check-cache", body, &resp, headers); err != nil {
return nil, err
}
return &resp, nil
}
// DebridAddMagnet adds a magnet link to the user's debrid service.
// Requires a Pro tier API key.
func (c *Client) DebridAddMagnet(ctx context.Context, provider, debridKey, infoHash string) (*DebridAddMagnetResponse, error) {
body := DebridAddMagnetRequest{InfoHash: infoHash}
headers := map[string]string{
headerDebridProvider: provider,
headerDebridKey: debridKey,
}
var resp DebridAddMagnetResponse
if err := c.doPost(ctx, "/api/v1/debrid/add-magnet", body, &resp, headers); err != nil {
return nil, err
}
return &resp, nil
}

137
debrid_test.go Normal file
View file

@ -0,0 +1,137 @@
package torrentclaw
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestDebridCheckCache(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
if r.URL.Path != "/api/v1/debrid/check-cache" {
t.Errorf("path = %q", r.URL.Path)
}
if got := r.Header.Get(headerDebridProvider); got != "real-debrid" {
t.Errorf("X-Debrid-Provider = %q, want real-debrid", got)
}
if got := r.Header.Get(headerDebridKey); got != "my-debrid-key" {
t.Errorf("X-Debrid-Key = %q, want my-debrid-key", got)
}
body, _ := io.ReadAll(r.Body)
var req DebridCheckCacheRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if len(req.InfoHashes) != 2 {
t.Errorf("len(InfoHashes) = %d, want 2", len(req.InfoHashes))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(DebridCheckCacheResponse{
Cached: map[string]bool{
"hash1": true,
"hash2": false,
},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.DebridCheckCache(context.Background(), "real-debrid", "my-debrid-key", []string{"hash1", "hash2"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !resp.Cached["hash1"] {
t.Error("hash1 should be cached")
}
if resp.Cached["hash2"] {
t.Error("hash2 should not be cached")
}
}
func TestDebridAddMagnet(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
if r.URL.Path != "/api/v1/debrid/add-magnet" {
t.Errorf("path = %q", r.URL.Path)
}
if got := r.Header.Get(headerDebridProvider); got != "alldebrid" {
t.Errorf("X-Debrid-Provider = %q, want alldebrid", got)
}
body, _ := io.ReadAll(r.Body)
var req DebridAddMagnetRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req.InfoHash != "abc123" {
t.Errorf("InfoHash = %q, want abc123", req.InfoHash)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(DebridAddMagnetResponse{
ID: "torrent-id-1",
Cached: true,
Name: "Test Movie",
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.DebridAddMagnet(context.Background(), "alldebrid", "key123", "abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.ID != "torrent-id-1" {
t.Errorf("ID = %q, want torrent-id-1", resp.ID)
}
if !resp.Cached {
t.Error("Cached should be true")
}
if resp.Name != "Test Movie" {
t.Errorf("Name = %q, want Test Movie", resp.Name)
}
}
func TestDebridCheckCache_Unauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"Invalid API key"}`))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.DebridCheckCache(context.Background(), "real-debrid", "bad-key", []string{"hash1"})
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 401 {
t.Errorf("StatusCode = %d, want 401", apiErr.StatusCode)
}
}
func TestDebridAddMagnet_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.DebridAddMagnet(context.Background(), "real-debrid", "key", "hash")
if err == nil {
t.Fatal("expected error")
}
}

19
doc.go Normal file
View file

@ -0,0 +1,19 @@
// Package torrentclaw provides a Go client for the TorrentClaw API,
// a torrent search engine that aggregates movies and TV shows from 30+
// international sources with TMDB metadata enrichment.
//
// Each file in this package is self-contained: it holds the types (params,
// response structs) and methods for a single API resource. Shared types
// that are referenced across multiple resources live in types.go.
//
// Usage:
//
// client := torrentclaw.NewClient(
// torrentclaw.WithAPIKey("your-api-key"),
// )
//
// resp, err := client.Search(context.Background(), torrentclaw.SearchParams{
// Query: "Inception",
// Type: "movie",
// })
package torrentclaw

78
errors.go Normal file
View file

@ -0,0 +1,78 @@
package torrentclaw
import "fmt"
// APIError represents an error response from the TorrentClaw API.
type APIError struct {
// StatusCode is the HTTP status code returned by the API.
StatusCode int
// Body contains the response body for client errors (4xx).
// It is intentionally omitted for server errors (5xx) to avoid leaking internals.
Body string
// Message is a human-readable error description.
Message string
}
// Error implements the error interface.
func (e *APIError) Error() string {
if e.Body != "" {
return fmt.Sprintf("torrentclaw: %s (HTTP %d): %s", e.Message, e.StatusCode, e.Body)
}
return fmt.Sprintf("torrentclaw: %s (HTTP %d)", e.Message, e.StatusCode)
}
// IsRetryable reports whether the error is likely transient and the request
// should be retried.
func (e *APIError) IsRetryable() bool {
switch e.StatusCode {
case 429, 500, 502, 503:
return true
default:
return false
}
}
// IsRateLimited reports whether the error is due to rate limiting (HTTP 429).
func (e *APIError) IsRateLimited() bool {
return e.StatusCode == 429
}
// IsNotFound reports whether the error is due to a missing resource (HTTP 404).
func (e *APIError) IsNotFound() bool {
return e.StatusCode == 404
}
// statusMessage returns a human-readable message for known HTTP status codes.
func statusMessage(code int) string {
switch code {
case 400:
return "Bad request — check that all parameters are valid"
case 401:
return "Authentication required — provide a valid API key"
case 403:
return "Forbidden — insufficient permissions for this endpoint"
case 404:
return "Not found — the requested resource does not exist"
case 429:
return "Rate limit exceeded — wait before retrying"
case 500:
return "TorrentClaw server error"
case 502:
return "TorrentClaw is temporarily unavailable"
case 503:
return "TorrentClaw is under maintenance"
default:
return fmt.Sprintf("API request failed with status %d", code)
}
}
// newAPIError creates an APIError from an HTTP status code and response body.
func newAPIError(statusCode int, body string) *APIError {
return &APIError{
StatusCode: statusCode,
Body: body,
Message: statusMessage(statusCode),
}
}

139
errors_test.go Normal file
View file

@ -0,0 +1,139 @@
package torrentclaw
import (
"errors"
"testing"
)
func TestAPIError_Error(t *testing.T) {
tests := []struct {
name string
err *APIError
want string
}{
{
name: "with body",
err: &APIError{StatusCode: 400, Message: "Bad request", Body: "invalid query"},
want: "torrentclaw: Bad request (HTTP 400): invalid query",
},
{
name: "without body",
err: &APIError{StatusCode: 500, Message: "Server error", Body: ""},
want: "torrentclaw: Server error (HTTP 500)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.err.Error()
if got != tt.want {
t.Errorf("Error() = %q, want %q", got, tt.want)
}
})
}
}
func TestAPIError_IsRetryable(t *testing.T) {
tests := []struct {
code int
want bool
}{
{400, false},
{401, false},
{403, false},
{404, false},
{429, true},
{500, true},
{502, true},
{503, true},
{504, false},
}
for _, tt := range tests {
err := &APIError{StatusCode: tt.code}
if got := err.IsRetryable(); got != tt.want {
t.Errorf("IsRetryable() for %d = %v, want %v", tt.code, got, tt.want)
}
}
}
func TestAPIError_IsRateLimited(t *testing.T) {
tests := []struct {
code int
want bool
}{
{200, false},
{400, false},
{429, true},
{500, false},
}
for _, tt := range tests {
err := &APIError{StatusCode: tt.code}
if got := err.IsRateLimited(); got != tt.want {
t.Errorf("IsRateLimited() for %d = %v, want %v", tt.code, got, tt.want)
}
}
}
func TestAPIError_IsNotFound(t *testing.T) {
tests := []struct {
code int
want bool
}{
{200, false},
{400, false},
{404, true},
{500, false},
}
for _, tt := range tests {
err := &APIError{StatusCode: tt.code}
if got := err.IsNotFound(); got != tt.want {
t.Errorf("IsNotFound() for %d = %v, want %v", tt.code, got, tt.want)
}
}
}
func TestStatusMessage(t *testing.T) {
tests := []struct {
code int
want string
}{
{400, "Bad request — check that all parameters are valid"},
{401, "Authentication required — provide a valid API key"},
{403, "Forbidden — insufficient permissions for this endpoint"},
{404, "Not found — the requested resource does not exist"},
{429, "Rate limit exceeded — wait before retrying"},
{500, "TorrentClaw server error"},
{502, "TorrentClaw is temporarily unavailable"},
{503, "TorrentClaw is under maintenance"},
{418, "API request failed with status 418"},
}
for _, tt := range tests {
got := statusMessage(tt.code)
if got != tt.want {
t.Errorf("statusMessage(%d) = %q, want %q", tt.code, got, tt.want)
}
}
}
func TestNewAPIError(t *testing.T) {
err := newAPIError(404, "resource not found")
if err.StatusCode != 404 {
t.Errorf("StatusCode = %d, want 404", err.StatusCode)
}
if err.Body != "resource not found" {
t.Errorf("Body = %q, want %q", err.Body, "resource not found")
}
if err.Message != statusMessage(404) {
t.Errorf("Message = %q", err.Message)
}
}
func TestAPIError_ErrorsAs(t *testing.T) {
err := newAPIError(429, "rate limited")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatal("errors.As failed to match *APIError")
}
if apiErr.StatusCode != 429 {
t.Errorf("StatusCode = %d, want 429", apiErr.StatusCode)
}
}

347
example_test.go Normal file
View file

@ -0,0 +1,347 @@
package torrentclaw_test
import (
"context"
"fmt"
"log"
"time"
"github.com/torrentclaw/go-client"
)
func Example() {
client := torrentclaw.NewClient(
torrentclaw.WithAPIKey("your-api-key"),
)
resp, err := client.Search(context.Background(), torrentclaw.SearchParams{
Query: "Inception",
Type: "movie",
Quality: "1080p",
Sort: "seeders",
})
if err != nil {
log.Fatal(err)
}
for _, result := range resp.Results {
fmt.Printf("%s (%s)\n", result.Title, result.ContentType)
for _, t := range result.Torrents {
fmt.Printf(" %s — %d seeders\n", t.InfoHash, t.Seeders)
}
}
}
func ExampleNewClient() {
// Create a client with default settings.
_ = torrentclaw.NewClient()
// Create a client with API key.
_ = torrentclaw.NewClient(
torrentclaw.WithAPIKey("your-api-key"),
torrentclaw.WithTimeout(30*time.Second),
torrentclaw.WithRetry(5, 2*time.Second, 60*time.Second),
)
// Create a client with bearer token.
_ = torrentclaw.NewClient(
torrentclaw.WithBearerToken("your-bearer-token"),
)
}
func ExampleClient_Search() {
client := torrentclaw.NewClient()
resp, err := client.Search(context.Background(), torrentclaw.SearchParams{
Query: "Breaking Bad",
Type: "show",
YearMin: 2008,
Language: "en",
Season: 1,
Episode: 5,
Page: 1,
Limit: 5,
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d results\n", resp.Total)
}
func ExampleClient_Popular() {
client := torrentclaw.NewClient()
resp, err := client.Popular(context.Background(), torrentclaw.PopularParams{
Limit: 10,
Page: 1,
Locale: "es",
})
if err != nil {
log.Fatal(err)
}
for _, item := range resp.Items {
fmt.Printf("%s — %d seeders\n", item.Title, item.MaxSeeders)
}
}
func ExampleClient_Credits() {
client := torrentclaw.NewClient()
credits, err := client.Credits(context.Background(), 42)
if err != nil {
log.Fatal(err)
}
if credits.Director != nil {
fmt.Printf("Director: %s\n", *credits.Director)
}
for _, member := range credits.Cast {
fmt.Printf("%s as %s\n", member.Name, member.Character)
}
}
func ExampleClient_TorrentDownloadURL() {
client := torrentclaw.NewClient()
url := client.TorrentDownloadURL("abc123def456789012345678901234567890abcd")
fmt.Println(url)
// Output: https://torrentclaw.com/api/v1/torrent/abc123def456789012345678901234567890abcd
}
func ExampleClient_Autocomplete() {
client := torrentclaw.NewClient()
suggestions, err := client.Autocomplete(context.Background(), torrentclaw.AutocompleteParams{
Query: "incep",
Locale: "es",
})
if err != nil {
log.Fatal(err)
}
for _, s := range suggestions {
fmt.Printf("%s (%s)\n", s.Title, s.ContentType)
}
}
func ExampleClient_Recent() {
client := torrentclaw.NewClient()
resp, err := client.Recent(context.Background(), torrentclaw.RecentParams{
Limit: 10,
Page: 1,
})
if err != nil {
log.Fatal(err)
}
for _, item := range resp.Items {
fmt.Printf("%s — added %s\n", item.Title, item.CreatedAt)
}
}
func ExampleClient_WatchProviders() {
client := torrentclaw.NewClient()
resp, err := client.WatchProviders(context.Background(), 42, "US")
if err != nil {
log.Fatal(err)
}
for _, p := range resp.Providers.Flatrate {
fmt.Printf("Stream on: %s\n", p.Name)
}
if resp.VPNSuggestion != nil {
fmt.Printf("Also available via VPN in: %v\n", resp.VPNSuggestion.AvailableIn)
}
}
func ExampleClient_Stats() {
client := torrentclaw.NewClient()
stats, err := client.Stats(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Printf("Movies: %d, Shows: %d\n", stats.Content.Movies, stats.Content.Shows)
fmt.Printf("Torrents: %d (with seeders: %d)\n", stats.Torrents.Total, stats.Torrents.WithSeeders)
}
func ExampleClient_GetTorrentFile() {
client := torrentclaw.NewClient()
data, err := client.GetTorrentFile(context.Background(), "abc123def456")
if err != nil {
log.Fatal(err)
}
// data contains the raw .torrent file bytes
_ = data
}
func ExampleWithRetry() {
// Disable retries entirely.
_ = torrentclaw.NewClient(torrentclaw.WithRetry(0, 0, 0))
// Custom: 5 retries, starting at 2s, capped at 60s.
_ = torrentclaw.NewClient(
torrentclaw.WithRetry(5, 2*time.Second, 60*time.Second),
)
}
func ExampleClient_Trending() {
client := torrentclaw.NewClient()
resp, err := client.Trending(context.Background(), torrentclaw.TrendingParams{
Period: "weekly",
Limit: 10,
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Trending (%s):\n", resp.Period)
for _, item := range resp.Items {
fmt.Printf(" %s — score %d\n", item.Title, item.TrendScore)
}
}
func ExampleClient_Upcoming() {
client := torrentclaw.NewClient()
resp, err := client.Upcoming(context.Background(), torrentclaw.UpcomingParams{
Type: "movie",
Limit: 10,
})
if err != nil {
log.Fatal(err)
}
for _, item := range resp.Items {
fmt.Printf("%s — releases %s\n", item.Title, item.ReleaseDate)
}
}
func ExampleClient_Collections() {
client := torrentclaw.NewClient()
resp, err := client.Collections(context.Background(), torrentclaw.CollectionListParams{
Limit: 12,
})
if err != nil {
log.Fatal(err)
}
for _, c := range resp.Items {
fmt.Printf("%s (%d movies)\n", c.Name, c.MovieCount)
}
}
func ExampleClient_CollectionByID() {
client := torrentclaw.NewClient()
detail, err := client.CollectionByID(context.Background(), 10, "es")
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s — %d movies\n", detail.Name, detail.MovieCount)
for _, m := range detail.Movies {
fmt.Printf(" %s\n", m.Title)
}
}
func ExampleClient_StreamingTop() {
client := torrentclaw.NewClient()
items, err := client.StreamingTop(context.Background(), torrentclaw.StreamingTopParams{
Service: "netflix",
Country: "US",
ShowType: "movie",
})
if err != nil {
log.Fatal(err)
}
for _, item := range items {
fmt.Printf("#%d %s (torrents: %v)\n", item.Rank, item.Title, item.HasTorrents)
}
}
func ExampleClient_DebridCheckCache() {
client := torrentclaw.NewClient(torrentclaw.WithAPIKey("your-pro-key"))
resp, err := client.DebridCheckCache(context.Background(),
"real-debrid", "your-debrid-key",
[]string{"abc123...", "def456..."},
)
if err != nil {
log.Fatal(err)
}
for hash, cached := range resp.Cached {
fmt.Printf("%s: cached=%v\n", hash, cached)
}
}
func ExampleClient_DebridAddMagnet() {
client := torrentclaw.NewClient(torrentclaw.WithAPIKey("your-pro-key"))
resp, err := client.DebridAddMagnet(context.Background(),
"real-debrid", "your-debrid-key", "abc123...",
)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Added: %s (cached: %v)\n", resp.Name, resp.Cached)
}
func ExampleClient_Torznab() {
client := torrentclaw.NewClient(torrentclaw.WithAPIKey("your-pro-key"))
// Get capabilities
caps, err := client.TorznabCaps(context.Background())
if err != nil {
log.Fatal(err)
}
_ = caps // XML bytes
// Search for a movie
data, err := client.Torznab(context.Background(), torrentclaw.TorznabParams{
T: "movie",
Q: "inception",
Limit: 25,
})
if err != nil {
log.Fatal(err)
}
_ = data // XML bytes
}
func ExampleClient_Health() {
client := torrentclaw.NewClient()
health, err := client.Health(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Printf("Status: %s, Uptime: %ds\n", health.Status, health.Uptime)
}
func ExampleClient_Mirrors() {
client := torrentclaw.NewClient()
resp, err := client.Mirrors(context.Background())
if err != nil {
log.Fatal(err)
}
for _, m := range resp.Mirrors {
fmt.Printf("%s — %s (primary: %v)\n", m.Label, m.URL, m.Primary)
}
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/torrentclaw/go-client
go 1.22

58
health.go Normal file
View file

@ -0,0 +1,58 @@
package torrentclaw
import "context"
// HealthResponse contains the API health status.
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
Uptime int `json:"uptime"`
Database *string `json:"database,omitempty"`
Redis *string `json:"redis,omitempty"`
}
// MirrorInfo represents an active TorrentClaw mirror instance.
type MirrorInfo struct {
URL string `json:"url"`
Label string `json:"label"`
Primary bool `json:"primary"`
}
// TorInfo represents a Tor (.onion) access point.
type TorInfo struct {
URL string `json:"url"`
Label string `json:"label"`
}
// StatusChannel represents a status/announcement channel.
type StatusChannel struct {
Label string `json:"label"`
URL string `json:"url"`
}
// MirrorsResponse contains the list of mirrors and access channels.
type MirrorsResponse struct {
Mirrors []MirrorInfo `json:"mirrors"`
Tor *TorInfo `json:"tor,omitempty"`
Lite *string `json:"lite,omitempty"`
Channels []StatusChannel `json:"channels,omitempty"`
}
// Health returns the API health status. This endpoint does not require authentication.
func (c *Client) Health(ctx context.Context) (*HealthResponse, error) {
var resp HealthResponse
if err := c.doJSON(ctx, "/api/health", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Mirrors returns the list of active TorrentClaw mirrors and access channels.
// This endpoint does not require authentication.
func (c *Client) Mirrors(ctx context.Context) (*MirrorsResponse, error) {
var resp MirrorsResponse
if err := c.doJSON(ctx, "/api/mirrors", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}

112
health_test.go Normal file
View file

@ -0,0 +1,112 @@
package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealth(t *testing.T) {
dbStatus := "connected"
redisStatus := "connected"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/health" {
t.Errorf("path = %q, want /api/health", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(HealthResponse{
Status: "ok",
Timestamp: "2026-03-26T10:00:00Z",
Uptime: 86400,
Database: &dbStatus,
Redis: &redisStatus,
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Health(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Status != "ok" {
t.Errorf("Status = %q, want ok", resp.Status)
}
if resp.Uptime != 86400 {
t.Errorf("Uptime = %d, want 86400", resp.Uptime)
}
if resp.Database == nil || *resp.Database != "connected" {
t.Errorf("Database = %v, want connected", resp.Database)
}
}
func TestHealth_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Health(context.Background())
if err == nil {
t.Fatal("expected error")
}
}
func TestMirrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/mirrors" {
t.Errorf("path = %q, want /api/mirrors", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(MirrorsResponse{
Mirrors: []MirrorInfo{
{URL: "https://torrentclaw.com", Label: "Primary", Primary: true},
{URL: "https://tc2.example.com", Label: "Mirror 1", Primary: false},
},
Tor: &TorInfo{URL: "http://example.onion", Label: ".onion"},
Channels: []StatusChannel{
{Label: "Telegram", URL: "https://t.me/torrentclaw"},
},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Mirrors(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Mirrors) != 2 {
t.Fatalf("len(Mirrors) = %d, want 2", len(resp.Mirrors))
}
if !resp.Mirrors[0].Primary {
t.Error("first mirror should be primary")
}
if resp.Tor == nil {
t.Fatal("expected Tor")
}
if resp.Tor.URL != "http://example.onion" {
t.Errorf("Tor.URL = %q", resp.Tor.URL)
}
if len(resp.Channels) != 1 {
t.Fatalf("len(Channels) = %d, want 1", len(resp.Channels))
}
}
func TestMirrors_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Mirrors(context.Background())
if err == nil {
t.Fatal("expected error")
}
}

26
lefthook.yml Normal file
View file

@ -0,0 +1,26 @@
# Lefthook git hooks configuration
# Install: lefthook install (or make install-hooks)
# Docs: https://github.com/evilmartians/lefthook
pre-commit:
parallel: true
commands:
gofmt-check:
glob: "*.go"
run: test -z "$(gofmt -l .)" || { echo "Files not formatted:"; gofmt -l .; exit 1; }
go-vet:
glob: "*.go"
run: go vet ./...
golangci-lint:
glob: "*.go"
run: |
if command -v golangci-lint &> /dev/null; then
golangci-lint run ./...
else
echo "golangci-lint not installed, skipping (install: https://golangci-lint.run/welcome/install/)"
fi
commit-msg:
scripts:
validate.sh:
runner: bash

167
search.go Normal file
View file

@ -0,0 +1,167 @@
package torrentclaw
import (
"context"
"net/url"
)
// SearchParams holds the parameters for a search request.
type SearchParams struct {
// Query is the search query (required, max 200 characters).
Query string
// Type filters by content type: "movie" or "show".
Type string
// Genre filters by genre (exact match), e.g. "Action", "Comedy".
Genre string
// YearMin sets the minimum release year.
YearMin int
// YearMax sets the maximum release year.
YearMax int
// MinRating sets the minimum IMDb/TMDB rating threshold (0-10).
MinRating float64
// Quality filters by video resolution: "480p", "720p", "1080p", "2160p".
Quality string
// Language filters by torrent audio language (ISO 639 code, e.g. "en", "es").
Language string
// Subs filters by subtitle language (ISO 639 code, e.g. "en", "es").
Subs string
// Audio filters by audio codec (substring match, e.g. "aac", "atmos").
Audio string
// HDR filters by HDR format (e.g. "hdr10", "dolby_vision").
HDR string
// Sort sets the sort order: "relevance", "seeders", "year", "rating", "added".
Sort string
// Page is the page number (starts at 1).
Page int
// Limit sets the number of results per page (1-50).
Limit int
// Country is an ISO 3166-1 country code to include streaming availability.
Country string
// Locale sets the language for title/overview translations (e.g. "es", "fr").
Locale string
// Availability filters by torrent availability: "all", "available", "unavailable".
Availability string
// Verified filters to only TrueSpec verified releases.
Verified bool
// Season filters by TV show season number.
Season int
// Episode filters by TV show episode number.
Episode int
}
// SearchResult represents a single content result from a search.
type SearchResult struct {
ID int `json:"id"`
IMDbID *string `json:"imdbId,omitempty"`
TMDbID *int `json:"tmdbId,omitempty"`
ContentType string `json:"contentType"`
Title string `json:"title"`
TitleOriginal *string `json:"titleOriginal,omitempty"`
Year *int `json:"year,omitempty"`
Overview *string `json:"overview,omitempty"`
PosterURL *string `json:"posterUrl,omitempty"`
BackdropURL *string `json:"backdropUrl,omitempty"`
Genres []string `json:"genres,omitempty"`
RatingIMDb *string `json:"ratingImdb,omitempty"`
RatingTMDb *string `json:"ratingTmdb,omitempty"`
ContentURL string `json:"contentUrl"`
HasTorrents bool `json:"hasTorrents"`
Torrents []TorrentInfo `json:"torrents"`
Streaming *StreamingInfo `json:"streaming,omitempty"`
}
// SearchResponse is the paginated response from the search endpoint.
type SearchResponse struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
ParsedSeason *int `json:"parsedSeason,omitempty"`
ParsedEpisode *int `json:"parsedEpisode,omitempty"`
FuzzyMatch *bool `json:"fuzzyMatch,omitempty"`
Results []SearchResult `json:"results"`
}
// AutocompleteParams holds the parameters for an autocomplete request.
type AutocompleteParams struct {
// Query is the search prefix (required, 2-200 characters).
Query string
// Locale sets the language for localized suggestions.
Locale string
}
// AutocompleteSuggestion represents a single autocomplete result.
type AutocompleteSuggestion struct {
ID int `json:"id"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
ContentType string `json:"contentType"`
PosterURL *string `json:"posterUrl,omitempty"`
MovieCount *int `json:"movieCount,omitempty"`
}
// Search queries the TorrentClaw search endpoint with the given parameters.
// The Query field in params is required.
func (c *Client) Search(ctx context.Context, params SearchParams) (*SearchResponse, error) {
q := url.Values{}
q.Set("q", params.Query)
addStringParam(q, "type", params.Type)
addStringParam(q, "genre", params.Genre)
addIntParam(q, "year_min", params.YearMin)
addIntParam(q, "year_max", params.YearMax)
addFloatParam(q, "min_rating", params.MinRating)
addStringParam(q, "quality", params.Quality)
addStringParam(q, "lang", params.Language)
addStringParam(q, "subs", params.Subs)
addStringParam(q, "audio", params.Audio)
addStringParam(q, "hdr", params.HDR)
addStringParam(q, "sort", params.Sort)
addIntParam(q, "page", params.Page)
addIntParam(q, "limit", params.Limit)
addStringParam(q, "country", params.Country)
addStringParam(q, "locale", params.Locale)
addStringParam(q, "availability", params.Availability)
addBoolParam(q, "verified", params.Verified)
addIntParam(q, "season", params.Season)
addIntParam(q, "episode", params.Episode)
var resp SearchResponse
if err := c.doJSON(ctx, "/api/v1/search", q, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Autocomplete returns title suggestions for the given query prefix.
func (c *Client) Autocomplete(ctx context.Context, params AutocompleteParams) ([]AutocompleteSuggestion, error) {
q := url.Values{}
q.Set("q", params.Query)
addStringParam(q, "locale", params.Locale)
var resp struct {
Suggestions []AutocompleteSuggestion `json:"suggestions"`
}
if err := c.doJSON(ctx, "/api/v1/autocomplete", q, &resp); err != nil {
return nil, err
}
return resp.Suggestions, nil
}

345
search_test.go Normal file
View file

@ -0,0 +1,345 @@
package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestSearch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search" {
t.Errorf("path = %q, want /api/v1/search", r.URL.Path)
}
q := r.URL.Query()
if got := q.Get("q"); got != "inception" {
t.Errorf("q = %q, want inception", got)
}
if got := q.Get("type"); got != "movie" {
t.Errorf("type = %q, want movie", got)
}
if got := q.Get("year_min"); got != "2010" {
t.Errorf("year_min = %q, want 2010", got)
}
if got := q.Get("quality"); got != "1080p" {
t.Errorf("quality = %q, want 1080p", got)
}
if got := q.Get("lang"); got != "en" {
t.Errorf("lang = %q, want en", got)
}
if got := q.Get("sort"); got != "seeders" {
t.Errorf("sort = %q, want seeders", got)
}
if got := q.Get("page"); got != "1" {
t.Errorf("page = %q, want 1", got)
}
if got := q.Get("limit"); got != "10" {
t.Errorf("limit = %q, want 10", got)
}
if got := q.Get("country"); got != "US" {
t.Errorf("country = %q, want US", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(SearchResponse{
Total: 1,
Page: 1,
PageSize: 10,
Results: []SearchResult{
{
ID: 42,
ContentType: "movie",
Title: "Inception",
HasTorrents: true,
Torrents: []TorrentInfo{
{
InfoHash: "abc123",
RawTitle: "Inception.2010.1080p",
Seeders: 100,
Leechers: 10,
Source: "yts",
Languages: []string{"en"},
},
},
},
},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Search(context.Background(), SearchParams{
Query: "inception",
Type: "movie",
YearMin: 2010,
Quality: "1080p",
Language: "en",
Sort: "seeders",
Page: 1,
Limit: 10,
Country: "US",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Total != 1 {
t.Errorf("Total = %d, want 1", resp.Total)
}
if len(resp.Results) != 1 {
t.Fatalf("len(Results) = %d, want 1", len(resp.Results))
}
r := resp.Results[0]
if r.Title != "Inception" {
t.Errorf("Title = %q, want Inception", r.Title)
}
if len(r.Torrents) != 1 {
t.Fatalf("len(Torrents) = %d, want 1", len(r.Torrents))
}
if r.Torrents[0].Seeders != 100 {
t.Errorf("Seeders = %d, want 100", r.Torrents[0].Seeders)
}
}
func TestSearch_AllParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
checks := map[string]string{
"q": "test",
"type": "show",
"genre": "Comedy",
"year_min": "2020",
"year_max": "2025",
"min_rating": "7",
"quality": "2160p",
"lang": "es",
"subs": "en",
"audio": "atmos",
"hdr": "dolby_vision",
"sort": "rating",
"page": "2",
"limit": "25",
"country": "ES",
"locale": "es",
"availability": "available",
"verified": "true",
"season": "3",
"episode": "5",
}
for key, want := range checks {
if got := q.Get(key); got != want {
t.Errorf("%s = %q, want %q", key, got, want)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(SearchResponse{Total: 0, Page: 2, PageSize: 25})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Search(context.Background(), SearchParams{
Query: "test",
Type: "show",
Genre: "Comedy",
YearMin: 2020,
YearMax: 2025,
MinRating: 7,
Quality: "2160p",
Language: "es",
Subs: "en",
Audio: "atmos",
HDR: "dolby_vision",
Sort: "rating",
Page: 2,
Limit: 25,
Country: "ES",
Locale: "es",
Availability: "available",
Verified: true,
Season: 3,
Episode: 5,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSearch_EmptyOptionalParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if got := q.Get("q"); got != "matrix" {
t.Errorf("q = %q, want matrix", got)
}
for _, key := range []string{"type", "genre", "year_min", "year_max", "quality", "lang", "subs", "sort", "page", "limit", "verified", "season", "episode"} {
if q.Has(key) {
t.Errorf("unexpected param %q = %q", key, q.Get(key))
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(SearchResponse{Total: 0, Page: 1, PageSize: 20})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Search(context.Background(), SearchParams{Query: "matrix"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSearch_ParsedSeasonEpisode(t *testing.T) {
season := 2
episode := 5
fuzzy := true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(SearchResponse{
Total: 1,
Page: 1,
PageSize: 20,
ParsedSeason: &season,
ParsedEpisode: &episode,
FuzzyMatch: &fuzzy,
Results: []SearchResult{},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Search(context.Background(), SearchParams{Query: "breaking bad s02e05"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.ParsedSeason == nil || *resp.ParsedSeason != 2 {
t.Errorf("ParsedSeason = %v, want 2", resp.ParsedSeason)
}
if resp.ParsedEpisode == nil || *resp.ParsedEpisode != 5 {
t.Errorf("ParsedEpisode = %v, want 5", resp.ParsedEpisode)
}
if resp.FuzzyMatch == nil || !*resp.FuzzyMatch {
t.Errorf("FuzzyMatch = %v, want true", resp.FuzzyMatch)
}
}
func TestAutocomplete(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/autocomplete" {
t.Errorf("path = %q, want /api/v1/autocomplete", r.URL.Path)
}
if got := r.URL.Query().Get("q"); got != "incep" {
t.Errorf("q = %q, want incep", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"suggestions": []AutocompleteSuggestion{
{ID: 1, Title: "Inception", ContentType: "movie"},
},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
results, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "incep"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
if results[0].Title != "Inception" {
t.Errorf("Title = %q, want Inception", results[0].Title)
}
}
func TestAutocomplete_WithLocale(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("locale"); got != "es" {
t.Errorf("locale = %q, want es", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"suggestions": []AutocompleteSuggestion{}})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "test", Locale: "es"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAutocomplete_WithMovieCount(t *testing.T) {
movieCount := 5
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"suggestions": []AutocompleteSuggestion{
{ID: 1, Title: "Star Wars", ContentType: "collection", MovieCount: &movieCount},
},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
results, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "star"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if results[0].MovieCount == nil || *results[0].MovieCount != 5 {
t.Errorf("MovieCount = %v, want 5", results[0].MovieCount)
}
}
func TestSearch_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Search(context.Background(), SearchParams{Query: "test"})
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 500 {
t.Errorf("StatusCode = %d, want 500", apiErr.StatusCode)
}
}
func TestAutocomplete_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "test"})
if err == nil {
t.Fatal("expected error")
}
}
func TestAutocomplete_EmptyResults(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"suggestions": []AutocompleteSuggestion{}})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
results, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "zzzzz"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 0 {
t.Errorf("len(results) = %d, want 0", len(results))
}
}

47
stats.go Normal file
View file

@ -0,0 +1,47 @@
package torrentclaw
import "context"
// ContentStats contains counts for movies, shows, and TMDB enrichment.
type ContentStats struct {
Movies int `json:"movies"`
Shows int `json:"shows"`
TMDbEnriched int `json:"tmdbEnriched"`
}
// TorrentStats contains aggregate torrent statistics.
type TorrentStats struct {
Total int `json:"total"`
WithSeeders int `json:"withSeeders"`
Orphans int `json:"orphans"`
DailyAverage int `json:"dailyAverage"`
BySource map[string]int `json:"bySource"`
}
// IngestionRecord represents a recent data ingestion event.
type IngestionRecord struct {
Source string `json:"source"`
Status string `json:"status"`
StartedAt string `json:"startedAt"`
CompletedAt *string `json:"completedAt,omitempty"`
Fetched int `json:"fetched"`
New int `json:"new"`
Updated int `json:"updated"`
}
// StatsResponse contains aggregator statistics.
type StatsResponse struct {
Content ContentStats `json:"content"`
Torrents TorrentStats `json:"torrents"`
RecentIngestions []IngestionRecord `json:"recentIngestions"`
}
// Stats returns aggregator statistics including content counts, torrent
// counts by source, and recent ingestion history.
func (c *Client) Stats(ctx context.Context) (*StatsResponse, error) {
var resp StatsResponse
if err := c.doJSON(ctx, "/api/v1/stats", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}

92
stats_test.go Normal file
View file

@ -0,0 +1,92 @@
package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestStats_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Stats(context.Background())
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 500 {
t.Errorf("StatusCode = %d, want 500", apiErr.StatusCode)
}
}
func TestStats_EmptyIngestions(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(StatsResponse{
Content: ContentStats{Movies: 100, Shows: 50, TMDbEnriched: 80},
Torrents: TorrentStats{Total: 500, WithSeeders: 300, BySource: map[string]int{}},
RecentIngestions: []IngestionRecord{},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Stats(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content.TMDbEnriched != 80 {
t.Errorf("TMDbEnriched = %d, want 80", resp.Content.TMDbEnriched)
}
if resp.Torrents.WithSeeders != 300 {
t.Errorf("WithSeeders = %d, want 300", resp.Torrents.WithSeeders)
}
if len(resp.RecentIngestions) != 0 {
t.Errorf("len(RecentIngestions) = %d, want 0", len(resp.RecentIngestions))
}
}
func TestStats_WithCompletedAt(t *testing.T) {
completedAt := "2025-01-15T10:05:00Z"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(StatsResponse{
Content: ContentStats{},
Torrents: TorrentStats{BySource: map[string]int{}},
RecentIngestions: []IngestionRecord{
{
Source: "yts",
Status: "completed",
StartedAt: "2025-01-15T10:00:00Z",
CompletedAt: &completedAt,
Fetched: 100,
New: 10,
Updated: 5,
},
},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Stats(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.RecentIngestions) != 1 {
t.Fatalf("len(RecentIngestions) = %d, want 1", len(resp.RecentIngestions))
}
rec := resp.RecentIngestions[0]
if rec.CompletedAt == nil || *rec.CompletedAt != completedAt {
t.Errorf("CompletedAt = %v, want %q", rec.CompletedAt, completedAt)
}
}

56
streaming.go Normal file
View file

@ -0,0 +1,56 @@
package torrentclaw
import (
"context"
"net/url"
)
// StreamingTopParams holds the parameters for a streaming top 10 request.
type StreamingTopParams struct {
// Service is the streaming service: "netflix", "prime", "disney", "hbo", "apple".
Service string
// Country is an ISO 3166-1 country code (default "US").
Country string
// ShowType is the content type: "movie" or "series" (default "movie").
ShowType string
// Locale sets the language for localized titles.
Locale string
}
// StreamingTopItem represents a ranked item in a streaming top list.
type StreamingTopItem struct {
Rank int `json:"rank"`
Title string `json:"title"`
IMDbID *string `json:"imdbId,omitempty"`
TMDbID *int `json:"tmdbId,omitempty"`
ContentType *string `json:"contentType,omitempty"`
Year *int `json:"year,omitempty"`
Overview *string `json:"overview,omitempty"`
Rating *string `json:"rating,omitempty"`
PosterURL *string `json:"posterUrl,omitempty"`
BackdropURL *string `json:"backdropUrl,omitempty"`
Genres []string `json:"genres,omitempty"`
StreamingLink *string `json:"streamingLink,omitempty"`
ContentID *int `json:"contentId,omitempty"`
HasTorrents bool `json:"hasTorrents"`
MaxSeeders int `json:"maxSeeders"`
}
// StreamingTop returns the top-ranked content for a streaming service.
// The response is an array of ranked items (not a paginated wrapper).
func (c *Client) StreamingTop(ctx context.Context, params StreamingTopParams) ([]StreamingTopItem, error) {
q := url.Values{}
addStringParam(q, "service", params.Service)
addStringParam(q, "country", params.Country)
addStringParam(q, "show_type", params.ShowType)
addStringParam(q, "locale", params.Locale)
var resp []StreamingTopItem
if err := c.doJSON(ctx, "/api/v1/streaming-top", q, &resp); err != nil {
return nil, err
}
return resp, nil
}

92
streaming_test.go Normal file
View file

@ -0,0 +1,92 @@
package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestStreamingTop(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/streaming-top" {
t.Errorf("path = %q, want /api/v1/streaming-top", r.URL.Path)
}
q := r.URL.Query()
if got := q.Get("service"); got != "netflix" {
t.Errorf("service = %q, want netflix", got)
}
if got := q.Get("country"); got != "US" {
t.Errorf("country = %q, want US", got)
}
if got := q.Get("show_type"); got != "movie" {
t.Errorf("show_type = %q, want movie", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]StreamingTopItem{
{Rank: 1, Title: "Top Movie", HasTorrents: true, MaxSeeders: 500},
{Rank: 2, Title: "Second Movie", HasTorrents: false, MaxSeeders: 0},
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
items, err := c.StreamingTop(context.Background(), StreamingTopParams{
Service: "netflix",
Country: "US",
ShowType: "movie",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(items) != 2 {
t.Fatalf("len(items) = %d, want 2", len(items))
}
if items[0].Rank != 1 {
t.Errorf("Rank = %d, want 1", items[0].Rank)
}
if items[0].Title != "Top Movie" {
t.Errorf("Title = %q, want Top Movie", items[0].Title)
}
if !items[0].HasTorrents {
t.Error("HasTorrents should be true")
}
}
func TestStreamingTop_DefaultParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
for _, key := range []string{"service", "country", "show_type"} {
if q.Has(key) {
t.Errorf("unexpected param %q", key)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]StreamingTopItem{})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
items, err := c.StreamingTop(context.Background(), StreamingTopParams{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(items) != 0 {
t.Errorf("len(items) = %d, want 0", len(items))
}
}
func TestStreamingTop_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.StreamingTop(context.Background(), StreamingTopParams{})
if err == nil {
t.Fatal("expected error")
}
}

19
torrent.go Normal file
View file

@ -0,0 +1,19 @@
package torrentclaw
import (
"context"
"fmt"
)
// TorrentDownloadURL returns the URL for downloading a .torrent file by its
// info hash. This method does not make an HTTP request.
func (c *Client) TorrentDownloadURL(infoHash string) string {
return fmt.Sprintf("%s/api/v1/torrent/%s", c.baseURL, infoHash)
}
// GetTorrentFile downloads the .torrent file for the given info hash and
// returns the raw bytes.
func (c *Client) GetTorrentFile(ctx context.Context, infoHash string) ([]byte, error) {
path := fmt.Sprintf("/api/v1/torrent/%s", infoHash)
return c.doRaw(ctx, path, nil)
}

108
torrent_test.go Normal file
View file

@ -0,0 +1,108 @@
package torrentclaw
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestTorrentDownloadURL_CustomBase(t *testing.T) {
c := NewClient(WithBaseURL("https://custom.example.com"))
got := c.TorrentDownloadURL("deadbeef")
want := "https://custom.example.com/api/v1/torrent/deadbeef"
if got != want {
t.Errorf("TorrentDownloadURL = %q, want %q", got, want)
}
}
func TestGetTorrentFile_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.GetTorrentFile(context.Background(), "abc123")
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 500 {
t.Errorf("StatusCode = %d, want 500", apiErr.StatusCode)
}
}
func TestGetTorrentFile_RetryOn503(t *testing.T) {
attempts := 0
torrentData := []byte{0xDE, 0xAD, 0xBE, 0xEF}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/x-bittorrent")
w.Write(torrentData)
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(3, 1*time.Millisecond, 10*time.Millisecond),
)
data, err := c.GetTorrentFile(context.Background(), "abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(data) != len(torrentData) {
t.Errorf("len(data) = %d, want %d", len(data), len(torrentData))
}
if attempts != 3 {
t.Errorf("attempts = %d, want 3", attempts)
}
}
func TestGetTorrentFile_RetryExhausted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
c := NewClient(
WithBaseURL(srv.URL),
WithRetry(1, 1*time.Millisecond, 10*time.Millisecond),
)
_, err := c.GetTorrentFile(context.Background(), "abc123")
if err == nil {
t.Fatal("expected error after retries exhausted")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 502 {
t.Errorf("StatusCode = %d, want 502", apiErr.StatusCode)
}
}
func TestGetTorrentFile_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := c.GetTorrentFile(ctx, "abc123")
if err == nil {
t.Fatal("expected error for canceled context")
}
}

59
torznab.go Normal file
View file

@ -0,0 +1,59 @@
package torrentclaw
import (
"context"
"net/url"
)
// TorznabParams holds the parameters for a Torznab API request.
type TorznabParams struct {
// T is the request type: "caps", "search", "tvsearch", "movie".
T string
// Q is the search query.
Q string
// IMDbID is an IMDb identifier (e.g. "tt1234567").
IMDbID string
// TMDbID is a TMDB identifier.
TMDbID string
// Season is the season number for TV searches.
Season int
// Ep is the episode number for TV searches.
Ep int
// Cat is a comma-separated list of category codes (2000=Movies, 5000=TV).
Cat string
// Limit sets the max results (1-100, default 50).
Limit int
// Offset is the 0-based pagination offset.
Offset int
}
// Torznab queries the Torznab-compatible API and returns the raw XML response.
// Requires a Pro tier API key.
func (c *Client) Torznab(ctx context.Context, params TorznabParams) ([]byte, error) {
q := url.Values{}
addStringParam(q, "t", params.T)
addStringParam(q, "q", params.Q)
addStringParam(q, "imdbid", params.IMDbID)
addStringParam(q, "tmdbid", params.TMDbID)
addIntParam(q, "season", params.Season)
addIntParam(q, "ep", params.Ep)
addStringParam(q, "cat", params.Cat)
addIntParam(q, "limit", params.Limit)
addIntParam(q, "offset", params.Offset)
return c.doRaw(ctx, "/api/v1/torznab", q)
}
// TorznabCaps returns the Torznab capabilities XML.
// Requires a Pro tier API key.
func (c *Client) TorznabCaps(ctx context.Context) ([]byte, error) {
return c.Torznab(ctx, TorznabParams{T: "caps"})
}

117
torznab_test.go Normal file
View file

@ -0,0 +1,117 @@
package torrentclaw
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestTorznab_Search(t *testing.T) {
xmlResp := `<?xml version="1.0" encoding="UTF-8"?><rss><channel><item><title>Test</title></item></channel></rss>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/torznab" {
t.Errorf("path = %q, want /api/v1/torznab", r.URL.Path)
}
q := r.URL.Query()
if got := q.Get("t"); got != "search" {
t.Errorf("t = %q, want search", got)
}
if got := q.Get("q"); got != "inception" {
t.Errorf("q = %q, want inception", got)
}
if got := q.Get("limit"); got != "50" {
t.Errorf("limit = %q, want 50", got)
}
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(xmlResp))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
data, err := c.Torznab(context.Background(), TorznabParams{
T: "search",
Q: "inception",
Limit: 50,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(data), "<title>Test</title>") {
t.Errorf("response does not contain expected XML")
}
}
func TestTorznab_TVSearch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if got := q.Get("t"); got != "tvsearch" {
t.Errorf("t = %q, want tvsearch", got)
}
if got := q.Get("imdbid"); got != "tt1234567" {
t.Errorf("imdbid = %q, want tt1234567", got)
}
if got := q.Get("season"); got != "3" {
t.Errorf("season = %q, want 3", got)
}
if got := q.Get("ep"); got != "5" {
t.Errorf("ep = %q, want 5", got)
}
w.Write([]byte("<rss/>"))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Torznab(context.Background(), TorznabParams{
T: "tvsearch",
IMDbID: "tt1234567",
Season: 3,
Ep: 5,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTorznabCaps(t *testing.T) {
capsXML := `<?xml version="1.0"?><caps><server title="TorrentClaw"/></caps>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("t"); got != "caps" {
t.Errorf("t = %q, want caps", got)
}
w.Write([]byte(capsXML))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
data, err := c.TorznabCaps(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(data), "TorrentClaw") {
t.Error("caps should contain TorrentClaw")
}
}
func TestTorznab_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("pro tier required"))
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Torznab(context.Background(), TorznabParams{T: "search", Q: "test"})
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 403 {
t.Errorf("StatusCode = %d, want 403", apiErr.StatusCode)
}
}

60
trending.go Normal file
View file

@ -0,0 +1,60 @@
package torrentclaw
import (
"context"
"net/url"
)
// TrendingParams holds the parameters for a trending content request.
type TrendingParams struct {
// Period sets the time window: "daily", "weekly", "monthly" (default "daily").
Period string
// Limit sets the number of items (1-50, default 20).
Limit int
// Page is the page number (starts at 1).
Page int
// Locale sets the language for localized titles.
Locale string
}
// TrendingItem represents a trending content item.
type TrendingItem struct {
ID int `json:"id"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
ContentType string `json:"contentType"`
PosterURL *string `json:"posterUrl,omitempty"`
RatingIMDb *string `json:"ratingImdb,omitempty"`
RatingTMDb *string `json:"ratingTmdb,omitempty"`
Overview *string `json:"overview,omitempty"`
MaxSeeders int `json:"maxSeeders"`
ClickCount int `json:"clickCount"`
TrendScore int `json:"trendScore"`
}
// TrendingResponse is the paginated response from the trending endpoint.
type TrendingResponse struct {
Period string `json:"period"`
Items []TrendingItem `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// Trending returns trending content for the given time period.
func (c *Client) Trending(ctx context.Context, params TrendingParams) (*TrendingResponse, error) {
q := url.Values{}
addStringParam(q, "period", params.Period)
addIntParam(q, "limit", params.Limit)
addIntParam(q, "page", params.Page)
addStringParam(q, "locale", params.Locale)
var resp TrendingResponse
if err := c.doJSON(ctx, "/api/v1/trending", q, &resp); err != nil {
return nil, err
}
return &resp, nil
}

87
trending_test.go Normal file
View file

@ -0,0 +1,87 @@
package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestTrending(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/trending" {
t.Errorf("path = %q, want /api/v1/trending", r.URL.Path)
}
q := r.URL.Query()
if got := q.Get("period"); got != "weekly" {
t.Errorf("period = %q, want weekly", got)
}
if got := q.Get("limit"); got != "10" {
t.Errorf("limit = %q, want 10", got)
}
if got := q.Get("locale"); got != "es" {
t.Errorf("locale = %q, want es", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(TrendingResponse{
Period: "weekly",
Items: []TrendingItem{
{ID: 1, Title: "Trending Movie", ContentType: "movie", MaxSeeders: 1000, TrendScore: 95},
},
Total: 50,
Page: 1,
PageSize: 10,
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Trending(context.Background(), TrendingParams{Period: "weekly", Limit: 10, Locale: "es"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Period != "weekly" {
t.Errorf("Period = %q, want weekly", resp.Period)
}
if len(resp.Items) != 1 {
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
}
if resp.Items[0].TrendScore != 95 {
t.Errorf("TrendScore = %d, want 95", resp.Items[0].TrendScore)
}
}
func TestTrending_DefaultParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
for _, key := range []string{"period", "limit", "page", "locale"} {
if q.Has(key) {
t.Errorf("unexpected param %q", key)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(TrendingResponse{Period: "daily", Total: 0, Page: 1, PageSize: 20})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Trending(context.Background(), TrendingParams{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTrending_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Trending(context.Background(), TrendingParams{})
if err == nil {
t.Fatal("expected error")
}
}

126
types.go Normal file
View file

@ -0,0 +1,126 @@
package torrentclaw
// This file contains shared types referenced by multiple endpoint files.
// Domain-specific types (params, responses) live alongside their methods
// in the corresponding endpoint file (search.go, content.go, etc.).
// ─── TrueSpec media metadata ────
// AudioTrack represents a single audio track detected by TrueSpec scanning.
type AudioTrack struct {
Lang string `json:"lang"`
Codec string `json:"codec"`
Channels int `json:"channels"`
Title string `json:"title,omitempty"`
Default bool `json:"default,omitempty"`
}
// SubtitleTrack represents a single subtitle track detected by TrueSpec scanning.
type SubtitleTrack struct {
Lang string `json:"lang"`
Codec string `json:"codec"`
Title string `json:"title,omitempty"`
Forced bool `json:"forced,omitempty"`
Default bool `json:"default,omitempty"`
}
// VideoInfo contains video stream metadata detected by TrueSpec scanning.
type VideoInfo struct {
Codec string `json:"codec"`
Width int `json:"width"`
Height int `json:"height"`
BitDepth *int `json:"bitDepth,omitempty"`
HDR *string `json:"hdr,omitempty"`
FrameRate *float64 `json:"frameRate,omitempty"`
Profile *string `json:"profile,omitempty"`
}
// ─── Torrent file analysis ────
// TorrentFileInfo describes a single file inside a torrent.
type TorrentFileInfo struct {
Path string `json:"path"`
Size int64 `json:"size"`
Ext string `json:"ext"`
}
// SuspiciousFileInfo describes a file flagged as potentially dangerous.
type SuspiciousFileInfo struct {
Path string `json:"path"`
Size int64 `json:"size"`
Ext string `json:"ext"`
Reason string `json:"reason"`
VTPermalink *string `json:"vtPermalink,omitempty"`
VTDetections *int `json:"vtDetections,omitempty"`
VTTotalEngines *int `json:"vtTotalEngines,omitempty"`
VTStatus *string `json:"vtStatus,omitempty"`
}
// TorrentFilesInfo contains the file listing and threat analysis for a torrent.
type TorrentFilesInfo struct {
Total int `json:"total"`
TotalSize int64 `json:"totalSize"`
VideoFiles []TorrentFileInfo `json:"videoFiles"`
AudioFiles []TorrentFileInfo `json:"audioFiles"`
SubFiles []TorrentFileInfo `json:"subFiles"`
ImageFiles []TorrentFileInfo `json:"imageFiles"`
OtherFiles []TorrentFileInfo `json:"otherFiles"`
Suspicious []SuspiciousFileInfo `json:"suspicious"`
ThreatLevel string `json:"threatLevel"`
}
// ─── Torrent ────
// TorrentInfo contains metadata about a single torrent.
// Used by SearchResult and torrent download endpoints.
type TorrentInfo struct {
InfoHash string `json:"infoHash"`
RawTitle string `json:"rawTitle"`
Quality *string `json:"quality,omitempty"`
Codec *string `json:"codec,omitempty"`
SourceType *string `json:"sourceType,omitempty"`
SizeBytes *int64 `json:"sizeBytes,omitempty"`
Seeders int `json:"seeders"`
Leechers int `json:"leechers"`
MagnetURL *string `json:"magnetUrl,omitempty"`
Source string `json:"source"`
QualityScore *int `json:"qualityScore,omitempty"`
UploadedAt *string `json:"uploadedAt,omitempty"`
Languages []string `json:"languages"`
AudioCodec *string `json:"audioCodec,omitempty"`
AudioTracks []AudioTrack `json:"audioTracks,omitempty"`
SubtitleTracks []SubtitleTrack `json:"subtitleTracks,omitempty"`
VideoInfo *VideoInfo `json:"videoInfo,omitempty"`
ScanStatus *string `json:"scanStatus,omitempty"`
ThreatLevel *string `json:"threatLevel,omitempty"`
TorrentFiles *TorrentFilesInfo `json:"torrentFiles,omitempty"`
HDRType *string `json:"hdrType,omitempty"`
ReleaseGroup *string `json:"releaseGroup,omitempty"`
IsProper *bool `json:"isProper,omitempty"`
IsRepack *bool `json:"isRepack,omitempty"`
IsRemastered *bool `json:"isRemastered,omitempty"`
Season *int `json:"season,omitempty"`
Episode *int `json:"episode,omitempty"`
SubtitleLanguages []string `json:"subtitleLanguages"`
}
// ─── Streaming ────
// StreamingProviderItem represents a streaming service provider.
// Used by StreamingInfo in search results.
type StreamingProviderItem struct {
ProviderID int `json:"providerId"`
Name string `json:"name"`
Logo *string `json:"logo,omitempty"`
Link *string `json:"link,omitempty"`
DisplayPriority int `json:"displayPriority,omitempty"`
}
// StreamingInfo contains streaming availability grouped by type.
// Used by SearchResult when a country filter is applied.
type StreamingInfo struct {
Flatrate []StreamingProviderItem `json:"flatrate,omitempty"`
Rent []StreamingProviderItem `json:"rent,omitempty"`
Buy []StreamingProviderItem `json:"buy,omitempty"`
Free []StreamingProviderItem `json:"free,omitempty"`
}

62
upcoming.go Normal file
View file

@ -0,0 +1,62 @@
package torrentclaw
import (
"context"
"net/url"
)
// UpcomingParams holds the parameters for an upcoming releases request.
type UpcomingParams struct {
// Limit sets the number of items (1-50, default 24).
Limit int
// Page is the page number (starts at 1).
Page int
// Type filters by content type: "all", "movie", "show" (default "all").
Type string
// Locale sets the language for localized titles.
Locale string
}
// UpcomingItem represents an upcoming release.
type UpcomingItem struct {
ID int `json:"id"`
TMDbID *int `json:"tmdbId,omitempty"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
ContentType string `json:"contentType"`
PosterURL *string `json:"posterUrl,omitempty"`
BackdropURL *string `json:"backdropUrl,omitempty"`
Overview *string `json:"overview,omitempty"`
Genres []string `json:"genres,omitempty"`
RatingIMDb *string `json:"ratingImdb,omitempty"`
RatingTMDb *string `json:"ratingTmdb,omitempty"`
PopularityTMDb *float64 `json:"popularityTmdb,omitempty"`
ReleaseDate string `json:"releaseDate"`
HasTorrents bool `json:"hasTorrents"`
}
// UpcomingResponse is the paginated response from the upcoming endpoint.
type UpcomingResponse struct {
Items []UpcomingItem `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// Upcoming returns upcoming releases, optionally filtered by content type.
func (c *Client) Upcoming(ctx context.Context, params UpcomingParams) (*UpcomingResponse, error) {
q := url.Values{}
addIntParam(q, "limit", params.Limit)
addIntParam(q, "page", params.Page)
addStringParam(q, "type", params.Type)
addStringParam(q, "locale", params.Locale)
var resp UpcomingResponse
if err := c.doJSON(ctx, "/api/v1/upcoming", q, &resp); err != nil {
return nil, err
}
return &resp, nil
}

66
upcoming_test.go Normal file
View file

@ -0,0 +1,66 @@
package torrentclaw
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestUpcoming(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/upcoming" {
t.Errorf("path = %q, want /api/v1/upcoming", r.URL.Path)
}
q := r.URL.Query()
if got := q.Get("type"); got != "movie" {
t.Errorf("type = %q, want movie", got)
}
if got := q.Get("limit"); got != "10" {
t.Errorf("limit = %q, want 10", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(UpcomingResponse{
Items: []UpcomingItem{
{ID: 1, Title: "Future Movie", ContentType: "movie", ReleaseDate: "2026-05-01", HasTorrents: false},
},
Total: 25,
Page: 1,
PageSize: 10,
})
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
resp, err := c.Upcoming(context.Background(), UpcomingParams{Type: "movie", Limit: 10})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Total != 25 {
t.Errorf("Total = %d, want 25", resp.Total)
}
if len(resp.Items) != 1 {
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
}
if resp.Items[0].ReleaseDate != "2026-05-01" {
t.Errorf("ReleaseDate = %q", resp.Items[0].ReleaseDate)
}
if resp.Items[0].HasTorrents {
t.Error("HasTorrents should be false for upcoming")
}
}
func TestUpcoming_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
_, err := c.Upcoming(context.Background(), UpcomingParams{})
if err == nil {
t.Fatal("expected error")
}
}