From f6f24c2c3f27b1e79fd1a1e3539187da2f767447 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sat, 28 Mar 2026 11:28:48 +0100 Subject: [PATCH] feat: implement TorrentClaw Go API client v0.1.0 --- .github/workflows/ci.yml | 77 ++++ .gitignore | 28 ++ .lefthook/commit-msg/validate.sh | 37 ++ CHANGELOG.md | 29 ++ CONTRIBUTING.md | 148 +++++++ LICENSE | 21 + Makefile | 43 ++ README.md | 278 +++++++++++++ client.go | 367 ++++++++++++++++ client_test.go | 691 +++++++++++++++++++++++++++++++ collections.go | 80 ++++ collections_test.go | 128 ++++++ content.go | 189 +++++++++ content_test.go | 472 +++++++++++++++++++++ debrid.go | 57 +++ debrid_test.go | 137 ++++++ doc.go | 19 + errors.go | 78 ++++ errors_test.go | 139 +++++++ example_test.go | 347 ++++++++++++++++ go.mod | 3 + health.go | 58 +++ health_test.go | 112 +++++ lefthook.yml | 26 ++ search.go | 167 ++++++++ search_test.go | 345 +++++++++++++++ stats.go | 47 +++ stats_test.go | 92 ++++ streaming.go | 56 +++ streaming_test.go | 92 ++++ torrent.go | 19 + torrent_test.go | 108 +++++ torznab.go | 59 +++ torznab_test.go | 117 ++++++ trending.go | 60 +++ trending_test.go | 87 ++++ types.go | 126 ++++++ upcoming.go | 62 +++ upcoming_test.go | 66 +++ 39 files changed, 5067 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100755 .lefthook/commit-msg/validate.sh create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 client.go create mode 100644 client_test.go create mode 100644 collections.go create mode 100644 collections_test.go create mode 100644 content.go create mode 100644 content_test.go create mode 100644 debrid.go create mode 100644 debrid_test.go create mode 100644 doc.go create mode 100644 errors.go create mode 100644 errors_test.go create mode 100644 example_test.go create mode 100644 go.mod create mode 100644 health.go create mode 100644 health_test.go create mode 100644 lefthook.yml create mode 100644 search.go create mode 100644 search_test.go create mode 100644 stats.go create mode 100644 stats_test.go create mode 100644 streaming.go create mode 100644 streaming_test.go create mode 100644 torrent.go create mode 100644 torrent_test.go create mode 100644 torznab.go create mode 100644 torznab_test.go create mode 100644 trending.go create mode 100644 trending_test.go create mode 100644 types.go create mode 100644 upcoming.go create mode 100644 upcoming_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f48b90 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a334cd6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.lefthook/commit-msg/validate.sh b/.lefthook/commit-msg/validate.sh new file mode 100755 index 0000000..46e21ee --- /dev/null +++ b/.lefthook/commit-msg/validate.sh @@ -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: [optional scope]: " + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ffd72fe --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3b989d2 --- /dev/null +++ b/CONTRIBUTING.md @@ -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: + +``` +[optional scope]: +``` + +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! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94b8048 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cfe1eac --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..80b4753 --- /dev/null +++ b/README.md @@ -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) + +--- + +

+ Made with ❤️ by the TorrentClaw community +
+ Website · GitHub +

diff --git a/client.go b/client.go new file mode 100644 index 0000000..589dc95 --- /dev/null +++ b/client.go @@ -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 +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..4bce713 --- /dev/null +++ b/client_test.go @@ -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("")) + })) + 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) != "" { + t.Errorf("data = %q, want ", string(data)) + } +} diff --git a/collections.go b/collections.go new file mode 100644 index 0000000..3d1f7f6 --- /dev/null +++ b/collections.go @@ -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 +} diff --git a/collections_test.go b/collections_test.go new file mode 100644 index 0000000..6c3284e --- /dev/null +++ b/collections_test.go @@ -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") + } +} diff --git a/content.go b/content.go new file mode 100644 index 0000000..5f9e098 --- /dev/null +++ b/content.go @@ -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 +} diff --git a/content_test.go b/content_test.go new file mode 100644 index 0000000..28c38c6 --- /dev/null +++ b/content_test.go @@ -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) + } +} diff --git a/debrid.go b/debrid.go new file mode 100644 index 0000000..bb50894 --- /dev/null +++ b/debrid.go @@ -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 +} diff --git a/debrid_test.go b/debrid_test.go new file mode 100644 index 0000000..efdbbc6 --- /dev/null +++ b/debrid_test.go @@ -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") + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..b66b2f3 --- /dev/null +++ b/doc.go @@ -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 diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..028d9fd --- /dev/null +++ b/errors.go @@ -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), + } +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..e470e60 --- /dev/null +++ b/errors_test.go @@ -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) + } +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..a6b6a62 --- /dev/null +++ b/example_test.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e9c791 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/torrentclaw/go-client + +go 1.22 diff --git a/health.go b/health.go new file mode 100644 index 0000000..2c888f9 --- /dev/null +++ b/health.go @@ -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 +} diff --git a/health_test.go b/health_test.go new file mode 100644 index 0000000..c4d56ad --- /dev/null +++ b/health_test.go @@ -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") + } +} diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..d6412ec --- /dev/null +++ b/lefthook.yml @@ -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 diff --git a/search.go b/search.go new file mode 100644 index 0000000..90c35ed --- /dev/null +++ b/search.go @@ -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 +} diff --git a/search_test.go b/search_test.go new file mode 100644 index 0000000..d99e61f --- /dev/null +++ b/search_test.go @@ -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)) + } +} diff --git a/stats.go b/stats.go new file mode 100644 index 0000000..d696cd0 --- /dev/null +++ b/stats.go @@ -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 +} diff --git a/stats_test.go b/stats_test.go new file mode 100644 index 0000000..976f3a6 --- /dev/null +++ b/stats_test.go @@ -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) + } +} diff --git a/streaming.go b/streaming.go new file mode 100644 index 0000000..14dc262 --- /dev/null +++ b/streaming.go @@ -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 +} diff --git a/streaming_test.go b/streaming_test.go new file mode 100644 index 0000000..63148f2 --- /dev/null +++ b/streaming_test.go @@ -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") + } +} diff --git a/torrent.go b/torrent.go new file mode 100644 index 0000000..1939346 --- /dev/null +++ b/torrent.go @@ -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) +} diff --git a/torrent_test.go b/torrent_test.go new file mode 100644 index 0000000..b450f36 --- /dev/null +++ b/torrent_test.go @@ -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") + } +} diff --git a/torznab.go b/torznab.go new file mode 100644 index 0000000..5886fd8 --- /dev/null +++ b/torznab.go @@ -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"}) +} diff --git a/torznab_test.go b/torznab_test.go new file mode 100644 index 0000000..2f174a5 --- /dev/null +++ b/torznab_test.go @@ -0,0 +1,117 @@ +package torrentclaw + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTorznab_Search(t *testing.T) { + xmlResp := `Test` + 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), "Test") { + 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("")) + })) + 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 := `` + 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) + } +} diff --git a/trending.go b/trending.go new file mode 100644 index 0000000..2144a81 --- /dev/null +++ b/trending.go @@ -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 +} diff --git a/trending_test.go b/trending_test.go new file mode 100644 index 0000000..9633c05 --- /dev/null +++ b/trending_test.go @@ -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") + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..f7e849f --- /dev/null +++ b/types.go @@ -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"` +} diff --git a/upcoming.go b/upcoming.go new file mode 100644 index 0000000..2cc528b --- /dev/null +++ b/upcoming.go @@ -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 +} diff --git a/upcoming_test.go b/upcoming_test.go new file mode 100644 index 0000000..22bfc5f --- /dev/null +++ b/upcoming_test.go @@ -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") + } +}