feat: implement TorrentClaw Go API client v0.1.0
This commit is contained in:
commit
f6f24c2c3f
39 changed files with 5067 additions and 0 deletions
77
.github/workflows/ci.yml
vendored
Normal file
77
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
go-version: ["1.22", "1.23", "1.24"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ matrix.go-version }}
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test -v -race -count=1 ./...
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
if: matrix.go-version == '1.24'
|
||||||
|
run: |
|
||||||
|
go test -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||||
|
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
|
||||||
|
echo "Total coverage: ${COVERAGE}%"
|
||||||
|
if [ "$(echo "$COVERAGE < 90" | bc)" -eq 1 ]; then
|
||||||
|
echo "::error::Coverage ${COVERAGE}% is below 90% threshold"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
if: matrix.go-version == '1.24'
|
||||||
|
uses: codecov/codecov-action@v4
|
||||||
|
with:
|
||||||
|
files: coverage.out
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.24"
|
||||||
|
|
||||||
|
- name: Run golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v6
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
vet:
|
||||||
|
name: Vet
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.24"
|
||||||
|
|
||||||
|
- name: Run go vet
|
||||||
|
run: go vet ./...
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of go coverage
|
||||||
|
*.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Go workspace
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
37
.lefthook/commit-msg/validate.sh
Executable file
37
.lefthook/commit-msg/validate.sh
Executable file
|
|
@ -0,0 +1,37 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Validate commit message follows Conventional Commits format.
|
||||||
|
# Allowed types: feat, fix, docs, test, chore, refactor, ci, style, perf, build
|
||||||
|
#
|
||||||
|
# Valid examples:
|
||||||
|
# feat: add search by genre
|
||||||
|
# fix(client): handle nil response
|
||||||
|
# docs: update README
|
||||||
|
# feat!: breaking change in API
|
||||||
|
|
||||||
|
commit_msg_file="$1"
|
||||||
|
commit_msg=$(head -1 "$commit_msg_file")
|
||||||
|
|
||||||
|
# Allow merge commits
|
||||||
|
if echo "$commit_msg" | grep -qE '^Merge '; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Conventional Commits regex:
|
||||||
|
# type(optional-scope)optional-!: description
|
||||||
|
pattern='^(feat|fix|docs|test|chore|refactor|ci|style|perf|build)(\([a-zA-Z0-9_-]+\))?!?: .+'
|
||||||
|
|
||||||
|
if ! echo "$commit_msg" | grep -qE "$pattern"; then
|
||||||
|
echo "ERROR: Commit message does not follow Conventional Commits format."
|
||||||
|
echo ""
|
||||||
|
echo " Expected: <type>[optional scope]: <description>"
|
||||||
|
echo ""
|
||||||
|
echo " Allowed types: feat, fix, docs, test, chore, refactor, ci, style, perf, build"
|
||||||
|
echo ""
|
||||||
|
echo " Examples:"
|
||||||
|
echo " feat: add search by genre"
|
||||||
|
echo " fix(client): handle nil response body"
|
||||||
|
echo " docs: update API reference"
|
||||||
|
echo ""
|
||||||
|
echo " Your message: $commit_msg"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-01-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release of the TorrentClaw Go client library.
|
||||||
|
- `Search` — full-text search with advanced filtering (type, genre, year, quality, language, audio, HDR, sort, pagination, country, locale, availability).
|
||||||
|
- `Autocomplete` — title suggestions for search-as-you-type.
|
||||||
|
- `Popular` — trending content by community engagement.
|
||||||
|
- `Recent` — recently added movies and TV shows.
|
||||||
|
- `WatchProviders` — streaming availability (flatrate, rent, buy, free) with VPN suggestions.
|
||||||
|
- `Credits` — director and top cast members.
|
||||||
|
- `Stats` — aggregator statistics (content counts, torrent counts, ingestion history).
|
||||||
|
- `GetTorrentFile` — download raw `.torrent` file bytes.
|
||||||
|
- `TorrentDownloadURL` — construct download URL without making an HTTP call.
|
||||||
|
- Functional options pattern for client configuration (`WithAPIKey`, `WithBaseURL`, `WithTimeout`, `WithRetry`, `WithHTTPClient`, `WithUserAgent`).
|
||||||
|
- Exponential backoff retry for transient errors (429, 5xx).
|
||||||
|
- Custom `APIError` type with helper methods (`IsRetryable`, `IsRateLimited`, `IsNotFound`).
|
||||||
|
- Context support on all methods.
|
||||||
|
- Zero external dependencies (stdlib only).
|
||||||
|
- Comprehensive test suite with `httptest`.
|
||||||
|
- Example tests for godoc.
|
||||||
|
- CI workflow with GitHub Actions.
|
||||||
148
CONTRIBUTING.md
Normal file
148
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
# Contributing to TorrentClaw Go Client
|
||||||
|
|
||||||
|
Thank you for your interest in contributing! This guide will help you get started.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. **Fork** the repository on GitHub
|
||||||
|
2. **Clone** your fork locally:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR-USERNAME/torrentclaw-go-client.git
|
||||||
|
cd torrentclaw-go-client
|
||||||
|
```
|
||||||
|
3. **Create a branch** for your change:
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/my-feature
|
||||||
|
```
|
||||||
|
4. **Make your changes**, write tests, and ensure everything passes
|
||||||
|
5. **Commit** with a clear message (see [Commit Messages](#commit-messages))
|
||||||
|
6. **Push** to your fork and [open a Pull Request](https://github.com/torrentclaw/go-client/compare)
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
You need **Go 1.22+** installed.
|
||||||
|
|
||||||
|
### Git Hooks (Lefthook)
|
||||||
|
|
||||||
|
This project uses [Lefthook](https://github.com/evilmartians/lefthook) to run pre-commit checks and validate commit messages automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install lefthook (pick one):
|
||||||
|
brew install lefthook # macOS
|
||||||
|
go install github.com/evilmartians/lefthook@latest # Go
|
||||||
|
npm install -g lefthook # npm
|
||||||
|
|
||||||
|
# Activate hooks in your local clone:
|
||||||
|
make install-hooks
|
||||||
|
# or: lefthook install
|
||||||
|
```
|
||||||
|
|
||||||
|
Once installed, every commit will automatically:
|
||||||
|
- **pre-commit**: check `gofmt`, run `go vet`, and run `golangci-lint` (if installed)
|
||||||
|
- **commit-msg**: validate the message follows [Conventional Commits](#commit-messages)
|
||||||
|
|
||||||
|
### Make Targets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # Run tests
|
||||||
|
make coverage # Run tests with coverage (90% threshold enforced)
|
||||||
|
make lint # Run golangci-lint
|
||||||
|
make fmt # Format code (gofmt -s -w)
|
||||||
|
make check # Verify formatting (no write, CI-friendly)
|
||||||
|
make vet # Run go vet
|
||||||
|
make all # fmt + vet + lint + test
|
||||||
|
make install-hooks # Install lefthook git hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Run `gofmt` on all code (or `make fmt`)
|
||||||
|
- Run `golangci-lint` (or `make lint`)
|
||||||
|
- This project has **zero external dependencies** — keep it that way. Only use the Go standard library.
|
||||||
|
- Follow existing patterns in the codebase:
|
||||||
|
- Functional options for configuration (`WithXxx`)
|
||||||
|
- `context.Context` as the first parameter on all public methods
|
||||||
|
- Custom error types with helper methods
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Specific test
|
||||||
|
go test -run TestSearch -v ./...
|
||||||
|
|
||||||
|
# With coverage report
|
||||||
|
make coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests run in CI against Go 1.22, 1.23, and 1.24. A minimum of **90% code coverage** is enforced.
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
This project enforces [Conventional Commits](https://www.conventionalcommits.org/) via a git hook. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>[optional scope]: <description>
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed types: `feat`, `fix`, `docs`, `test`, `chore`, `refactor`, `ci`, `style`, `perf`, `build`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add support for filtering by audio codec
|
||||||
|
fix(client): handle nil response body on 204
|
||||||
|
docs: update Quick Start example
|
||||||
|
test: add edge case tests for retry logic
|
||||||
|
chore: update CI matrix to Go 1.24
|
||||||
|
refactor: extract retry logic into helper
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
- Keep PRs focused — one feature or fix per PR
|
||||||
|
- Include tests for new functionality
|
||||||
|
- Update documentation if the public API changes
|
||||||
|
- Ensure all CI checks pass before requesting review
|
||||||
|
- Link related issues in the PR description
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
[Open an issue](https://github.com/torrentclaw/go-client/issues/new?labels=bug) with:
|
||||||
|
|
||||||
|
- **Description** — what went wrong
|
||||||
|
- **Steps to reproduce** — minimal code or commands to trigger the bug
|
||||||
|
- **Expected behavior** — what you expected to happen
|
||||||
|
- **Actual behavior** — what actually happened
|
||||||
|
- **Environment** — Go version, OS, client version
|
||||||
|
|
||||||
|
## Requesting Features
|
||||||
|
|
||||||
|
[Open an issue](https://github.com/torrentclaw/go-client/issues/new?labels=enhancement) with:
|
||||||
|
|
||||||
|
- **Problem** — what are you trying to solve?
|
||||||
|
- **Proposed solution** — how do you think it should work?
|
||||||
|
- **Alternatives considered** — other approaches you thought about
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||||
|
|
||||||
|
In short:
|
||||||
|
|
||||||
|
- **Be respectful** — treat everyone with dignity regardless of background or experience level
|
||||||
|
- **Be constructive** — focus on what's best for the project and community
|
||||||
|
- **Be collaborative** — welcome newcomers, help others learn
|
||||||
|
- **No harassment** — unacceptable behavior includes trolling, insults, and unwelcome attention
|
||||||
|
|
||||||
|
Violations can be reported to the project maintainers. All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
If you're unsure about something, [open a discussion](https://github.com/torrentclaw/go-client/issues) or reach out on Discord (coming soon).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thank you for helping make TorrentClaw better!
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 TorrentClaw
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
43
Makefile
Normal file
43
Makefile
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
.PHONY: all test lint coverage clean fmt vet check install-hooks
|
||||||
|
|
||||||
|
all: fmt vet lint test
|
||||||
|
|
||||||
|
## Run all tests
|
||||||
|
test:
|
||||||
|
go test -v -race -count=1 ./...
|
||||||
|
|
||||||
|
## Run linter (requires golangci-lint)
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
## Run tests with coverage report
|
||||||
|
coverage:
|
||||||
|
go test -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||||
|
go tool cover -func=coverage.out
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
## Format code
|
||||||
|
fmt:
|
||||||
|
gofmt -s -w .
|
||||||
|
|
||||||
|
## Check formatting (no write, exits non-zero if unformatted)
|
||||||
|
check:
|
||||||
|
@test -z "$$(gofmt -l .)" || { echo "Files not formatted:"; gofmt -l .; exit 1; }
|
||||||
|
|
||||||
|
## Run go vet
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
## Install lefthook git hooks
|
||||||
|
install-hooks:
|
||||||
|
lefthook install
|
||||||
|
|
||||||
|
## Remove generated files
|
||||||
|
clean:
|
||||||
|
rm -f coverage.out coverage.html
|
||||||
|
|
||||||
|
# Release with goreleaser (future):
|
||||||
|
# 1. Install goreleaser: https://goreleaser.com/install/
|
||||||
|
# 2. Create .goreleaser.yml config
|
||||||
|
# 3. Tag a version: git tag -a v0.x.0 -m "release v0.x.0"
|
||||||
|
# 4. Run: goreleaser release --clean
|
||||||
278
README.md
Normal file
278
README.md
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
# TorrentClaw Go Client
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/github.com/torrentclaw/go-client)
|
||||||
|
[](https://github.com/torrentclaw/go-client/actions/workflows/ci.yml)
|
||||||
|
[](https://goreportcard.com/report/github.com/torrentclaw/go-client)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://torrentclaw.com)
|
||||||
|
|
||||||
|
Go client library for the [TorrentClaw](https://torrentclaw.com) API.
|
||||||
|
|
||||||
|
## About TorrentClaw
|
||||||
|
|
||||||
|
[TorrentClaw](https://torrentclaw.com) is a torrent search engine that aggregates movies and TV shows from **30+ international sources** into a single, clean API. No ads, no tracking.
|
||||||
|
|
||||||
|
- **Quality scoring** — torrents ranked by real quality metrics, not just seeders
|
||||||
|
- **TMDB metadata** — posters, ratings, genres, cast, and crew for every result
|
||||||
|
- **Watch providers** — see where content is streaming (Netflix, Amazon, Disney+, etc.)
|
||||||
|
- **Semantic search** — natural language queries in 11+ languages
|
||||||
|
- **TrueSpec verification** — verify actual media specs from info hashes
|
||||||
|
|
||||||
|
### Links
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Website** | [torrentclaw.com](https://torrentclaw.com) |
|
||||||
|
| **API Docs (OpenAPI)** | [torrentclaw.com/api/openapi.json](https://torrentclaw.com/api/openapi.json) |
|
||||||
|
| **Discord** | Coming soon |
|
||||||
|
| **GitHub** | [github.com/torrentclaw](https://github.com/torrentclaw) |
|
||||||
|
|
||||||
|
## Ecosystem
|
||||||
|
|
||||||
|
TorrentClaw is more than just an API. It's a growing ecosystem of tools:
|
||||||
|
|
||||||
|
| Project | Language | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| [torrentclaw-go-client](https://github.com/torrentclaw/go-client) | Go | API client library **(this repo)** |
|
||||||
|
| torrentclaw-cli | Go | Command-line interface *(coming soon)* |
|
||||||
|
| [torrentclaw-mcp](https://github.com/torrentclaw/torrentclaw-mcp) | TypeScript | MCP server for AI agents |
|
||||||
|
| [truespec](https://github.com/torrentclaw/truespec) | Go | Verify real media specs from info hashes |
|
||||||
|
| [torrentclaw-skill](https://github.com/torrentclaw/torrentclaw-skill) | - | Agent skill for OpenClaw |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Zero external dependencies** — stdlib only
|
||||||
|
- **Context support** on all methods
|
||||||
|
- **Functional options** for client configuration
|
||||||
|
- **Exponential backoff retry** for transient errors (429, 5xx)
|
||||||
|
- **Custom error types** with helper methods (`IsRetryable`, `IsRateLimited`, `IsNotFound`)
|
||||||
|
- **Full API coverage** — search, autocomplete, popular, recent, watch providers, credits, stats, torrent download
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/torrentclaw/go-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Go 1.22 or later.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
torrentclaw "github.com/torrentclaw/go-client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client := torrentclaw.NewClient(
|
||||||
|
torrentclaw.WithAPIKey("your-api-key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := client.Search(context.Background(), torrentclaw.SearchParams{
|
||||||
|
Query: "Inception",
|
||||||
|
Type: "movie",
|
||||||
|
Quality: "1080p",
|
||||||
|
Sort: "seeders",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range resp.Results {
|
||||||
|
fmt.Printf("%s (%d)\n", result.Title, *result.Year)
|
||||||
|
for _, t := range result.Torrents {
|
||||||
|
fmt.Printf(" %s — %d seeders\n", t.InfoHash, t.Seeders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Configuration
|
||||||
|
|
||||||
|
```go
|
||||||
|
client := torrentclaw.NewClient(
|
||||||
|
torrentclaw.WithAPIKey("your-api-key"), // API key (X-API-Key header)
|
||||||
|
torrentclaw.WithBaseURL("https://custom.example"), // Custom base URL
|
||||||
|
torrentclaw.WithTimeout(30 * time.Second), // HTTP timeout (default: 15s)
|
||||||
|
torrentclaw.WithUserAgent("my-app/1.0"), // Custom User-Agent
|
||||||
|
torrentclaw.WithHTTPClient(customHTTPClient), // Custom *http.Client
|
||||||
|
torrentclaw.WithRetry(5, 2*time.Second, 60*time.Second), // Retry policy
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
```go
|
||||||
|
resp, err := client.Search(ctx, torrentclaw.SearchParams{
|
||||||
|
Query: "Breaking Bad",
|
||||||
|
Type: "show", // "movie" or "show"
|
||||||
|
Genre: "Drama", // exact genre match
|
||||||
|
YearMin: 2008,
|
||||||
|
YearMax: 2013,
|
||||||
|
MinRating: 8.0, // 0-10
|
||||||
|
Quality: "1080p", // "480p", "720p", "1080p", "2160p"
|
||||||
|
Language: "en", // ISO 639 code
|
||||||
|
Audio: "atmos", // audio codec substring
|
||||||
|
HDR: "dolby_vision", // HDR format
|
||||||
|
Sort: "seeders", // "relevance", "seeders", "year", "rating", "added"
|
||||||
|
Page: 1,
|
||||||
|
Limit: 20,
|
||||||
|
Country: "US", // includes streaming availability
|
||||||
|
Locale: "es", // localized titles/overviews
|
||||||
|
Availability: "available", // "all", "available", "unavailable"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autocomplete
|
||||||
|
|
||||||
|
```go
|
||||||
|
suggestions, err := client.Autocomplete(ctx, "incep")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Popular Content
|
||||||
|
|
||||||
|
```go
|
||||||
|
resp, err := client.Popular(ctx, 10, 1) // limit, page (0 = server default)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recent Content
|
||||||
|
|
||||||
|
```go
|
||||||
|
resp, err := client.Recent(ctx, 12, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Watch Providers
|
||||||
|
|
||||||
|
```go
|
||||||
|
resp, err := client.WatchProviders(ctx, contentID, "US")
|
||||||
|
// resp.Providers.Flatrate — subscription (Netflix, Disney+, etc.)
|
||||||
|
// resp.Providers.Rent — available for rental
|
||||||
|
// resp.Providers.Buy — available for purchase
|
||||||
|
// resp.Providers.Free — free with ads
|
||||||
|
// resp.VPNSuggestion — available in other countries
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credits
|
||||||
|
|
||||||
|
```go
|
||||||
|
credits, err := client.Credits(ctx, contentID)
|
||||||
|
// credits.Director — director name
|
||||||
|
// credits.Cast — top 10 cast members
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
```go
|
||||||
|
stats, err := client.Stats(ctx)
|
||||||
|
// stats.Content.Movies, stats.Content.Shows
|
||||||
|
// stats.Torrents.Total, stats.Torrents.BySource
|
||||||
|
// stats.RecentIngestions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Torrent File
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Get the download URL (no HTTP call)
|
||||||
|
url := client.TorrentDownloadURL("abc123...")
|
||||||
|
|
||||||
|
// Download the .torrent file
|
||||||
|
data, err := client.GetTorrentFile(ctx, "abc123...")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All API errors are returned as `*torrentclaw.APIError`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
resp, err := client.Search(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
var apiErr *torrentclaw.APIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
fmt.Printf("HTTP %d: %s\n", apiErr.StatusCode, apiErr.Message)
|
||||||
|
|
||||||
|
if apiErr.IsRateLimited() {
|
||||||
|
// Handle 429
|
||||||
|
}
|
||||||
|
if apiErr.IsNotFound() {
|
||||||
|
// Handle 404
|
||||||
|
}
|
||||||
|
if apiErr.IsRetryable() {
|
||||||
|
// 429, 500, 502, 503 — retries are automatic by default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Transient errors (429, 500, 502, 503) are automatically retried with exponential backoff. Configure the retry policy with `WithRetry`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Disable retries
|
||||||
|
client := torrentclaw.NewClient(torrentclaw.WithRetry(0, 0, 0))
|
||||||
|
|
||||||
|
// Custom: 5 retries, starting at 2s, capped at 60s
|
||||||
|
client := torrentclaw.NewClient(torrentclaw.WithRetry(5, 2*time.Second, 60*time.Second))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install git hooks (requires lefthook)
|
||||||
|
make install-hooks
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
make coverage
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
make fmt
|
||||||
|
|
||||||
|
# Check formatting (CI-friendly, no write)
|
||||||
|
make check
|
||||||
|
|
||||||
|
# Run all checks
|
||||||
|
make all
|
||||||
|
```
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for full setup instructions including lefthook installation.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) before submitting a pull request.
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
Found a bug? [Open an issue](https://github.com/torrentclaw/go-client/issues/new?labels=bug&template=bug_report.md) on GitHub with:
|
||||||
|
|
||||||
|
- A clear description of the problem
|
||||||
|
- Steps to reproduce
|
||||||
|
- Expected vs actual behavior
|
||||||
|
- Go version and OS
|
||||||
|
|
||||||
|
## Requesting Features
|
||||||
|
|
||||||
|
Have an idea? [Open a feature request](https://github.com/torrentclaw/go-client/issues/new?labels=enhancement&template=feature_request.md) on GitHub. We'd love to hear from you.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Made with ❤️ by the <a href="https://github.com/torrentclaw">TorrentClaw</a> community
|
||||||
|
<br>
|
||||||
|
<a href="https://torrentclaw.com">Website</a> · <a href="https://github.com/torrentclaw">GitHub</a>
|
||||||
|
</p>
|
||||||
367
client.go
Normal file
367
client.go
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the library version, used in the default User-Agent header.
|
||||||
|
const Version = "0.2.0"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBaseURL = "https://torrentclaw.com"
|
||||||
|
defaultTimeout = 15 * time.Second
|
||||||
|
defaultUserAgent = "torrentclaw-go-client/" + Version
|
||||||
|
|
||||||
|
defaultMaxRetries = 3
|
||||||
|
defaultRetryBaseWait = 1 * time.Second
|
||||||
|
defaultRetryMaxWait = 30 * time.Second
|
||||||
|
|
||||||
|
headerAPIKey = "X-API-Key"
|
||||||
|
headerAuthorization = "Authorization"
|
||||||
|
headerSearchSource = "X-Search-Source"
|
||||||
|
headerUserAgent = "User-Agent"
|
||||||
|
headerDebridProvider = "X-Debrid-Provider"
|
||||||
|
headerDebridKey = "X-Debrid-Key"
|
||||||
|
|
||||||
|
searchSource = "go-client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a TorrentClaw API client. Use [NewClient] to create one.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
bearerToken string
|
||||||
|
userAgent string
|
||||||
|
httpClient *http.Client
|
||||||
|
maxRetries int
|
||||||
|
retryBaseWait time.Duration
|
||||||
|
retryMaxWait time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures a [Client].
|
||||||
|
type Option func(*Client)
|
||||||
|
|
||||||
|
// WithBaseURL sets a custom API base URL. The default is https://torrentclaw.com.
|
||||||
|
func WithBaseURL(u string) Option {
|
||||||
|
return func(c *Client) { c.baseURL = u }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAPIKey sets the API key sent as the X-API-Key header.
|
||||||
|
func WithAPIKey(key string) Option {
|
||||||
|
return func(c *Client) { c.apiKey = key }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBearerToken sets a bearer token sent as the Authorization header.
|
||||||
|
// If both WithBearerToken and WithAPIKey are used, the bearer token takes precedence.
|
||||||
|
func WithBearerToken(token string) Option {
|
||||||
|
return func(c *Client) { c.bearerToken = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithUserAgent sets a custom User-Agent header.
|
||||||
|
func WithUserAgent(ua string) Option {
|
||||||
|
return func(c *Client) { c.userAgent = ua }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHTTPClient sets a custom *http.Client for all requests.
|
||||||
|
func WithHTTPClient(hc *http.Client) Option {
|
||||||
|
return func(c *Client) { c.httpClient = hc }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTimeout sets the HTTP client timeout. The default is 15 seconds.
|
||||||
|
// This option is safe to use regardless of option ordering; it sets the
|
||||||
|
// timeout on the client's internal HTTP client.
|
||||||
|
func WithTimeout(d time.Duration) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
if c.httpClient == nil {
|
||||||
|
c.httpClient = &http.Client{}
|
||||||
|
}
|
||||||
|
c.httpClient.Timeout = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRetry configures the retry policy for transient errors (429, 5xx).
|
||||||
|
// maxRetries is the maximum number of retries (0 disables retrying).
|
||||||
|
// baseWait is the initial wait duration before the first retry.
|
||||||
|
// maxWait caps the exponential backoff duration.
|
||||||
|
func WithRetry(maxRetries int, baseWait, maxWait time.Duration) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
c.maxRetries = maxRetries
|
||||||
|
c.retryBaseWait = baseWait
|
||||||
|
c.retryMaxWait = maxWait
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new TorrentClaw API client with the given options.
|
||||||
|
func NewClient(opts ...Option) *Client {
|
||||||
|
c := &Client{
|
||||||
|
baseURL: defaultBaseURL,
|
||||||
|
userAgent: defaultUserAgent,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: defaultTimeout,
|
||||||
|
},
|
||||||
|
maxRetries: defaultMaxRetries,
|
||||||
|
retryBaseWait: defaultRetryBaseWait,
|
||||||
|
retryMaxWait: defaultRetryMaxWait,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(c)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// doJSON performs an HTTP GET request, retries on transient errors, and
|
||||||
|
// decodes the JSON response into dst.
|
||||||
|
func (c *Client) doJSON(ctx context.Context, path string, query url.Values, dst any) error {
|
||||||
|
u, err := url.Parse(c.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("torrentclaw: invalid base URL: %w", err)
|
||||||
|
}
|
||||||
|
u.Path = path
|
||||||
|
if query != nil {
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
attempts := 1 + c.maxRetries
|
||||||
|
for i := range attempts {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("torrentclaw: failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
c.setHeaders(req)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("torrentclaw: request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
err := json.NewDecoder(resp.Body).Decode(dst)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("torrentclaw: failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body := readErrorBody(resp)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
apiErr := newAPIError(resp.StatusCode, body)
|
||||||
|
lastErr = apiErr
|
||||||
|
|
||||||
|
if !apiErr.IsRetryable() || i == attempts-1 {
|
||||||
|
return apiErr
|
||||||
|
}
|
||||||
|
|
||||||
|
wait := c.backoffDuration(i)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRaw performs an HTTP GET request, retries on transient errors, and
|
||||||
|
// returns the raw response body bytes.
|
||||||
|
func (c *Client) doRaw(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
||||||
|
u, err := url.Parse(c.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("torrentclaw: invalid base URL: %w", err)
|
||||||
|
}
|
||||||
|
u.Path = path
|
||||||
|
if query != nil {
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
attempts := 1 + c.maxRetries
|
||||||
|
for i := range attempts {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("torrentclaw: failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
c.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("torrentclaw: request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("torrentclaw: failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body := readErrorBody(resp)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
apiErr := newAPIError(resp.StatusCode, body)
|
||||||
|
lastErr = apiErr
|
||||||
|
|
||||||
|
if !apiErr.IsRetryable() || i == attempts-1 {
|
||||||
|
return nil, apiErr
|
||||||
|
}
|
||||||
|
|
||||||
|
wait := c.backoffDuration(i)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// setHeaders applies common headers to an outgoing request.
|
||||||
|
func (c *Client) setHeaders(req *http.Request) {
|
||||||
|
req.Header.Set(headerUserAgent, c.userAgent)
|
||||||
|
req.Header.Set(headerSearchSource, searchSource)
|
||||||
|
if c.bearerToken != "" {
|
||||||
|
req.Header.Set(headerAuthorization, "Bearer "+c.bearerToken)
|
||||||
|
} else if c.apiKey != "" {
|
||||||
|
req.Header.Set(headerAPIKey, c.apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backoffDuration computes the wait time for retry attempt i using
|
||||||
|
// exponential backoff capped at retryMaxWait.
|
||||||
|
func (c *Client) backoffDuration(attempt int) time.Duration {
|
||||||
|
wait := time.Duration(float64(c.retryBaseWait) * math.Pow(2, float64(attempt)))
|
||||||
|
if wait > c.retryMaxWait {
|
||||||
|
wait = c.retryMaxWait
|
||||||
|
}
|
||||||
|
return wait
|
||||||
|
}
|
||||||
|
|
||||||
|
// readErrorBody reads up to 512 bytes of the response body for error context.
|
||||||
|
// For server errors (5xx), an empty string is returned to avoid leaking internals.
|
||||||
|
func readErrorBody(resp *http.Response) string {
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addIntParam adds an integer query parameter if the value is non-zero.
|
||||||
|
func addIntParam(q url.Values, key string, val int) {
|
||||||
|
if val != 0 {
|
||||||
|
q.Set(key, strconv.Itoa(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFloatParam adds a float query parameter if the value is non-zero.
|
||||||
|
func addFloatParam(q url.Values, key string, val float64) {
|
||||||
|
if val != 0 {
|
||||||
|
q.Set(key, strconv.FormatFloat(val, 'f', -1, 64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addStringParam adds a string query parameter if the value is non-empty.
|
||||||
|
func addStringParam(q url.Values, key, val string) {
|
||||||
|
if val != "" {
|
||||||
|
q.Set(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addBoolParam adds a boolean query parameter if the value is true.
|
||||||
|
func addBoolParam(q url.Values, key string, val bool) {
|
||||||
|
if val {
|
||||||
|
q.Set(key, "true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doPost performs an HTTP POST request with a JSON body, retries on transient
|
||||||
|
// errors, and decodes the JSON response into dst. Extra headers (e.g. debrid
|
||||||
|
// provider credentials) are applied on top of the common headers.
|
||||||
|
func (c *Client) doPost(ctx context.Context, path string, body any, dst any, extraHeaders map[string]string) error {
|
||||||
|
u, err := url.Parse(c.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("torrentclaw: invalid base URL: %w", err)
|
||||||
|
}
|
||||||
|
u.Path = path
|
||||||
|
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("torrentclaw: failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
attempts := 1 + c.maxRetries
|
||||||
|
for i := range attempts {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("torrentclaw: failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
c.setHeaders(req)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
for k, v := range extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("torrentclaw: request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
err := json.NewDecoder(resp.Body).Decode(dst)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("torrentclaw: failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
errBody := readErrorBody(resp)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
apiErr := newAPIError(resp.StatusCode, errBody)
|
||||||
|
lastErr = apiErr
|
||||||
|
|
||||||
|
if !apiErr.IsRetryable() || i == attempts-1 {
|
||||||
|
return apiErr
|
||||||
|
}
|
||||||
|
|
||||||
|
wait := c.backoffDuration(i)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
691
client_test.go
Normal file
691
client_test.go
Normal file
|
|
@ -0,0 +1,691 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewClientDefaults(t *testing.T) {
|
||||||
|
c := NewClient()
|
||||||
|
if c.baseURL != defaultBaseURL {
|
||||||
|
t.Errorf("baseURL = %q, want %q", c.baseURL, defaultBaseURL)
|
||||||
|
}
|
||||||
|
if c.userAgent != defaultUserAgent {
|
||||||
|
t.Errorf("userAgent = %q, want %q", c.userAgent, defaultUserAgent)
|
||||||
|
}
|
||||||
|
if c.apiKey != "" {
|
||||||
|
t.Errorf("apiKey = %q, want empty", c.apiKey)
|
||||||
|
}
|
||||||
|
if c.maxRetries != defaultMaxRetries {
|
||||||
|
t.Errorf("maxRetries = %d, want %d", c.maxRetries, defaultMaxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientOptions(t *testing.T) {
|
||||||
|
hc := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL("https://custom.example.com"),
|
||||||
|
WithAPIKey("test-key-123"),
|
||||||
|
WithUserAgent("my-app/1.0"),
|
||||||
|
WithHTTPClient(hc),
|
||||||
|
WithRetry(5, 2*time.Second, 60*time.Second),
|
||||||
|
)
|
||||||
|
if c.baseURL != "https://custom.example.com" {
|
||||||
|
t.Errorf("baseURL = %q", c.baseURL)
|
||||||
|
}
|
||||||
|
if c.apiKey != "test-key-123" {
|
||||||
|
t.Errorf("apiKey = %q", c.apiKey)
|
||||||
|
}
|
||||||
|
if c.userAgent != "my-app/1.0" {
|
||||||
|
t.Errorf("userAgent = %q", c.userAgent)
|
||||||
|
}
|
||||||
|
if c.httpClient != hc {
|
||||||
|
t.Error("httpClient not set")
|
||||||
|
}
|
||||||
|
if c.maxRetries != 5 {
|
||||||
|
t.Errorf("maxRetries = %d, want 5", c.maxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTimeout(t *testing.T) {
|
||||||
|
c := NewClient(WithTimeout(30 * time.Second))
|
||||||
|
if c.httpClient.Timeout != 30*time.Second {
|
||||||
|
t.Errorf("timeout = %v, want 30s", c.httpClient.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHeaders(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
apiKey string
|
||||||
|
want map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "without API key",
|
||||||
|
apiKey: "",
|
||||||
|
want: map[string]string{
|
||||||
|
headerUserAgent: defaultUserAgent,
|
||||||
|
headerSearchSource: searchSource,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with API key",
|
||||||
|
apiKey: "my-key",
|
||||||
|
want: map[string]string{
|
||||||
|
headerUserAgent: defaultUserAgent,
|
||||||
|
headerSearchSource: searchSource,
|
||||||
|
headerAPIKey: "my-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
c := NewClient(WithAPIKey(tt.apiKey))
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||||
|
c.setHeaders(req)
|
||||||
|
for key, want := range tt.want {
|
||||||
|
got := req.Header.Get(key)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("header %q = %q, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tt.apiKey == "" && req.Header.Get(headerAPIKey) != "" {
|
||||||
|
t.Error("API key header should not be set when apiKey is empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get(headerUserAgent); got != defaultUserAgent {
|
||||||
|
t.Errorf("User-Agent = %q, want %q", got, defaultUserAgent)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get(headerSearchSource); got != searchSource {
|
||||||
|
t.Errorf("X-Search-Source = %q, want %q", got, searchSource)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Accept"); got != "application/json" {
|
||||||
|
t.Errorf("Accept = %q, want application/json", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]int{"total": 42})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL))
|
||||||
|
var dst struct{ Total int }
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if dst.Total != 42 {
|
||||||
|
t.Errorf("Total = %d, want 42", dst.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_APIError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
wantBody bool
|
||||||
|
}{
|
||||||
|
{name: "400 bad request", statusCode: 400, body: "invalid query", wantBody: true},
|
||||||
|
{name: "404 not found", statusCode: 404, body: "not found", wantBody: true},
|
||||||
|
{name: "500 server error", statusCode: 500, body: "internal details", wantBody: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(tt.statusCode)
|
||||||
|
w.Write([]byte(tt.body))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
var dst struct{}
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != tt.statusCode {
|
||||||
|
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.statusCode)
|
||||||
|
}
|
||||||
|
if tt.wantBody && apiErr.Body == "" {
|
||||||
|
t.Error("expected Body to be non-empty for 4xx")
|
||||||
|
}
|
||||||
|
if !tt.wantBody && apiErr.Body != "" {
|
||||||
|
t.Errorf("expected empty Body for 5xx, got %q", apiErr.Body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_RetryOn429(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 3 {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL(srv.URL),
|
||||||
|
WithRetry(3, 1*time.Millisecond, 10*time.Millisecond),
|
||||||
|
)
|
||||||
|
var dst struct{ Status string }
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if dst.Status != "ok" {
|
||||||
|
t.Errorf("Status = %q, want ok", dst.Status)
|
||||||
|
}
|
||||||
|
if attempts != 3 {
|
||||||
|
t.Errorf("attempts = %d, want 3", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_RetryExhausted(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL(srv.URL),
|
||||||
|
WithRetry(2, 1*time.Millisecond, 10*time.Millisecond),
|
||||||
|
)
|
||||||
|
var dst struct{}
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after retries exhausted")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 429 {
|
||||||
|
t.Errorf("StatusCode = %d, want 429", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_ContextCanceled(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
var dst struct{}
|
||||||
|
err := c.doJSON(ctx, "/test", nil, &dst)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for canceled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackoffDuration(t *testing.T) {
|
||||||
|
c := NewClient(WithRetry(5, 1*time.Second, 30*time.Second))
|
||||||
|
tests := []struct {
|
||||||
|
attempt int
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{0, 1 * time.Second},
|
||||||
|
{1, 2 * time.Second},
|
||||||
|
{2, 4 * time.Second},
|
||||||
|
{3, 8 * time.Second},
|
||||||
|
{4, 16 * time.Second},
|
||||||
|
{5, 30 * time.Second}, // capped
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := c.backoffDuration(tt.attempt)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("backoffDuration(%d) = %v, want %v", tt.attempt, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyHeader(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got := r.Header.Get(headerAPIKey)
|
||||||
|
if got != "secret-key" {
|
||||||
|
t.Errorf("X-API-Key = %q, want %q", got, "secret-key")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithAPIKey("secret-key"))
|
||||||
|
var dst struct{ OK bool }
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_InvalidJSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte("not json"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
var dst struct{}
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "decode") {
|
||||||
|
t.Errorf("error = %q, want to contain 'decode'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_ContextCanceledDuringBackoff(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL(srv.URL),
|
||||||
|
WithRetry(5, 1*time.Second, 10*time.Second), // long backoff
|
||||||
|
)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var dst struct{}
|
||||||
|
err := c.doJSON(ctx, "/test", nil, &dst)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Errorf("err = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_RetryOn502(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 2 {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL(srv.URL),
|
||||||
|
WithRetry(2, 1*time.Millisecond, 10*time.Millisecond),
|
||||||
|
)
|
||||||
|
var dst struct{ Ok string }
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("attempts = %d, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_RetryOn503(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 2 {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL(srv.URL),
|
||||||
|
WithRetry(2, 1*time.Millisecond, 10*time.Millisecond),
|
||||||
|
)
|
||||||
|
var dst struct{ Ok string }
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("attempts = %d, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_NoRetryOn401(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte("unauthorized"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL(srv.URL),
|
||||||
|
WithRetry(3, 1*time.Millisecond, 10*time.Millisecond),
|
||||||
|
)
|
||||||
|
var dst struct{}
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Errorf("attempts = %d, want 1 (no retries for 401)", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_WithQueryParams(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("foo"); got != "bar" {
|
||||||
|
t.Errorf("foo = %q, want bar", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("num"); got != "42" {
|
||||||
|
t.Errorf("num = %q, want 42", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("foo", "bar")
|
||||||
|
q.Set("num", "42")
|
||||||
|
var dst struct{ Ok bool }
|
||||||
|
err := c.doJSON(context.Background(), "/test", q, &dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON_InvalidBaseURL(t *testing.T) {
|
||||||
|
c := NewClient(WithBaseURL("://invalid"), WithRetry(0, 0, 0))
|
||||||
|
var dst struct{}
|
||||||
|
err := c.doJSON(context.Background(), "/test", nil, &dst)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid base URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid base URL") {
|
||||||
|
t.Errorf("error = %q, want to contain 'invalid base URL'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRaw_InvalidBaseURL(t *testing.T) {
|
||||||
|
c := NewClient(WithBaseURL("://invalid"), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.doRaw(context.Background(), "/test", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid base URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid base URL") {
|
||||||
|
t.Errorf("error = %q, want to contain 'invalid base URL'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddIntParam(t *testing.T) {
|
||||||
|
q := url.Values{}
|
||||||
|
addIntParam(q, "zero", 0)
|
||||||
|
if q.Has("zero") {
|
||||||
|
t.Error("zero value should not be added")
|
||||||
|
}
|
||||||
|
addIntParam(q, "val", 42)
|
||||||
|
if q.Get("val") != "42" {
|
||||||
|
t.Errorf("val = %q, want 42", q.Get("val"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddFloatParam(t *testing.T) {
|
||||||
|
q := url.Values{}
|
||||||
|
addFloatParam(q, "zero", 0)
|
||||||
|
if q.Has("zero") {
|
||||||
|
t.Error("zero value should not be added")
|
||||||
|
}
|
||||||
|
addFloatParam(q, "rating", 7.5)
|
||||||
|
if q.Get("rating") != "7.5" {
|
||||||
|
t.Errorf("rating = %q, want 7.5", q.Get("rating"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddStringParam(t *testing.T) {
|
||||||
|
q := url.Values{}
|
||||||
|
addStringParam(q, "empty", "")
|
||||||
|
if q.Has("empty") {
|
||||||
|
t.Error("empty value should not be added")
|
||||||
|
}
|
||||||
|
addStringParam(q, "key", "value")
|
||||||
|
if q.Get("key") != "value" {
|
||||||
|
t.Errorf("key = %q, want value", q.Get("key"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersion(t *testing.T) {
|
||||||
|
if Version == "" {
|
||||||
|
t.Error("Version should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRaw_ContextCanceledDuringBackoff(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL(srv.URL),
|
||||||
|
WithRetry(5, 1*time.Second, 10*time.Second),
|
||||||
|
)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err := c.doRaw(ctx, "/test", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Errorf("err = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTimeout_NilHTTPClient(t *testing.T) {
|
||||||
|
// Simulate the edge case where httpClient is nil when WithTimeout is applied.
|
||||||
|
c := &Client{}
|
||||||
|
opt := WithTimeout(10 * time.Second)
|
||||||
|
opt(c)
|
||||||
|
if c.httpClient == nil {
|
||||||
|
t.Fatal("httpClient should be initialized")
|
||||||
|
}
|
||||||
|
if c.httpClient.Timeout != 10*time.Second {
|
||||||
|
t.Errorf("timeout = %v, want 10s", c.httpClient.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithBearerToken(t *testing.T) {
|
||||||
|
c := NewClient(WithBearerToken("my-token"))
|
||||||
|
if c.bearerToken != "my-token" {
|
||||||
|
t.Errorf("bearerToken = %q, want my-token", c.bearerToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHeaders_BearerToken(t *testing.T) {
|
||||||
|
c := NewClient(WithBearerToken("tok123"))
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||||
|
c.setHeaders(req)
|
||||||
|
|
||||||
|
if got := req.Header.Get(headerAuthorization); got != "Bearer tok123" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", got, "Bearer tok123")
|
||||||
|
}
|
||||||
|
if got := req.Header.Get(headerAPIKey); got != "" {
|
||||||
|
t.Errorf("X-API-Key should be empty when bearer token is set, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHeaders_BearerTokenPrecedence(t *testing.T) {
|
||||||
|
c := NewClient(WithAPIKey("api-key"), WithBearerToken("bearer-tok"))
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||||
|
c.setHeaders(req)
|
||||||
|
|
||||||
|
if got := req.Header.Get(headerAuthorization); got != "Bearer bearer-tok" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer bearer-tok", got)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get(headerAPIKey); got != "" {
|
||||||
|
t.Errorf("X-API-Key should be empty when bearer token takes precedence, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddBoolParam(t *testing.T) {
|
||||||
|
q := url.Values{}
|
||||||
|
addBoolParam(q, "disabled", false)
|
||||||
|
if q.Has("disabled") {
|
||||||
|
t.Error("false value should not be added")
|
||||||
|
}
|
||||||
|
addBoolParam(q, "enabled", true)
|
||||||
|
if q.Get("enabled") != "true" {
|
||||||
|
t.Errorf("enabled = %q, want true", q.Get("enabled"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoPost_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Accept"); got != "application/json" {
|
||||||
|
t.Errorf("Accept = %q, want application/json", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("X-Custom"); got != "val" {
|
||||||
|
t.Errorf("X-Custom = %q, want val", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"result":"ok"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
var dst struct{ Result string }
|
||||||
|
err := c.doPost(context.Background(), "/test", map[string]string{"key": "value"}, &dst, map[string]string{"X-Custom": "val"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if dst.Result != "ok" {
|
||||||
|
t.Errorf("Result = %q, want ok", dst.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoPost_APIError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte("bad request"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
var dst struct{}
|
||||||
|
err := c.doPost(context.Background(), "/test", nil, &dst, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 400 {
|
||||||
|
t.Errorf("StatusCode = %d, want 400", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoPost_RetryOnTransient(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 2 {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(2, 1*time.Millisecond, 10*time.Millisecond))
|
||||||
|
var dst struct{ Ok bool }
|
||||||
|
err := c.doPost(context.Background(), "/test", nil, &dst, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("attempts = %d, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoPost_InvalidBaseURL(t *testing.T) {
|
||||||
|
c := NewClient(WithBaseURL("://invalid"), WithRetry(0, 0, 0))
|
||||||
|
var dst struct{}
|
||||||
|
err := c.doPost(context.Background(), "/test", nil, &dst, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid base URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid base URL") {
|
||||||
|
t.Errorf("error = %q, want to contain 'invalid base URL'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRaw_WithQueryParams(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("t"); got != "caps" {
|
||||||
|
t.Errorf("t = %q, want caps", got)
|
||||||
|
}
|
||||||
|
w.Write([]byte("<xml/>"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("t", "caps")
|
||||||
|
data, err := c.doRaw(context.Background(), "/test", q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "<xml/>" {
|
||||||
|
t.Errorf("data = %q, want <xml/>", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
80
collections.go
Normal file
80
collections.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CollectionListParams holds the parameters for listing collections.
|
||||||
|
type CollectionListParams struct {
|
||||||
|
// Limit sets the number of items (1-48, default 24).
|
||||||
|
Limit int
|
||||||
|
|
||||||
|
// Page is the page number (starts at 1).
|
||||||
|
Page int
|
||||||
|
|
||||||
|
// Locale sets the language for localized names.
|
||||||
|
Locale string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionListItem represents a movie collection in a list.
|
||||||
|
type CollectionListItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
TMDbID int `json:"tmdbId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PosterURL *string `json:"posterUrl,omitempty"`
|
||||||
|
BackdropURL *string `json:"backdropUrl,omitempty"`
|
||||||
|
MovieCount int `json:"movieCount"`
|
||||||
|
TotalSeeders int `json:"totalSeeders"`
|
||||||
|
PartCount int `json:"partCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionListResponse is the paginated response from the collections endpoint.
|
||||||
|
type CollectionListResponse struct {
|
||||||
|
Items []CollectionListItem `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionDetail contains full details about a movie collection.
|
||||||
|
type CollectionDetail struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
TMDbID int `json:"tmdbId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PosterURL *string `json:"posterUrl,omitempty"`
|
||||||
|
BackdropURL *string `json:"backdropUrl,omitempty"`
|
||||||
|
MovieCount int `json:"movieCount"`
|
||||||
|
TotalSeeders int `json:"totalSeeders"`
|
||||||
|
PartCount int `json:"partCount"`
|
||||||
|
Overview *string `json:"overview,omitempty"`
|
||||||
|
Movies []PopularItem `json:"movies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collections returns a paginated list of movie collections (sagas).
|
||||||
|
func (c *Client) Collections(ctx context.Context, params CollectionListParams) (*CollectionListResponse, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
addIntParam(q, "limit", params.Limit)
|
||||||
|
addIntParam(q, "page", params.Page)
|
||||||
|
addStringParam(q, "locale", params.Locale)
|
||||||
|
|
||||||
|
var resp CollectionListResponse
|
||||||
|
if err := c.doJSON(ctx, "/api/v1/collections", q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionByID returns full details for a single movie collection.
|
||||||
|
func (c *Client) CollectionByID(ctx context.Context, id int, locale string) (*CollectionDetail, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
addStringParam(q, "locale", locale)
|
||||||
|
|
||||||
|
path := fmt.Sprintf("/api/v1/collections/%d", id)
|
||||||
|
var resp CollectionDetail
|
||||||
|
if err := c.doJSON(ctx, path, q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
128
collections_test.go
Normal file
128
collections_test.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollections(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/collections" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/collections", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("limit"); got != "12" {
|
||||||
|
t.Errorf("limit = %q, want 12", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(CollectionListResponse{
|
||||||
|
Items: []CollectionListItem{
|
||||||
|
{ID: 1, TMDbID: 10, Name: "Star Wars", MovieCount: 9, TotalSeeders: 5000, PartCount: 9},
|
||||||
|
},
|
||||||
|
Total: 50,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 12,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Collections(context.Background(), CollectionListParams{Limit: 12})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Total != 50 {
|
||||||
|
t.Errorf("Total = %d, want 50", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 1 {
|
||||||
|
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
|
||||||
|
}
|
||||||
|
if resp.Items[0].Name != "Star Wars" {
|
||||||
|
t.Errorf("Name = %q, want Star Wars", resp.Items[0].Name)
|
||||||
|
}
|
||||||
|
if resp.Items[0].MovieCount != 9 {
|
||||||
|
t.Errorf("MovieCount = %d, want 9", resp.Items[0].MovieCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionByID(t *testing.T) {
|
||||||
|
overview := "The epic saga"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/collections/10" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/collections/10", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("locale"); got != "es" {
|
||||||
|
t.Errorf("locale = %q, want es", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(CollectionDetail{
|
||||||
|
ID: 1,
|
||||||
|
TMDbID: 10,
|
||||||
|
Name: "Star Wars",
|
||||||
|
MovieCount: 9,
|
||||||
|
TotalSeeders: 5000,
|
||||||
|
PartCount: 9,
|
||||||
|
Overview: &overview,
|
||||||
|
Movies: []PopularItem{
|
||||||
|
{ID: 100, Title: "A New Hope", ContentType: "movie", MaxSeeders: 800},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.CollectionByID(context.Background(), 10, "es")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Name != "Star Wars" {
|
||||||
|
t.Errorf("Name = %q, want Star Wars", resp.Name)
|
||||||
|
}
|
||||||
|
if resp.Overview == nil || *resp.Overview != "The epic saga" {
|
||||||
|
t.Errorf("Overview = %v", resp.Overview)
|
||||||
|
}
|
||||||
|
if len(resp.Movies) != 1 {
|
||||||
|
t.Fatalf("len(Movies) = %d, want 1", len(resp.Movies))
|
||||||
|
}
|
||||||
|
if resp.Movies[0].Title != "A New Hope" {
|
||||||
|
t.Errorf("Title = %q, want A New Hope", resp.Movies[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionByID_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte(`{"error":"Collection not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.CollectionByID(context.Background(), 999, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if !apiErr.IsNotFound() {
|
||||||
|
t.Errorf("expected 404, got %d", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollections_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Collections(context.Background(), CollectionListParams{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
189
content.go
Normal file
189
content.go
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Popular ────
|
||||||
|
|
||||||
|
// PopularParams holds the parameters for a popular content request.
|
||||||
|
type PopularParams struct {
|
||||||
|
// Limit sets the number of items (1-24, default 12).
|
||||||
|
Limit int
|
||||||
|
|
||||||
|
// Page is the page number (starts at 1).
|
||||||
|
Page int
|
||||||
|
|
||||||
|
// Locale sets the language for localized titles.
|
||||||
|
Locale string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopularItem represents a popular content item.
|
||||||
|
type PopularItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year *int `json:"year,omitempty"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
PosterURL *string `json:"posterUrl,omitempty"`
|
||||||
|
RatingIMDb *string `json:"ratingImdb,omitempty"`
|
||||||
|
RatingTMDb *string `json:"ratingTmdb,omitempty"`
|
||||||
|
Overview *string `json:"overview,omitempty"`
|
||||||
|
MaxSeeders int `json:"maxSeeders"`
|
||||||
|
Genres []string `json:"genres,omitempty"`
|
||||||
|
BestQuality *string `json:"bestQuality,omitempty"`
|
||||||
|
HasHDR *bool `json:"hasHdr,omitempty"`
|
||||||
|
TopInfoHash *string `json:"topInfoHash,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopularResponse is the paginated response from the popular endpoint.
|
||||||
|
type PopularResponse struct {
|
||||||
|
Items []PopularItem `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recent ────
|
||||||
|
|
||||||
|
// RecentParams holds the parameters for a recent content request.
|
||||||
|
type RecentParams struct {
|
||||||
|
// Limit sets the number of items (1-24, default 12).
|
||||||
|
Limit int
|
||||||
|
|
||||||
|
// Page is the page number (starts at 1).
|
||||||
|
Page int
|
||||||
|
|
||||||
|
// Locale sets the language for localized titles.
|
||||||
|
Locale string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentItem represents a recently added content item.
|
||||||
|
type RecentItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year *int `json:"year,omitempty"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
PosterURL *string `json:"posterUrl,omitempty"`
|
||||||
|
RatingIMDb *string `json:"ratingImdb,omitempty"`
|
||||||
|
RatingTMDb *string `json:"ratingTmdb,omitempty"`
|
||||||
|
Overview *string `json:"overview,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentResponse is the paginated response from the recent endpoint.
|
||||||
|
type RecentResponse struct {
|
||||||
|
Items []RecentItem `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Watch Providers ────
|
||||||
|
|
||||||
|
// WatchProviderItem represents a single watch/streaming provider.
|
||||||
|
type WatchProviderItem struct {
|
||||||
|
ProviderID int `json:"providerId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Logo *string `json:"logo,omitempty"`
|
||||||
|
Link *string `json:"link,omitempty"`
|
||||||
|
DisplayPriority int `json:"displayPriority,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchProviders contains streaming availability grouped by access type.
|
||||||
|
type WatchProviders struct {
|
||||||
|
Flatrate []WatchProviderItem `json:"flatrate,omitempty"`
|
||||||
|
Rent []WatchProviderItem `json:"rent,omitempty"`
|
||||||
|
Buy []WatchProviderItem `json:"buy,omitempty"`
|
||||||
|
Free []WatchProviderItem `json:"free,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VPNSuggestion is included when content is available in other countries
|
||||||
|
// via subscription but not in the user's country.
|
||||||
|
type VPNSuggestion struct {
|
||||||
|
AvailableIn []string `json:"availableIn"`
|
||||||
|
AffiliateURL string `json:"affiliateUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchProvidersResponse contains watch providers for a content item.
|
||||||
|
type WatchProvidersResponse struct {
|
||||||
|
ContentID int `json:"contentId"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Providers WatchProviders `json:"providers"`
|
||||||
|
VPNSuggestion *VPNSuggestion `json:"vpnSuggestion,omitempty"`
|
||||||
|
Attribution string `json:"attribution"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Credits ────
|
||||||
|
|
||||||
|
// CastMember represents an actor in a movie or TV show.
|
||||||
|
type CastMember struct {
|
||||||
|
TmdbID *int `json:"tmdbId,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Character string `json:"character"`
|
||||||
|
ProfileURL *string `json:"profileUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreditsResponse contains the director and cast for a content item.
|
||||||
|
type CreditsResponse struct {
|
||||||
|
ContentID int `json:"contentId"`
|
||||||
|
Director *string `json:"director,omitempty"`
|
||||||
|
DirectorTmdbID *int `json:"directorTmdbId,omitempty"`
|
||||||
|
Cast []CastMember `json:"cast"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Methods ────
|
||||||
|
|
||||||
|
// Popular returns the most popular content ranked by community engagement.
|
||||||
|
func (c *Client) Popular(ctx context.Context, params PopularParams) (*PopularResponse, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
addIntParam(q, "limit", params.Limit)
|
||||||
|
addIntParam(q, "page", params.Page)
|
||||||
|
addStringParam(q, "locale", params.Locale)
|
||||||
|
|
||||||
|
var resp PopularResponse
|
||||||
|
if err := c.doJSON(ctx, "/api/v1/popular", q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns the most recently added content.
|
||||||
|
func (c *Client) Recent(ctx context.Context, params RecentParams) (*RecentResponse, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
addIntParam(q, "limit", params.Limit)
|
||||||
|
addIntParam(q, "page", params.Page)
|
||||||
|
addStringParam(q, "locale", params.Locale)
|
||||||
|
|
||||||
|
var resp RecentResponse
|
||||||
|
if err := c.doJSON(ctx, "/api/v1/recent", q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchProviders returns streaming/watch providers for a content item.
|
||||||
|
// The country parameter is an ISO 3166-1 code (e.g. "US", "ES"). Pass an
|
||||||
|
// empty string to use the server default ("US").
|
||||||
|
func (c *Client) WatchProviders(ctx context.Context, contentID int, country string) (*WatchProvidersResponse, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
addStringParam(q, "country", country)
|
||||||
|
|
||||||
|
path := fmt.Sprintf("/api/v1/content/%d/watch-providers", contentID)
|
||||||
|
var resp WatchProvidersResponse
|
||||||
|
if err := c.doJSON(ctx, path, q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credits returns the director and top cast members for a content item.
|
||||||
|
func (c *Client) Credits(ctx context.Context, contentID int) (*CreditsResponse, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/content/%d/credits", contentID)
|
||||||
|
var resp CreditsResponse
|
||||||
|
if err := c.doJSON(ctx, path, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
472
content_test.go
Normal file
472
content_test.go
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPopular(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/popular" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/popular", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("limit"); got != "5" {
|
||||||
|
t.Errorf("limit = %q, want 5", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("page"); got != "2" {
|
||||||
|
t.Errorf("page = %q, want 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(PopularResponse{
|
||||||
|
Items: []PopularItem{
|
||||||
|
{ID: 1, Title: "The Matrix", ContentType: "movie", MaxSeeders: 500},
|
||||||
|
},
|
||||||
|
Total: 100,
|
||||||
|
Page: 2,
|
||||||
|
PageSize: 5,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Popular(context.Background(), PopularParams{Limit: 5, Page: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Total != 100 {
|
||||||
|
t.Errorf("Total = %d, want 100", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 1 {
|
||||||
|
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
|
||||||
|
}
|
||||||
|
if resp.Items[0].MaxSeeders != 500 {
|
||||||
|
t.Errorf("MaxSeeders = %d, want 500", resp.Items[0].MaxSeeders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopular_DefaultParams(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if q.Has("limit") {
|
||||||
|
t.Errorf("unexpected limit param: %q", q.Get("limit"))
|
||||||
|
}
|
||||||
|
if q.Has("page") {
|
||||||
|
t.Errorf("unexpected page param: %q", q.Get("page"))
|
||||||
|
}
|
||||||
|
if q.Has("locale") {
|
||||||
|
t.Errorf("unexpected locale param: %q", q.Get("locale"))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(PopularResponse{Total: 0, Page: 1, PageSize: 12})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Popular(context.Background(), PopularParams{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopular_WithLocale(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("locale"); got != "es" {
|
||||||
|
t.Errorf("locale = %q, want es", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(PopularResponse{Total: 0, Page: 1, PageSize: 12})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Popular(context.Background(), PopularParams{Locale: "es"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecent(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/recent" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/recent", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
overview := "A great movie"
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(RecentResponse{
|
||||||
|
Items: []RecentItem{
|
||||||
|
{ID: 10, Title: "New Movie", ContentType: "movie", Overview: &overview, CreatedAt: "2025-01-15T10:00:00Z"},
|
||||||
|
},
|
||||||
|
Total: 50,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 12,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Recent(context.Background(), RecentParams{Limit: 12, Page: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Total != 50 {
|
||||||
|
t.Errorf("Total = %d, want 50", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 1 {
|
||||||
|
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
|
||||||
|
}
|
||||||
|
if resp.Items[0].CreatedAt != "2025-01-15T10:00:00Z" {
|
||||||
|
t.Errorf("CreatedAt = %q", resp.Items[0].CreatedAt)
|
||||||
|
}
|
||||||
|
if resp.Items[0].Overview == nil || *resp.Items[0].Overview != "A great movie" {
|
||||||
|
t.Errorf("Overview = %v, want 'A great movie'", resp.Items[0].Overview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecent_WithLocale(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("locale"); got != "fr" {
|
||||||
|
t.Errorf("locale = %q, want fr", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(RecentResponse{Total: 0, Page: 1, PageSize: 12})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Recent(context.Background(), RecentParams{Locale: "fr"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchProviders(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/content/42/watch-providers" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("country"); got != "US" {
|
||||||
|
t.Errorf("country = %q, want US", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(WatchProvidersResponse{
|
||||||
|
ContentID: 42,
|
||||||
|
Country: "US",
|
||||||
|
Providers: WatchProviders{
|
||||||
|
Flatrate: []WatchProviderItem{
|
||||||
|
{ProviderID: 8, Name: "Netflix"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Attribution: "Powered by JustWatch",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.WatchProviders(context.Background(), 42, "US")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.ContentID != 42 {
|
||||||
|
t.Errorf("ContentID = %d, want 42", resp.ContentID)
|
||||||
|
}
|
||||||
|
if resp.Country != "US" {
|
||||||
|
t.Errorf("Country = %q, want US", resp.Country)
|
||||||
|
}
|
||||||
|
if len(resp.Providers.Flatrate) != 1 {
|
||||||
|
t.Fatalf("len(Flatrate) = %d, want 1", len(resp.Providers.Flatrate))
|
||||||
|
}
|
||||||
|
if resp.Providers.Flatrate[0].Name != "Netflix" {
|
||||||
|
t.Errorf("Name = %q, want Netflix", resp.Providers.Flatrate[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchProviders_WithVPN(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(WatchProvidersResponse{
|
||||||
|
ContentID: 42,
|
||||||
|
Country: "AR",
|
||||||
|
Providers: WatchProviders{},
|
||||||
|
VPNSuggestion: &VPNSuggestion{
|
||||||
|
AvailableIn: []string{"US", "GB"},
|
||||||
|
AffiliateURL: "https://example.com/vpn",
|
||||||
|
},
|
||||||
|
Attribution: "Powered by JustWatch",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.WatchProviders(context.Background(), 42, "AR")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.VPNSuggestion == nil {
|
||||||
|
t.Fatal("expected VPNSuggestion")
|
||||||
|
}
|
||||||
|
if len(resp.VPNSuggestion.AvailableIn) != 2 {
|
||||||
|
t.Errorf("len(AvailableIn) = %d, want 2", len(resp.VPNSuggestion.AvailableIn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredits(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/content/42/credits" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
director := "Christopher Nolan"
|
||||||
|
directorID := 525
|
||||||
|
tmdbID := 6193
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(CreditsResponse{
|
||||||
|
ContentID: 42,
|
||||||
|
Director: &director,
|
||||||
|
DirectorTmdbID: &directorID,
|
||||||
|
Cast: []CastMember{
|
||||||
|
{TmdbID: &tmdbID, Name: "Leonardo DiCaprio", Character: "Cobb"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Credits(context.Background(), 42)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.ContentID != 42 {
|
||||||
|
t.Errorf("ContentID = %d, want 42", resp.ContentID)
|
||||||
|
}
|
||||||
|
if resp.Director == nil || *resp.Director != "Christopher Nolan" {
|
||||||
|
t.Errorf("Director = %v", resp.Director)
|
||||||
|
}
|
||||||
|
if resp.DirectorTmdbID == nil || *resp.DirectorTmdbID != 525 {
|
||||||
|
t.Errorf("DirectorTmdbID = %v, want 525", resp.DirectorTmdbID)
|
||||||
|
}
|
||||||
|
if len(resp.Cast) != 1 {
|
||||||
|
t.Fatalf("len(Cast) = %d, want 1", len(resp.Cast))
|
||||||
|
}
|
||||||
|
if resp.Cast[0].Name != "Leonardo DiCaprio" {
|
||||||
|
t.Errorf("Name = %q", resp.Cast[0].Name)
|
||||||
|
}
|
||||||
|
if resp.Cast[0].TmdbID == nil || *resp.Cast[0].TmdbID != 6193 {
|
||||||
|
t.Errorf("TmdbID = %v, want 6193", resp.Cast[0].TmdbID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/stats" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/stats", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(StatsResponse{
|
||||||
|
Content: ContentStats{
|
||||||
|
Movies: 50000,
|
||||||
|
Shows: 10000,
|
||||||
|
TMDbEnriched: 45000,
|
||||||
|
},
|
||||||
|
Torrents: TorrentStats{
|
||||||
|
Total: 200000,
|
||||||
|
WithSeeders: 150000,
|
||||||
|
Orphans: 5000,
|
||||||
|
DailyAverage: 1200,
|
||||||
|
BySource: map[string]int{
|
||||||
|
"yts": 50000,
|
||||||
|
"eztv": 30000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RecentIngestions: []IngestionRecord{
|
||||||
|
{
|
||||||
|
Source: "yts",
|
||||||
|
Status: "completed",
|
||||||
|
StartedAt: "2025-01-15T10:00:00Z",
|
||||||
|
Fetched: 100,
|
||||||
|
New: 10,
|
||||||
|
Updated: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Stats(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Content.Movies != 50000 {
|
||||||
|
t.Errorf("Movies = %d, want 50000", resp.Content.Movies)
|
||||||
|
}
|
||||||
|
if resp.Torrents.Total != 200000 {
|
||||||
|
t.Errorf("Total = %d, want 200000", resp.Torrents.Total)
|
||||||
|
}
|
||||||
|
if resp.Torrents.Orphans != 5000 {
|
||||||
|
t.Errorf("Orphans = %d, want 5000", resp.Torrents.Orphans)
|
||||||
|
}
|
||||||
|
if resp.Torrents.DailyAverage != 1200 {
|
||||||
|
t.Errorf("DailyAverage = %d, want 1200", resp.Torrents.DailyAverage)
|
||||||
|
}
|
||||||
|
if resp.Torrents.BySource["yts"] != 50000 {
|
||||||
|
t.Errorf("BySource[yts] = %d, want 50000", resp.Torrents.BySource["yts"])
|
||||||
|
}
|
||||||
|
if len(resp.RecentIngestions) != 1 {
|
||||||
|
t.Fatalf("len(RecentIngestions) = %d, want 1", len(resp.RecentIngestions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTorrentDownloadURL(t *testing.T) {
|
||||||
|
c := NewClient()
|
||||||
|
got := c.TorrentDownloadURL("abc123def456")
|
||||||
|
want := "https://torrentclaw.com/api/v1/torrent/abc123def456"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("TorrentDownloadURL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTorrentFile(t *testing.T) {
|
||||||
|
torrentData := []byte{0x64, 0x38, 0x3A, 0x61} // fake torrent bytes
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/torrent/abc123" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/x-bittorrent")
|
||||||
|
w.Write(torrentData)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
data, err := c.GetTorrentFile(context.Background(), "abc123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(data) != len(torrentData) {
|
||||||
|
t.Errorf("len(data) = %d, want %d", len(data), len(torrentData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTorrentFile_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte("not found"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.GetTorrentFile(context.Background(), "nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if !apiErr.IsNotFound() {
|
||||||
|
t.Errorf("expected IsNotFound, got StatusCode=%d", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopular_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Popular(context.Background(), PopularParams{Limit: 10, Page: 1})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecent_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Recent(context.Background(), RecentParams{Limit: 10, Page: 1})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchProviders_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.WatchProviders(context.Background(), 42, "US")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchProviders_NoCountry(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Has("country") {
|
||||||
|
t.Error("country param should not be set when empty")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(WatchProvidersResponse{
|
||||||
|
ContentID: 42,
|
||||||
|
Country: "US",
|
||||||
|
Providers: WatchProviders{},
|
||||||
|
Attribution: "Powered by JustWatch",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.WatchProviders(context.Background(), 42, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredits_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte("not found"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Credits(context.Background(), 999)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredits_NilDirector(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(CreditsResponse{
|
||||||
|
ContentID: 42,
|
||||||
|
Director: nil,
|
||||||
|
Cast: []CastMember{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Credits(context.Background(), 42)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Director != nil {
|
||||||
|
t.Errorf("Director = %v, want nil", resp.Director)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
debrid.go
Normal file
57
debrid.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// DebridCheckCacheRequest is the request body for checking debrid cache.
|
||||||
|
type DebridCheckCacheRequest struct {
|
||||||
|
InfoHashes []string `json:"infoHashes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebridCheckCacheResponse contains the cache status for each info hash.
|
||||||
|
type DebridCheckCacheResponse struct {
|
||||||
|
Cached map[string]bool `json:"cached"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebridAddMagnetRequest is the request body for adding a magnet to debrid.
|
||||||
|
type DebridAddMagnetRequest struct {
|
||||||
|
InfoHash string `json:"infoHash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebridAddMagnetResponse contains the result of adding a magnet.
|
||||||
|
type DebridAddMagnetResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Cached bool `json:"cached"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebridCheckCache checks which info hashes are cached in the user's debrid
|
||||||
|
// service. Requires a Pro tier API key.
|
||||||
|
func (c *Client) DebridCheckCache(ctx context.Context, provider, debridKey string, infoHashes []string) (*DebridCheckCacheResponse, error) {
|
||||||
|
body := DebridCheckCacheRequest{InfoHashes: infoHashes}
|
||||||
|
headers := map[string]string{
|
||||||
|
headerDebridProvider: provider,
|
||||||
|
headerDebridKey: debridKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp DebridCheckCacheResponse
|
||||||
|
if err := c.doPost(ctx, "/api/v1/debrid/check-cache", body, &resp, headers); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebridAddMagnet adds a magnet link to the user's debrid service.
|
||||||
|
// Requires a Pro tier API key.
|
||||||
|
func (c *Client) DebridAddMagnet(ctx context.Context, provider, debridKey, infoHash string) (*DebridAddMagnetResponse, error) {
|
||||||
|
body := DebridAddMagnetRequest{InfoHash: infoHash}
|
||||||
|
headers := map[string]string{
|
||||||
|
headerDebridProvider: provider,
|
||||||
|
headerDebridKey: debridKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp DebridAddMagnetResponse
|
||||||
|
if err := c.doPost(ctx, "/api/v1/debrid/add-magnet", body, &resp, headers); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
137
debrid_test.go
Normal file
137
debrid_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDebridCheckCache(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/v1/debrid/check-cache" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get(headerDebridProvider); got != "real-debrid" {
|
||||||
|
t.Errorf("X-Debrid-Provider = %q, want real-debrid", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get(headerDebridKey); got != "my-debrid-key" {
|
||||||
|
t.Errorf("X-Debrid-Key = %q, want my-debrid-key", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req DebridCheckCacheRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
t.Fatalf("failed to parse request body: %v", err)
|
||||||
|
}
|
||||||
|
if len(req.InfoHashes) != 2 {
|
||||||
|
t.Errorf("len(InfoHashes) = %d, want 2", len(req.InfoHashes))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(DebridCheckCacheResponse{
|
||||||
|
Cached: map[string]bool{
|
||||||
|
"hash1": true,
|
||||||
|
"hash2": false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.DebridCheckCache(context.Background(), "real-debrid", "my-debrid-key", []string{"hash1", "hash2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.Cached["hash1"] {
|
||||||
|
t.Error("hash1 should be cached")
|
||||||
|
}
|
||||||
|
if resp.Cached["hash2"] {
|
||||||
|
t.Error("hash2 should not be cached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebridAddMagnet(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/v1/debrid/add-magnet" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get(headerDebridProvider); got != "alldebrid" {
|
||||||
|
t.Errorf("X-Debrid-Provider = %q, want alldebrid", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req DebridAddMagnetRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
t.Fatalf("failed to parse request body: %v", err)
|
||||||
|
}
|
||||||
|
if req.InfoHash != "abc123" {
|
||||||
|
t.Errorf("InfoHash = %q, want abc123", req.InfoHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(DebridAddMagnetResponse{
|
||||||
|
ID: "torrent-id-1",
|
||||||
|
Cached: true,
|
||||||
|
Name: "Test Movie",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.DebridAddMagnet(context.Background(), "alldebrid", "key123", "abc123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.ID != "torrent-id-1" {
|
||||||
|
t.Errorf("ID = %q, want torrent-id-1", resp.ID)
|
||||||
|
}
|
||||||
|
if !resp.Cached {
|
||||||
|
t.Error("Cached should be true")
|
||||||
|
}
|
||||||
|
if resp.Name != "Test Movie" {
|
||||||
|
t.Errorf("Name = %q, want Test Movie", resp.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebridCheckCache_Unauthorized(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error":"Invalid API key"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.DebridCheckCache(context.Background(), "real-debrid", "bad-key", []string{"hash1"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 401 {
|
||||||
|
t.Errorf("StatusCode = %d, want 401", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebridAddMagnet_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.DebridAddMagnet(context.Background(), "real-debrid", "key", "hash")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
19
doc.go
Normal file
19
doc.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Package torrentclaw provides a Go client for the TorrentClaw API,
|
||||||
|
// a torrent search engine that aggregates movies and TV shows from 30+
|
||||||
|
// international sources with TMDB metadata enrichment.
|
||||||
|
//
|
||||||
|
// Each file in this package is self-contained: it holds the types (params,
|
||||||
|
// response structs) and methods for a single API resource. Shared types
|
||||||
|
// that are referenced across multiple resources live in types.go.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// client := torrentclaw.NewClient(
|
||||||
|
// torrentclaw.WithAPIKey("your-api-key"),
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// resp, err := client.Search(context.Background(), torrentclaw.SearchParams{
|
||||||
|
// Query: "Inception",
|
||||||
|
// Type: "movie",
|
||||||
|
// })
|
||||||
|
package torrentclaw
|
||||||
78
errors.go
Normal file
78
errors.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// APIError represents an error response from the TorrentClaw API.
|
||||||
|
type APIError struct {
|
||||||
|
// StatusCode is the HTTP status code returned by the API.
|
||||||
|
StatusCode int
|
||||||
|
|
||||||
|
// Body contains the response body for client errors (4xx).
|
||||||
|
// It is intentionally omitted for server errors (5xx) to avoid leaking internals.
|
||||||
|
Body string
|
||||||
|
|
||||||
|
// Message is a human-readable error description.
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface.
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
if e.Body != "" {
|
||||||
|
return fmt.Sprintf("torrentclaw: %s (HTTP %d): %s", e.Message, e.StatusCode, e.Body)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("torrentclaw: %s (HTTP %d)", e.Message, e.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetryable reports whether the error is likely transient and the request
|
||||||
|
// should be retried.
|
||||||
|
func (e *APIError) IsRetryable() bool {
|
||||||
|
switch e.StatusCode {
|
||||||
|
case 429, 500, 502, 503:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRateLimited reports whether the error is due to rate limiting (HTTP 429).
|
||||||
|
func (e *APIError) IsRateLimited() bool {
|
||||||
|
return e.StatusCode == 429
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNotFound reports whether the error is due to a missing resource (HTTP 404).
|
||||||
|
func (e *APIError) IsNotFound() bool {
|
||||||
|
return e.StatusCode == 404
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusMessage returns a human-readable message for known HTTP status codes.
|
||||||
|
func statusMessage(code int) string {
|
||||||
|
switch code {
|
||||||
|
case 400:
|
||||||
|
return "Bad request — check that all parameters are valid"
|
||||||
|
case 401:
|
||||||
|
return "Authentication required — provide a valid API key"
|
||||||
|
case 403:
|
||||||
|
return "Forbidden — insufficient permissions for this endpoint"
|
||||||
|
case 404:
|
||||||
|
return "Not found — the requested resource does not exist"
|
||||||
|
case 429:
|
||||||
|
return "Rate limit exceeded — wait before retrying"
|
||||||
|
case 500:
|
||||||
|
return "TorrentClaw server error"
|
||||||
|
case 502:
|
||||||
|
return "TorrentClaw is temporarily unavailable"
|
||||||
|
case 503:
|
||||||
|
return "TorrentClaw is under maintenance"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("API request failed with status %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAPIError creates an APIError from an HTTP status code and response body.
|
||||||
|
func newAPIError(statusCode int, body string) *APIError {
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Body: body,
|
||||||
|
Message: statusMessage(statusCode),
|
||||||
|
}
|
||||||
|
}
|
||||||
139
errors_test.go
Normal file
139
errors_test.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIError_Error(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err *APIError
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with body",
|
||||||
|
err: &APIError{StatusCode: 400, Message: "Bad request", Body: "invalid query"},
|
||||||
|
want: "torrentclaw: Bad request (HTTP 400): invalid query",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without body",
|
||||||
|
err: &APIError{StatusCode: 500, Message: "Server error", Body: ""},
|
||||||
|
want: "torrentclaw: Server error (HTTP 500)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.err.Error()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("Error() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIError_IsRetryable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
code int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{400, false},
|
||||||
|
{401, false},
|
||||||
|
{403, false},
|
||||||
|
{404, false},
|
||||||
|
{429, true},
|
||||||
|
{500, true},
|
||||||
|
{502, true},
|
||||||
|
{503, true},
|
||||||
|
{504, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
err := &APIError{StatusCode: tt.code}
|
||||||
|
if got := err.IsRetryable(); got != tt.want {
|
||||||
|
t.Errorf("IsRetryable() for %d = %v, want %v", tt.code, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIError_IsRateLimited(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
code int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{200, false},
|
||||||
|
{400, false},
|
||||||
|
{429, true},
|
||||||
|
{500, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
err := &APIError{StatusCode: tt.code}
|
||||||
|
if got := err.IsRateLimited(); got != tt.want {
|
||||||
|
t.Errorf("IsRateLimited() for %d = %v, want %v", tt.code, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIError_IsNotFound(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
code int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{200, false},
|
||||||
|
{400, false},
|
||||||
|
{404, true},
|
||||||
|
{500, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
err := &APIError{StatusCode: tt.code}
|
||||||
|
if got := err.IsNotFound(); got != tt.want {
|
||||||
|
t.Errorf("IsNotFound() for %d = %v, want %v", tt.code, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusMessage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
code int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{400, "Bad request — check that all parameters are valid"},
|
||||||
|
{401, "Authentication required — provide a valid API key"},
|
||||||
|
{403, "Forbidden — insufficient permissions for this endpoint"},
|
||||||
|
{404, "Not found — the requested resource does not exist"},
|
||||||
|
{429, "Rate limit exceeded — wait before retrying"},
|
||||||
|
{500, "TorrentClaw server error"},
|
||||||
|
{502, "TorrentClaw is temporarily unavailable"},
|
||||||
|
{503, "TorrentClaw is under maintenance"},
|
||||||
|
{418, "API request failed with status 418"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := statusMessage(tt.code)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("statusMessage(%d) = %q, want %q", tt.code, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAPIError(t *testing.T) {
|
||||||
|
err := newAPIError(404, "resource not found")
|
||||||
|
if err.StatusCode != 404 {
|
||||||
|
t.Errorf("StatusCode = %d, want 404", err.StatusCode)
|
||||||
|
}
|
||||||
|
if err.Body != "resource not found" {
|
||||||
|
t.Errorf("Body = %q, want %q", err.Body, "resource not found")
|
||||||
|
}
|
||||||
|
if err.Message != statusMessage(404) {
|
||||||
|
t.Errorf("Message = %q", err.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIError_ErrorsAs(t *testing.T) {
|
||||||
|
err := newAPIError(429, "rate limited")
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatal("errors.As failed to match *APIError")
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 429 {
|
||||||
|
t.Errorf("StatusCode = %d, want 429", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
347
example_test.go
Normal file
347
example_test.go
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
package torrentclaw_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/torrentclaw/go-client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example() {
|
||||||
|
client := torrentclaw.NewClient(
|
||||||
|
torrentclaw.WithAPIKey("your-api-key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := client.Search(context.Background(), torrentclaw.SearchParams{
|
||||||
|
Query: "Inception",
|
||||||
|
Type: "movie",
|
||||||
|
Quality: "1080p",
|
||||||
|
Sort: "seeders",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range resp.Results {
|
||||||
|
fmt.Printf("%s (%s)\n", result.Title, result.ContentType)
|
||||||
|
for _, t := range result.Torrents {
|
||||||
|
fmt.Printf(" %s — %d seeders\n", t.InfoHash, t.Seeders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleNewClient() {
|
||||||
|
// Create a client with default settings.
|
||||||
|
_ = torrentclaw.NewClient()
|
||||||
|
|
||||||
|
// Create a client with API key.
|
||||||
|
_ = torrentclaw.NewClient(
|
||||||
|
torrentclaw.WithAPIKey("your-api-key"),
|
||||||
|
torrentclaw.WithTimeout(30*time.Second),
|
||||||
|
torrentclaw.WithRetry(5, 2*time.Second, 60*time.Second),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create a client with bearer token.
|
||||||
|
_ = torrentclaw.NewClient(
|
||||||
|
torrentclaw.WithBearerToken("your-bearer-token"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Search() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
resp, err := client.Search(context.Background(), torrentclaw.SearchParams{
|
||||||
|
Query: "Breaking Bad",
|
||||||
|
Type: "show",
|
||||||
|
YearMin: 2008,
|
||||||
|
Language: "en",
|
||||||
|
Season: 1,
|
||||||
|
Episode: 5,
|
||||||
|
Page: 1,
|
||||||
|
Limit: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d results\n", resp.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Popular() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
resp, err := client.Popular(context.Background(), torrentclaw.PopularParams{
|
||||||
|
Limit: 10,
|
||||||
|
Page: 1,
|
||||||
|
Locale: "es",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
fmt.Printf("%s — %d seeders\n", item.Title, item.MaxSeeders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Credits() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
credits, err := client.Credits(context.Background(), 42)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if credits.Director != nil {
|
||||||
|
fmt.Printf("Director: %s\n", *credits.Director)
|
||||||
|
}
|
||||||
|
for _, member := range credits.Cast {
|
||||||
|
fmt.Printf("%s as %s\n", member.Name, member.Character)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_TorrentDownloadURL() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
url := client.TorrentDownloadURL("abc123def456789012345678901234567890abcd")
|
||||||
|
fmt.Println(url)
|
||||||
|
// Output: https://torrentclaw.com/api/v1/torrent/abc123def456789012345678901234567890abcd
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Autocomplete() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
suggestions, err := client.Autocomplete(context.Background(), torrentclaw.AutocompleteParams{
|
||||||
|
Query: "incep",
|
||||||
|
Locale: "es",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range suggestions {
|
||||||
|
fmt.Printf("%s (%s)\n", s.Title, s.ContentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Recent() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
resp, err := client.Recent(context.Background(), torrentclaw.RecentParams{
|
||||||
|
Limit: 10,
|
||||||
|
Page: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
fmt.Printf("%s — added %s\n", item.Title, item.CreatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_WatchProviders() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
resp, err := client.WatchProviders(context.Background(), 42, "US")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range resp.Providers.Flatrate {
|
||||||
|
fmt.Printf("Stream on: %s\n", p.Name)
|
||||||
|
}
|
||||||
|
if resp.VPNSuggestion != nil {
|
||||||
|
fmt.Printf("Also available via VPN in: %v\n", resp.VPNSuggestion.AvailableIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Stats() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
stats, err := client.Stats(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Movies: %d, Shows: %d\n", stats.Content.Movies, stats.Content.Shows)
|
||||||
|
fmt.Printf("Torrents: %d (with seeders: %d)\n", stats.Torrents.Total, stats.Torrents.WithSeeders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_GetTorrentFile() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
data, err := client.GetTorrentFile(context.Background(), "abc123def456")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// data contains the raw .torrent file bytes
|
||||||
|
_ = data
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleWithRetry() {
|
||||||
|
// Disable retries entirely.
|
||||||
|
_ = torrentclaw.NewClient(torrentclaw.WithRetry(0, 0, 0))
|
||||||
|
|
||||||
|
// Custom: 5 retries, starting at 2s, capped at 60s.
|
||||||
|
_ = torrentclaw.NewClient(
|
||||||
|
torrentclaw.WithRetry(5, 2*time.Second, 60*time.Second),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Trending() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
resp, err := client.Trending(context.Background(), torrentclaw.TrendingParams{
|
||||||
|
Period: "weekly",
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Trending (%s):\n", resp.Period)
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
fmt.Printf(" %s — score %d\n", item.Title, item.TrendScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Upcoming() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
resp, err := client.Upcoming(context.Background(), torrentclaw.UpcomingParams{
|
||||||
|
Type: "movie",
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
fmt.Printf("%s — releases %s\n", item.Title, item.ReleaseDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Collections() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
resp, err := client.Collections(context.Background(), torrentclaw.CollectionListParams{
|
||||||
|
Limit: 12,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range resp.Items {
|
||||||
|
fmt.Printf("%s (%d movies)\n", c.Name, c.MovieCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_CollectionByID() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
detail, err := client.CollectionByID(context.Background(), 10, "es")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s — %d movies\n", detail.Name, detail.MovieCount)
|
||||||
|
for _, m := range detail.Movies {
|
||||||
|
fmt.Printf(" %s\n", m.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_StreamingTop() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
items, err := client.StreamingTop(context.Background(), torrentclaw.StreamingTopParams{
|
||||||
|
Service: "netflix",
|
||||||
|
Country: "US",
|
||||||
|
ShowType: "movie",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
fmt.Printf("#%d %s (torrents: %v)\n", item.Rank, item.Title, item.HasTorrents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_DebridCheckCache() {
|
||||||
|
client := torrentclaw.NewClient(torrentclaw.WithAPIKey("your-pro-key"))
|
||||||
|
|
||||||
|
resp, err := client.DebridCheckCache(context.Background(),
|
||||||
|
"real-debrid", "your-debrid-key",
|
||||||
|
[]string{"abc123...", "def456..."},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for hash, cached := range resp.Cached {
|
||||||
|
fmt.Printf("%s: cached=%v\n", hash, cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_DebridAddMagnet() {
|
||||||
|
client := torrentclaw.NewClient(torrentclaw.WithAPIKey("your-pro-key"))
|
||||||
|
|
||||||
|
resp, err := client.DebridAddMagnet(context.Background(),
|
||||||
|
"real-debrid", "your-debrid-key", "abc123...",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Added: %s (cached: %v)\n", resp.Name, resp.Cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Torznab() {
|
||||||
|
client := torrentclaw.NewClient(torrentclaw.WithAPIKey("your-pro-key"))
|
||||||
|
|
||||||
|
// Get capabilities
|
||||||
|
caps, err := client.TorznabCaps(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = caps // XML bytes
|
||||||
|
|
||||||
|
// Search for a movie
|
||||||
|
data, err := client.Torznab(context.Background(), torrentclaw.TorznabParams{
|
||||||
|
T: "movie",
|
||||||
|
Q: "inception",
|
||||||
|
Limit: 25,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = data // XML bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Health() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
health, err := client.Health(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Status: %s, Uptime: %ds\n", health.Status, health.Uptime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_Mirrors() {
|
||||||
|
client := torrentclaw.NewClient()
|
||||||
|
|
||||||
|
resp, err := client.Mirrors(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range resp.Mirrors {
|
||||||
|
fmt.Printf("%s — %s (primary: %v)\n", m.Label, m.URL, m.Primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/torrentclaw/go-client
|
||||||
|
|
||||||
|
go 1.22
|
||||||
58
health.go
Normal file
58
health.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// HealthResponse contains the API health status.
|
||||||
|
type HealthResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Uptime int `json:"uptime"`
|
||||||
|
Database *string `json:"database,omitempty"`
|
||||||
|
Redis *string `json:"redis,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorInfo represents an active TorrentClaw mirror instance.
|
||||||
|
type MirrorInfo struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TorInfo represents a Tor (.onion) access point.
|
||||||
|
type TorInfo struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusChannel represents a status/announcement channel.
|
||||||
|
type StatusChannel struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorsResponse contains the list of mirrors and access channels.
|
||||||
|
type MirrorsResponse struct {
|
||||||
|
Mirrors []MirrorInfo `json:"mirrors"`
|
||||||
|
Tor *TorInfo `json:"tor,omitempty"`
|
||||||
|
Lite *string `json:"lite,omitempty"`
|
||||||
|
Channels []StatusChannel `json:"channels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health returns the API health status. This endpoint does not require authentication.
|
||||||
|
func (c *Client) Health(ctx context.Context) (*HealthResponse, error) {
|
||||||
|
var resp HealthResponse
|
||||||
|
if err := c.doJSON(ctx, "/api/health", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors returns the list of active TorrentClaw mirrors and access channels.
|
||||||
|
// This endpoint does not require authentication.
|
||||||
|
func (c *Client) Mirrors(ctx context.Context) (*MirrorsResponse, error) {
|
||||||
|
var resp MirrorsResponse
|
||||||
|
if err := c.doJSON(ctx, "/api/mirrors", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
112
health_test.go
Normal file
112
health_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealth(t *testing.T) {
|
||||||
|
dbStatus := "connected"
|
||||||
|
redisStatus := "connected"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/health" {
|
||||||
|
t.Errorf("path = %q, want /api/health", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(HealthResponse{
|
||||||
|
Status: "ok",
|
||||||
|
Timestamp: "2026-03-26T10:00:00Z",
|
||||||
|
Uptime: 86400,
|
||||||
|
Database: &dbStatus,
|
||||||
|
Redis: &redisStatus,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Health(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Status != "ok" {
|
||||||
|
t.Errorf("Status = %q, want ok", resp.Status)
|
||||||
|
}
|
||||||
|
if resp.Uptime != 86400 {
|
||||||
|
t.Errorf("Uptime = %d, want 86400", resp.Uptime)
|
||||||
|
}
|
||||||
|
if resp.Database == nil || *resp.Database != "connected" {
|
||||||
|
t.Errorf("Database = %v, want connected", resp.Database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealth_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Health(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMirrors(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/mirrors" {
|
||||||
|
t.Errorf("path = %q, want /api/mirrors", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(MirrorsResponse{
|
||||||
|
Mirrors: []MirrorInfo{
|
||||||
|
{URL: "https://torrentclaw.com", Label: "Primary", Primary: true},
|
||||||
|
{URL: "https://tc2.example.com", Label: "Mirror 1", Primary: false},
|
||||||
|
},
|
||||||
|
Tor: &TorInfo{URL: "http://example.onion", Label: ".onion"},
|
||||||
|
Channels: []StatusChannel{
|
||||||
|
{Label: "Telegram", URL: "https://t.me/torrentclaw"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Mirrors(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Mirrors) != 2 {
|
||||||
|
t.Fatalf("len(Mirrors) = %d, want 2", len(resp.Mirrors))
|
||||||
|
}
|
||||||
|
if !resp.Mirrors[0].Primary {
|
||||||
|
t.Error("first mirror should be primary")
|
||||||
|
}
|
||||||
|
if resp.Tor == nil {
|
||||||
|
t.Fatal("expected Tor")
|
||||||
|
}
|
||||||
|
if resp.Tor.URL != "http://example.onion" {
|
||||||
|
t.Errorf("Tor.URL = %q", resp.Tor.URL)
|
||||||
|
}
|
||||||
|
if len(resp.Channels) != 1 {
|
||||||
|
t.Fatalf("len(Channels) = %d, want 1", len(resp.Channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMirrors_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Mirrors(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
26
lefthook.yml
Normal file
26
lefthook.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Lefthook git hooks configuration
|
||||||
|
# Install: lefthook install (or make install-hooks)
|
||||||
|
# Docs: https://github.com/evilmartians/lefthook
|
||||||
|
|
||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
commands:
|
||||||
|
gofmt-check:
|
||||||
|
glob: "*.go"
|
||||||
|
run: test -z "$(gofmt -l .)" || { echo "Files not formatted:"; gofmt -l .; exit 1; }
|
||||||
|
go-vet:
|
||||||
|
glob: "*.go"
|
||||||
|
run: go vet ./...
|
||||||
|
golangci-lint:
|
||||||
|
glob: "*.go"
|
||||||
|
run: |
|
||||||
|
if command -v golangci-lint &> /dev/null; then
|
||||||
|
golangci-lint run ./...
|
||||||
|
else
|
||||||
|
echo "golangci-lint not installed, skipping (install: https://golangci-lint.run/welcome/install/)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
commit-msg:
|
||||||
|
scripts:
|
||||||
|
validate.sh:
|
||||||
|
runner: bash
|
||||||
167
search.go
Normal file
167
search.go
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchParams holds the parameters for a search request.
|
||||||
|
type SearchParams struct {
|
||||||
|
// Query is the search query (required, max 200 characters).
|
||||||
|
Query string
|
||||||
|
|
||||||
|
// Type filters by content type: "movie" or "show".
|
||||||
|
Type string
|
||||||
|
|
||||||
|
// Genre filters by genre (exact match), e.g. "Action", "Comedy".
|
||||||
|
Genre string
|
||||||
|
|
||||||
|
// YearMin sets the minimum release year.
|
||||||
|
YearMin int
|
||||||
|
|
||||||
|
// YearMax sets the maximum release year.
|
||||||
|
YearMax int
|
||||||
|
|
||||||
|
// MinRating sets the minimum IMDb/TMDB rating threshold (0-10).
|
||||||
|
MinRating float64
|
||||||
|
|
||||||
|
// Quality filters by video resolution: "480p", "720p", "1080p", "2160p".
|
||||||
|
Quality string
|
||||||
|
|
||||||
|
// Language filters by torrent audio language (ISO 639 code, e.g. "en", "es").
|
||||||
|
Language string
|
||||||
|
|
||||||
|
// Subs filters by subtitle language (ISO 639 code, e.g. "en", "es").
|
||||||
|
Subs string
|
||||||
|
|
||||||
|
// Audio filters by audio codec (substring match, e.g. "aac", "atmos").
|
||||||
|
Audio string
|
||||||
|
|
||||||
|
// HDR filters by HDR format (e.g. "hdr10", "dolby_vision").
|
||||||
|
HDR string
|
||||||
|
|
||||||
|
// Sort sets the sort order: "relevance", "seeders", "year", "rating", "added".
|
||||||
|
Sort string
|
||||||
|
|
||||||
|
// Page is the page number (starts at 1).
|
||||||
|
Page int
|
||||||
|
|
||||||
|
// Limit sets the number of results per page (1-50).
|
||||||
|
Limit int
|
||||||
|
|
||||||
|
// Country is an ISO 3166-1 country code to include streaming availability.
|
||||||
|
Country string
|
||||||
|
|
||||||
|
// Locale sets the language for title/overview translations (e.g. "es", "fr").
|
||||||
|
Locale string
|
||||||
|
|
||||||
|
// Availability filters by torrent availability: "all", "available", "unavailable".
|
||||||
|
Availability string
|
||||||
|
|
||||||
|
// Verified filters to only TrueSpec verified releases.
|
||||||
|
Verified bool
|
||||||
|
|
||||||
|
// Season filters by TV show season number.
|
||||||
|
Season int
|
||||||
|
|
||||||
|
// Episode filters by TV show episode number.
|
||||||
|
Episode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResult represents a single content result from a search.
|
||||||
|
type SearchResult struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
IMDbID *string `json:"imdbId,omitempty"`
|
||||||
|
TMDbID *int `json:"tmdbId,omitempty"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
TitleOriginal *string `json:"titleOriginal,omitempty"`
|
||||||
|
Year *int `json:"year,omitempty"`
|
||||||
|
Overview *string `json:"overview,omitempty"`
|
||||||
|
PosterURL *string `json:"posterUrl,omitempty"`
|
||||||
|
BackdropURL *string `json:"backdropUrl,omitempty"`
|
||||||
|
Genres []string `json:"genres,omitempty"`
|
||||||
|
RatingIMDb *string `json:"ratingImdb,omitempty"`
|
||||||
|
RatingTMDb *string `json:"ratingTmdb,omitempty"`
|
||||||
|
ContentURL string `json:"contentUrl"`
|
||||||
|
HasTorrents bool `json:"hasTorrents"`
|
||||||
|
Torrents []TorrentInfo `json:"torrents"`
|
||||||
|
Streaming *StreamingInfo `json:"streaming,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResponse is the paginated response from the search endpoint.
|
||||||
|
type SearchResponse struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
ParsedSeason *int `json:"parsedSeason,omitempty"`
|
||||||
|
ParsedEpisode *int `json:"parsedEpisode,omitempty"`
|
||||||
|
FuzzyMatch *bool `json:"fuzzyMatch,omitempty"`
|
||||||
|
Results []SearchResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutocompleteParams holds the parameters for an autocomplete request.
|
||||||
|
type AutocompleteParams struct {
|
||||||
|
// Query is the search prefix (required, 2-200 characters).
|
||||||
|
Query string
|
||||||
|
|
||||||
|
// Locale sets the language for localized suggestions.
|
||||||
|
Locale string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutocompleteSuggestion represents a single autocomplete result.
|
||||||
|
type AutocompleteSuggestion struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year *int `json:"year,omitempty"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
PosterURL *string `json:"posterUrl,omitempty"`
|
||||||
|
MovieCount *int `json:"movieCount,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search queries the TorrentClaw search endpoint with the given parameters.
|
||||||
|
// The Query field in params is required.
|
||||||
|
func (c *Client) Search(ctx context.Context, params SearchParams) (*SearchResponse, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("q", params.Query)
|
||||||
|
addStringParam(q, "type", params.Type)
|
||||||
|
addStringParam(q, "genre", params.Genre)
|
||||||
|
addIntParam(q, "year_min", params.YearMin)
|
||||||
|
addIntParam(q, "year_max", params.YearMax)
|
||||||
|
addFloatParam(q, "min_rating", params.MinRating)
|
||||||
|
addStringParam(q, "quality", params.Quality)
|
||||||
|
addStringParam(q, "lang", params.Language)
|
||||||
|
addStringParam(q, "subs", params.Subs)
|
||||||
|
addStringParam(q, "audio", params.Audio)
|
||||||
|
addStringParam(q, "hdr", params.HDR)
|
||||||
|
addStringParam(q, "sort", params.Sort)
|
||||||
|
addIntParam(q, "page", params.Page)
|
||||||
|
addIntParam(q, "limit", params.Limit)
|
||||||
|
addStringParam(q, "country", params.Country)
|
||||||
|
addStringParam(q, "locale", params.Locale)
|
||||||
|
addStringParam(q, "availability", params.Availability)
|
||||||
|
addBoolParam(q, "verified", params.Verified)
|
||||||
|
addIntParam(q, "season", params.Season)
|
||||||
|
addIntParam(q, "episode", params.Episode)
|
||||||
|
|
||||||
|
var resp SearchResponse
|
||||||
|
if err := c.doJSON(ctx, "/api/v1/search", q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autocomplete returns title suggestions for the given query prefix.
|
||||||
|
func (c *Client) Autocomplete(ctx context.Context, params AutocompleteParams) ([]AutocompleteSuggestion, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("q", params.Query)
|
||||||
|
addStringParam(q, "locale", params.Locale)
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Suggestions []AutocompleteSuggestion `json:"suggestions"`
|
||||||
|
}
|
||||||
|
if err := c.doJSON(ctx, "/api/v1/autocomplete", q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Suggestions, nil
|
||||||
|
}
|
||||||
345
search_test.go
Normal file
345
search_test.go
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/search" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/search", r.URL.Path)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if got := q.Get("q"); got != "inception" {
|
||||||
|
t.Errorf("q = %q, want inception", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("type"); got != "movie" {
|
||||||
|
t.Errorf("type = %q, want movie", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("year_min"); got != "2010" {
|
||||||
|
t.Errorf("year_min = %q, want 2010", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("quality"); got != "1080p" {
|
||||||
|
t.Errorf("quality = %q, want 1080p", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("lang"); got != "en" {
|
||||||
|
t.Errorf("lang = %q, want en", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("sort"); got != "seeders" {
|
||||||
|
t.Errorf("sort = %q, want seeders", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("page"); got != "1" {
|
||||||
|
t.Errorf("page = %q, want 1", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("limit"); got != "10" {
|
||||||
|
t.Errorf("limit = %q, want 10", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("country"); got != "US" {
|
||||||
|
t.Errorf("country = %q, want US", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(SearchResponse{
|
||||||
|
Total: 1,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
Results: []SearchResult{
|
||||||
|
{
|
||||||
|
ID: 42,
|
||||||
|
ContentType: "movie",
|
||||||
|
Title: "Inception",
|
||||||
|
HasTorrents: true,
|
||||||
|
Torrents: []TorrentInfo{
|
||||||
|
{
|
||||||
|
InfoHash: "abc123",
|
||||||
|
RawTitle: "Inception.2010.1080p",
|
||||||
|
Seeders: 100,
|
||||||
|
Leechers: 10,
|
||||||
|
Source: "yts",
|
||||||
|
Languages: []string{"en"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Search(context.Background(), SearchParams{
|
||||||
|
Query: "inception",
|
||||||
|
Type: "movie",
|
||||||
|
YearMin: 2010,
|
||||||
|
Quality: "1080p",
|
||||||
|
Language: "en",
|
||||||
|
Sort: "seeders",
|
||||||
|
Page: 1,
|
||||||
|
Limit: 10,
|
||||||
|
Country: "US",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Total != 1 {
|
||||||
|
t.Errorf("Total = %d, want 1", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Results) != 1 {
|
||||||
|
t.Fatalf("len(Results) = %d, want 1", len(resp.Results))
|
||||||
|
}
|
||||||
|
r := resp.Results[0]
|
||||||
|
if r.Title != "Inception" {
|
||||||
|
t.Errorf("Title = %q, want Inception", r.Title)
|
||||||
|
}
|
||||||
|
if len(r.Torrents) != 1 {
|
||||||
|
t.Fatalf("len(Torrents) = %d, want 1", len(r.Torrents))
|
||||||
|
}
|
||||||
|
if r.Torrents[0].Seeders != 100 {
|
||||||
|
t.Errorf("Seeders = %d, want 100", r.Torrents[0].Seeders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_AllParams(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
checks := map[string]string{
|
||||||
|
"q": "test",
|
||||||
|
"type": "show",
|
||||||
|
"genre": "Comedy",
|
||||||
|
"year_min": "2020",
|
||||||
|
"year_max": "2025",
|
||||||
|
"min_rating": "7",
|
||||||
|
"quality": "2160p",
|
||||||
|
"lang": "es",
|
||||||
|
"subs": "en",
|
||||||
|
"audio": "atmos",
|
||||||
|
"hdr": "dolby_vision",
|
||||||
|
"sort": "rating",
|
||||||
|
"page": "2",
|
||||||
|
"limit": "25",
|
||||||
|
"country": "ES",
|
||||||
|
"locale": "es",
|
||||||
|
"availability": "available",
|
||||||
|
"verified": "true",
|
||||||
|
"season": "3",
|
||||||
|
"episode": "5",
|
||||||
|
}
|
||||||
|
for key, want := range checks {
|
||||||
|
if got := q.Get(key); got != want {
|
||||||
|
t.Errorf("%s = %q, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(SearchResponse{Total: 0, Page: 2, PageSize: 25})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Search(context.Background(), SearchParams{
|
||||||
|
Query: "test",
|
||||||
|
Type: "show",
|
||||||
|
Genre: "Comedy",
|
||||||
|
YearMin: 2020,
|
||||||
|
YearMax: 2025,
|
||||||
|
MinRating: 7,
|
||||||
|
Quality: "2160p",
|
||||||
|
Language: "es",
|
||||||
|
Subs: "en",
|
||||||
|
Audio: "atmos",
|
||||||
|
HDR: "dolby_vision",
|
||||||
|
Sort: "rating",
|
||||||
|
Page: 2,
|
||||||
|
Limit: 25,
|
||||||
|
Country: "ES",
|
||||||
|
Locale: "es",
|
||||||
|
Availability: "available",
|
||||||
|
Verified: true,
|
||||||
|
Season: 3,
|
||||||
|
Episode: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_EmptyOptionalParams(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if got := q.Get("q"); got != "matrix" {
|
||||||
|
t.Errorf("q = %q, want matrix", got)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"type", "genre", "year_min", "year_max", "quality", "lang", "subs", "sort", "page", "limit", "verified", "season", "episode"} {
|
||||||
|
if q.Has(key) {
|
||||||
|
t.Errorf("unexpected param %q = %q", key, q.Get(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(SearchResponse{Total: 0, Page: 1, PageSize: 20})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Search(context.Background(), SearchParams{Query: "matrix"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_ParsedSeasonEpisode(t *testing.T) {
|
||||||
|
season := 2
|
||||||
|
episode := 5
|
||||||
|
fuzzy := true
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(SearchResponse{
|
||||||
|
Total: 1,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
ParsedSeason: &season,
|
||||||
|
ParsedEpisode: &episode,
|
||||||
|
FuzzyMatch: &fuzzy,
|
||||||
|
Results: []SearchResult{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Search(context.Background(), SearchParams{Query: "breaking bad s02e05"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.ParsedSeason == nil || *resp.ParsedSeason != 2 {
|
||||||
|
t.Errorf("ParsedSeason = %v, want 2", resp.ParsedSeason)
|
||||||
|
}
|
||||||
|
if resp.ParsedEpisode == nil || *resp.ParsedEpisode != 5 {
|
||||||
|
t.Errorf("ParsedEpisode = %v, want 5", resp.ParsedEpisode)
|
||||||
|
}
|
||||||
|
if resp.FuzzyMatch == nil || !*resp.FuzzyMatch {
|
||||||
|
t.Errorf("FuzzyMatch = %v, want true", resp.FuzzyMatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutocomplete(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/autocomplete" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/autocomplete", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("q"); got != "incep" {
|
||||||
|
t.Errorf("q = %q, want incep", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"suggestions": []AutocompleteSuggestion{
|
||||||
|
{ID: 1, Title: "Inception", ContentType: "movie"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
results, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "incep"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("len(results) = %d, want 1", len(results))
|
||||||
|
}
|
||||||
|
if results[0].Title != "Inception" {
|
||||||
|
t.Errorf("Title = %q, want Inception", results[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutocomplete_WithLocale(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("locale"); got != "es" {
|
||||||
|
t.Errorf("locale = %q, want es", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"suggestions": []AutocompleteSuggestion{}})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "test", Locale: "es"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutocomplete_WithMovieCount(t *testing.T) {
|
||||||
|
movieCount := 5
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"suggestions": []AutocompleteSuggestion{
|
||||||
|
{ID: 1, Title: "Star Wars", ContentType: "collection", MovieCount: &movieCount},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
results, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "star"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if results[0].MovieCount == nil || *results[0].MovieCount != 5 {
|
||||||
|
t.Errorf("MovieCount = %v, want 5", results[0].MovieCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Search(context.Background(), SearchParams{Query: "test"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 500 {
|
||||||
|
t.Errorf("StatusCode = %d, want 500", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutocomplete_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "test"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutocomplete_EmptyResults(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"suggestions": []AutocompleteSuggestion{}})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
results, err := c.Autocomplete(context.Background(), AutocompleteParams{Query: "zzzzz"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Errorf("len(results) = %d, want 0", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
47
stats.go
Normal file
47
stats.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// ContentStats contains counts for movies, shows, and TMDB enrichment.
|
||||||
|
type ContentStats struct {
|
||||||
|
Movies int `json:"movies"`
|
||||||
|
Shows int `json:"shows"`
|
||||||
|
TMDbEnriched int `json:"tmdbEnriched"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TorrentStats contains aggregate torrent statistics.
|
||||||
|
type TorrentStats struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
WithSeeders int `json:"withSeeders"`
|
||||||
|
Orphans int `json:"orphans"`
|
||||||
|
DailyAverage int `json:"dailyAverage"`
|
||||||
|
BySource map[string]int `json:"bySource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestionRecord represents a recent data ingestion event.
|
||||||
|
type IngestionRecord struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
CompletedAt *string `json:"completedAt,omitempty"`
|
||||||
|
Fetched int `json:"fetched"`
|
||||||
|
New int `json:"new"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsResponse contains aggregator statistics.
|
||||||
|
type StatsResponse struct {
|
||||||
|
Content ContentStats `json:"content"`
|
||||||
|
Torrents TorrentStats `json:"torrents"`
|
||||||
|
RecentIngestions []IngestionRecord `json:"recentIngestions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns aggregator statistics including content counts, torrent
|
||||||
|
// counts by source, and recent ingestion history.
|
||||||
|
func (c *Client) Stats(ctx context.Context) (*StatsResponse, error) {
|
||||||
|
var resp StatsResponse
|
||||||
|
if err := c.doJSON(ctx, "/api/v1/stats", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
92
stats_test.go
Normal file
92
stats_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStats_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Stats(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 500 {
|
||||||
|
t.Errorf("StatusCode = %d, want 500", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_EmptyIngestions(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(StatsResponse{
|
||||||
|
Content: ContentStats{Movies: 100, Shows: 50, TMDbEnriched: 80},
|
||||||
|
Torrents: TorrentStats{Total: 500, WithSeeders: 300, BySource: map[string]int{}},
|
||||||
|
RecentIngestions: []IngestionRecord{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Stats(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Content.TMDbEnriched != 80 {
|
||||||
|
t.Errorf("TMDbEnriched = %d, want 80", resp.Content.TMDbEnriched)
|
||||||
|
}
|
||||||
|
if resp.Torrents.WithSeeders != 300 {
|
||||||
|
t.Errorf("WithSeeders = %d, want 300", resp.Torrents.WithSeeders)
|
||||||
|
}
|
||||||
|
if len(resp.RecentIngestions) != 0 {
|
||||||
|
t.Errorf("len(RecentIngestions) = %d, want 0", len(resp.RecentIngestions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_WithCompletedAt(t *testing.T) {
|
||||||
|
completedAt := "2025-01-15T10:05:00Z"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(StatsResponse{
|
||||||
|
Content: ContentStats{},
|
||||||
|
Torrents: TorrentStats{BySource: map[string]int{}},
|
||||||
|
RecentIngestions: []IngestionRecord{
|
||||||
|
{
|
||||||
|
Source: "yts",
|
||||||
|
Status: "completed",
|
||||||
|
StartedAt: "2025-01-15T10:00:00Z",
|
||||||
|
CompletedAt: &completedAt,
|
||||||
|
Fetched: 100,
|
||||||
|
New: 10,
|
||||||
|
Updated: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Stats(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.RecentIngestions) != 1 {
|
||||||
|
t.Fatalf("len(RecentIngestions) = %d, want 1", len(resp.RecentIngestions))
|
||||||
|
}
|
||||||
|
rec := resp.RecentIngestions[0]
|
||||||
|
if rec.CompletedAt == nil || *rec.CompletedAt != completedAt {
|
||||||
|
t.Errorf("CompletedAt = %v, want %q", rec.CompletedAt, completedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
streaming.go
Normal file
56
streaming.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamingTopParams holds the parameters for a streaming top 10 request.
|
||||||
|
type StreamingTopParams struct {
|
||||||
|
// Service is the streaming service: "netflix", "prime", "disney", "hbo", "apple".
|
||||||
|
Service string
|
||||||
|
|
||||||
|
// Country is an ISO 3166-1 country code (default "US").
|
||||||
|
Country string
|
||||||
|
|
||||||
|
// ShowType is the content type: "movie" or "series" (default "movie").
|
||||||
|
ShowType string
|
||||||
|
|
||||||
|
// Locale sets the language for localized titles.
|
||||||
|
Locale string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamingTopItem represents a ranked item in a streaming top list.
|
||||||
|
type StreamingTopItem struct {
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
IMDbID *string `json:"imdbId,omitempty"`
|
||||||
|
TMDbID *int `json:"tmdbId,omitempty"`
|
||||||
|
ContentType *string `json:"contentType,omitempty"`
|
||||||
|
Year *int `json:"year,omitempty"`
|
||||||
|
Overview *string `json:"overview,omitempty"`
|
||||||
|
Rating *string `json:"rating,omitempty"`
|
||||||
|
PosterURL *string `json:"posterUrl,omitempty"`
|
||||||
|
BackdropURL *string `json:"backdropUrl,omitempty"`
|
||||||
|
Genres []string `json:"genres,omitempty"`
|
||||||
|
StreamingLink *string `json:"streamingLink,omitempty"`
|
||||||
|
ContentID *int `json:"contentId,omitempty"`
|
||||||
|
HasTorrents bool `json:"hasTorrents"`
|
||||||
|
MaxSeeders int `json:"maxSeeders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamingTop returns the top-ranked content for a streaming service.
|
||||||
|
// The response is an array of ranked items (not a paginated wrapper).
|
||||||
|
func (c *Client) StreamingTop(ctx context.Context, params StreamingTopParams) ([]StreamingTopItem, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
addStringParam(q, "service", params.Service)
|
||||||
|
addStringParam(q, "country", params.Country)
|
||||||
|
addStringParam(q, "show_type", params.ShowType)
|
||||||
|
addStringParam(q, "locale", params.Locale)
|
||||||
|
|
||||||
|
var resp []StreamingTopItem
|
||||||
|
if err := c.doJSON(ctx, "/api/v1/streaming-top", q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
92
streaming_test.go
Normal file
92
streaming_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStreamingTop(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/streaming-top" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/streaming-top", r.URL.Path)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if got := q.Get("service"); got != "netflix" {
|
||||||
|
t.Errorf("service = %q, want netflix", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("country"); got != "US" {
|
||||||
|
t.Errorf("country = %q, want US", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("show_type"); got != "movie" {
|
||||||
|
t.Errorf("show_type = %q, want movie", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode([]StreamingTopItem{
|
||||||
|
{Rank: 1, Title: "Top Movie", HasTorrents: true, MaxSeeders: 500},
|
||||||
|
{Rank: 2, Title: "Second Movie", HasTorrents: false, MaxSeeders: 0},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
items, err := c.StreamingTop(context.Background(), StreamingTopParams{
|
||||||
|
Service: "netflix",
|
||||||
|
Country: "US",
|
||||||
|
ShowType: "movie",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("len(items) = %d, want 2", len(items))
|
||||||
|
}
|
||||||
|
if items[0].Rank != 1 {
|
||||||
|
t.Errorf("Rank = %d, want 1", items[0].Rank)
|
||||||
|
}
|
||||||
|
if items[0].Title != "Top Movie" {
|
||||||
|
t.Errorf("Title = %q, want Top Movie", items[0].Title)
|
||||||
|
}
|
||||||
|
if !items[0].HasTorrents {
|
||||||
|
t.Error("HasTorrents should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamingTop_DefaultParams(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
for _, key := range []string{"service", "country", "show_type"} {
|
||||||
|
if q.Has(key) {
|
||||||
|
t.Errorf("unexpected param %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode([]StreamingTopItem{})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
items, err := c.StreamingTop(context.Background(), StreamingTopParams{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 0 {
|
||||||
|
t.Errorf("len(items) = %d, want 0", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamingTop_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.StreamingTop(context.Background(), StreamingTopParams{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
19
torrent.go
Normal file
19
torrent.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TorrentDownloadURL returns the URL for downloading a .torrent file by its
|
||||||
|
// info hash. This method does not make an HTTP request.
|
||||||
|
func (c *Client) TorrentDownloadURL(infoHash string) string {
|
||||||
|
return fmt.Sprintf("%s/api/v1/torrent/%s", c.baseURL, infoHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTorrentFile downloads the .torrent file for the given info hash and
|
||||||
|
// returns the raw bytes.
|
||||||
|
func (c *Client) GetTorrentFile(ctx context.Context, infoHash string) ([]byte, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/torrent/%s", infoHash)
|
||||||
|
return c.doRaw(ctx, path, nil)
|
||||||
|
}
|
||||||
108
torrent_test.go
Normal file
108
torrent_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTorrentDownloadURL_CustomBase(t *testing.T) {
|
||||||
|
c := NewClient(WithBaseURL("https://custom.example.com"))
|
||||||
|
got := c.TorrentDownloadURL("deadbeef")
|
||||||
|
want := "https://custom.example.com/api/v1/torrent/deadbeef"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("TorrentDownloadURL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTorrentFile_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.GetTorrentFile(context.Background(), "abc123")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 500 {
|
||||||
|
t.Errorf("StatusCode = %d, want 500", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTorrentFile_RetryOn503(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
torrentData := []byte{0xDE, 0xAD, 0xBE, 0xEF}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 3 {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/x-bittorrent")
|
||||||
|
w.Write(torrentData)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL(srv.URL),
|
||||||
|
WithRetry(3, 1*time.Millisecond, 10*time.Millisecond),
|
||||||
|
)
|
||||||
|
data, err := c.GetTorrentFile(context.Background(), "abc123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(data) != len(torrentData) {
|
||||||
|
t.Errorf("len(data) = %d, want %d", len(data), len(torrentData))
|
||||||
|
}
|
||||||
|
if attempts != 3 {
|
||||||
|
t.Errorf("attempts = %d, want 3", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTorrentFile_RetryExhausted(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(
|
||||||
|
WithBaseURL(srv.URL),
|
||||||
|
WithRetry(1, 1*time.Millisecond, 10*time.Millisecond),
|
||||||
|
)
|
||||||
|
_, err := c.GetTorrentFile(context.Background(), "abc123")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after retries exhausted")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 502 {
|
||||||
|
t.Errorf("StatusCode = %d, want 502", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTorrentFile_ContextCanceled(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := c.GetTorrentFile(ctx, "abc123")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for canceled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
59
torznab.go
Normal file
59
torznab.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TorznabParams holds the parameters for a Torznab API request.
|
||||||
|
type TorznabParams struct {
|
||||||
|
// T is the request type: "caps", "search", "tvsearch", "movie".
|
||||||
|
T string
|
||||||
|
|
||||||
|
// Q is the search query.
|
||||||
|
Q string
|
||||||
|
|
||||||
|
// IMDbID is an IMDb identifier (e.g. "tt1234567").
|
||||||
|
IMDbID string
|
||||||
|
|
||||||
|
// TMDbID is a TMDB identifier.
|
||||||
|
TMDbID string
|
||||||
|
|
||||||
|
// Season is the season number for TV searches.
|
||||||
|
Season int
|
||||||
|
|
||||||
|
// Ep is the episode number for TV searches.
|
||||||
|
Ep int
|
||||||
|
|
||||||
|
// Cat is a comma-separated list of category codes (2000=Movies, 5000=TV).
|
||||||
|
Cat string
|
||||||
|
|
||||||
|
// Limit sets the max results (1-100, default 50).
|
||||||
|
Limit int
|
||||||
|
|
||||||
|
// Offset is the 0-based pagination offset.
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Torznab queries the Torznab-compatible API and returns the raw XML response.
|
||||||
|
// Requires a Pro tier API key.
|
||||||
|
func (c *Client) Torznab(ctx context.Context, params TorznabParams) ([]byte, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
addStringParam(q, "t", params.T)
|
||||||
|
addStringParam(q, "q", params.Q)
|
||||||
|
addStringParam(q, "imdbid", params.IMDbID)
|
||||||
|
addStringParam(q, "tmdbid", params.TMDbID)
|
||||||
|
addIntParam(q, "season", params.Season)
|
||||||
|
addIntParam(q, "ep", params.Ep)
|
||||||
|
addStringParam(q, "cat", params.Cat)
|
||||||
|
addIntParam(q, "limit", params.Limit)
|
||||||
|
addIntParam(q, "offset", params.Offset)
|
||||||
|
|
||||||
|
return c.doRaw(ctx, "/api/v1/torznab", q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TorznabCaps returns the Torznab capabilities XML.
|
||||||
|
// Requires a Pro tier API key.
|
||||||
|
func (c *Client) TorznabCaps(ctx context.Context) ([]byte, error) {
|
||||||
|
return c.Torznab(ctx, TorznabParams{T: "caps"})
|
||||||
|
}
|
||||||
117
torznab_test.go
Normal file
117
torznab_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTorznab_Search(t *testing.T) {
|
||||||
|
xmlResp := `<?xml version="1.0" encoding="UTF-8"?><rss><channel><item><title>Test</title></item></channel></rss>`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/torznab" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/torznab", r.URL.Path)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if got := q.Get("t"); got != "search" {
|
||||||
|
t.Errorf("t = %q, want search", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("q"); got != "inception" {
|
||||||
|
t.Errorf("q = %q, want inception", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("limit"); got != "50" {
|
||||||
|
t.Errorf("limit = %q, want 50", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
w.Write([]byte(xmlResp))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
data, err := c.Torznab(context.Background(), TorznabParams{
|
||||||
|
T: "search",
|
||||||
|
Q: "inception",
|
||||||
|
Limit: 50,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "<title>Test</title>") {
|
||||||
|
t.Errorf("response does not contain expected XML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTorznab_TVSearch(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if got := q.Get("t"); got != "tvsearch" {
|
||||||
|
t.Errorf("t = %q, want tvsearch", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("imdbid"); got != "tt1234567" {
|
||||||
|
t.Errorf("imdbid = %q, want tt1234567", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("season"); got != "3" {
|
||||||
|
t.Errorf("season = %q, want 3", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("ep"); got != "5" {
|
||||||
|
t.Errorf("ep = %q, want 5", got)
|
||||||
|
}
|
||||||
|
w.Write([]byte("<rss/>"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Torznab(context.Background(), TorznabParams{
|
||||||
|
T: "tvsearch",
|
||||||
|
IMDbID: "tt1234567",
|
||||||
|
Season: 3,
|
||||||
|
Ep: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTorznabCaps(t *testing.T) {
|
||||||
|
capsXML := `<?xml version="1.0"?><caps><server title="TorrentClaw"/></caps>`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("t"); got != "caps" {
|
||||||
|
t.Errorf("t = %q, want caps", got)
|
||||||
|
}
|
||||||
|
w.Write([]byte(capsXML))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
data, err := c.TorznabCaps(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "TorrentClaw") {
|
||||||
|
t.Error("caps should contain TorrentClaw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTorznab_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte("pro tier required"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Torznab(context.Background(), TorznabParams{T: "search", Q: "test"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 403 {
|
||||||
|
t.Errorf("StatusCode = %d, want 403", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
trending.go
Normal file
60
trending.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrendingParams holds the parameters for a trending content request.
|
||||||
|
type TrendingParams struct {
|
||||||
|
// Period sets the time window: "daily", "weekly", "monthly" (default "daily").
|
||||||
|
Period string
|
||||||
|
|
||||||
|
// Limit sets the number of items (1-50, default 20).
|
||||||
|
Limit int
|
||||||
|
|
||||||
|
// Page is the page number (starts at 1).
|
||||||
|
Page int
|
||||||
|
|
||||||
|
// Locale sets the language for localized titles.
|
||||||
|
Locale string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendingItem represents a trending content item.
|
||||||
|
type TrendingItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year *int `json:"year,omitempty"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
PosterURL *string `json:"posterUrl,omitempty"`
|
||||||
|
RatingIMDb *string `json:"ratingImdb,omitempty"`
|
||||||
|
RatingTMDb *string `json:"ratingTmdb,omitempty"`
|
||||||
|
Overview *string `json:"overview,omitempty"`
|
||||||
|
MaxSeeders int `json:"maxSeeders"`
|
||||||
|
ClickCount int `json:"clickCount"`
|
||||||
|
TrendScore int `json:"trendScore"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendingResponse is the paginated response from the trending endpoint.
|
||||||
|
type TrendingResponse struct {
|
||||||
|
Period string `json:"period"`
|
||||||
|
Items []TrendingItem `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trending returns trending content for the given time period.
|
||||||
|
func (c *Client) Trending(ctx context.Context, params TrendingParams) (*TrendingResponse, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
addStringParam(q, "period", params.Period)
|
||||||
|
addIntParam(q, "limit", params.Limit)
|
||||||
|
addIntParam(q, "page", params.Page)
|
||||||
|
addStringParam(q, "locale", params.Locale)
|
||||||
|
|
||||||
|
var resp TrendingResponse
|
||||||
|
if err := c.doJSON(ctx, "/api/v1/trending", q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
87
trending_test.go
Normal file
87
trending_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTrending(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/trending" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/trending", r.URL.Path)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if got := q.Get("period"); got != "weekly" {
|
||||||
|
t.Errorf("period = %q, want weekly", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("limit"); got != "10" {
|
||||||
|
t.Errorf("limit = %q, want 10", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("locale"); got != "es" {
|
||||||
|
t.Errorf("locale = %q, want es", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(TrendingResponse{
|
||||||
|
Period: "weekly",
|
||||||
|
Items: []TrendingItem{
|
||||||
|
{ID: 1, Title: "Trending Movie", ContentType: "movie", MaxSeeders: 1000, TrendScore: 95},
|
||||||
|
},
|
||||||
|
Total: 50,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Trending(context.Background(), TrendingParams{Period: "weekly", Limit: 10, Locale: "es"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Period != "weekly" {
|
||||||
|
t.Errorf("Period = %q, want weekly", resp.Period)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 1 {
|
||||||
|
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
|
||||||
|
}
|
||||||
|
if resp.Items[0].TrendScore != 95 {
|
||||||
|
t.Errorf("TrendScore = %d, want 95", resp.Items[0].TrendScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrending_DefaultParams(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
for _, key := range []string{"period", "limit", "page", "locale"} {
|
||||||
|
if q.Has(key) {
|
||||||
|
t.Errorf("unexpected param %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(TrendingResponse{Period: "daily", Total: 0, Page: 1, PageSize: 20})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Trending(context.Background(), TrendingParams{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrending_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Trending(context.Background(), TrendingParams{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
126
types.go
Normal file
126
types.go
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
// This file contains shared types referenced by multiple endpoint files.
|
||||||
|
// Domain-specific types (params, responses) live alongside their methods
|
||||||
|
// in the corresponding endpoint file (search.go, content.go, etc.).
|
||||||
|
|
||||||
|
// ─── TrueSpec media metadata ────
|
||||||
|
|
||||||
|
// AudioTrack represents a single audio track detected by TrueSpec scanning.
|
||||||
|
type AudioTrack struct {
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
Channels int `json:"channels"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Default bool `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubtitleTrack represents a single subtitle track detected by TrueSpec scanning.
|
||||||
|
type SubtitleTrack struct {
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Forced bool `json:"forced,omitempty"`
|
||||||
|
Default bool `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoInfo contains video stream metadata detected by TrueSpec scanning.
|
||||||
|
type VideoInfo struct {
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
BitDepth *int `json:"bitDepth,omitempty"`
|
||||||
|
HDR *string `json:"hdr,omitempty"`
|
||||||
|
FrameRate *float64 `json:"frameRate,omitempty"`
|
||||||
|
Profile *string `json:"profile,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Torrent file analysis ────
|
||||||
|
|
||||||
|
// TorrentFileInfo describes a single file inside a torrent.
|
||||||
|
type TorrentFileInfo struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Ext string `json:"ext"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuspiciousFileInfo describes a file flagged as potentially dangerous.
|
||||||
|
type SuspiciousFileInfo struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Ext string `json:"ext"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
VTPermalink *string `json:"vtPermalink,omitempty"`
|
||||||
|
VTDetections *int `json:"vtDetections,omitempty"`
|
||||||
|
VTTotalEngines *int `json:"vtTotalEngines,omitempty"`
|
||||||
|
VTStatus *string `json:"vtStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TorrentFilesInfo contains the file listing and threat analysis for a torrent.
|
||||||
|
type TorrentFilesInfo struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
TotalSize int64 `json:"totalSize"`
|
||||||
|
VideoFiles []TorrentFileInfo `json:"videoFiles"`
|
||||||
|
AudioFiles []TorrentFileInfo `json:"audioFiles"`
|
||||||
|
SubFiles []TorrentFileInfo `json:"subFiles"`
|
||||||
|
ImageFiles []TorrentFileInfo `json:"imageFiles"`
|
||||||
|
OtherFiles []TorrentFileInfo `json:"otherFiles"`
|
||||||
|
Suspicious []SuspiciousFileInfo `json:"suspicious"`
|
||||||
|
ThreatLevel string `json:"threatLevel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Torrent ────
|
||||||
|
|
||||||
|
// TorrentInfo contains metadata about a single torrent.
|
||||||
|
// Used by SearchResult and torrent download endpoints.
|
||||||
|
type TorrentInfo struct {
|
||||||
|
InfoHash string `json:"infoHash"`
|
||||||
|
RawTitle string `json:"rawTitle"`
|
||||||
|
Quality *string `json:"quality,omitempty"`
|
||||||
|
Codec *string `json:"codec,omitempty"`
|
||||||
|
SourceType *string `json:"sourceType,omitempty"`
|
||||||
|
SizeBytes *int64 `json:"sizeBytes,omitempty"`
|
||||||
|
Seeders int `json:"seeders"`
|
||||||
|
Leechers int `json:"leechers"`
|
||||||
|
MagnetURL *string `json:"magnetUrl,omitempty"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
QualityScore *int `json:"qualityScore,omitempty"`
|
||||||
|
UploadedAt *string `json:"uploadedAt,omitempty"`
|
||||||
|
Languages []string `json:"languages"`
|
||||||
|
AudioCodec *string `json:"audioCodec,omitempty"`
|
||||||
|
AudioTracks []AudioTrack `json:"audioTracks,omitempty"`
|
||||||
|
SubtitleTracks []SubtitleTrack `json:"subtitleTracks,omitempty"`
|
||||||
|
VideoInfo *VideoInfo `json:"videoInfo,omitempty"`
|
||||||
|
ScanStatus *string `json:"scanStatus,omitempty"`
|
||||||
|
ThreatLevel *string `json:"threatLevel,omitempty"`
|
||||||
|
TorrentFiles *TorrentFilesInfo `json:"torrentFiles,omitempty"`
|
||||||
|
HDRType *string `json:"hdrType,omitempty"`
|
||||||
|
ReleaseGroup *string `json:"releaseGroup,omitempty"`
|
||||||
|
IsProper *bool `json:"isProper,omitempty"`
|
||||||
|
IsRepack *bool `json:"isRepack,omitempty"`
|
||||||
|
IsRemastered *bool `json:"isRemastered,omitempty"`
|
||||||
|
Season *int `json:"season,omitempty"`
|
||||||
|
Episode *int `json:"episode,omitempty"`
|
||||||
|
SubtitleLanguages []string `json:"subtitleLanguages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Streaming ────
|
||||||
|
|
||||||
|
// StreamingProviderItem represents a streaming service provider.
|
||||||
|
// Used by StreamingInfo in search results.
|
||||||
|
type StreamingProviderItem struct {
|
||||||
|
ProviderID int `json:"providerId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Logo *string `json:"logo,omitempty"`
|
||||||
|
Link *string `json:"link,omitempty"`
|
||||||
|
DisplayPriority int `json:"displayPriority,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamingInfo contains streaming availability grouped by type.
|
||||||
|
// Used by SearchResult when a country filter is applied.
|
||||||
|
type StreamingInfo struct {
|
||||||
|
Flatrate []StreamingProviderItem `json:"flatrate,omitempty"`
|
||||||
|
Rent []StreamingProviderItem `json:"rent,omitempty"`
|
||||||
|
Buy []StreamingProviderItem `json:"buy,omitempty"`
|
||||||
|
Free []StreamingProviderItem `json:"free,omitempty"`
|
||||||
|
}
|
||||||
62
upcoming.go
Normal file
62
upcoming.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpcomingParams holds the parameters for an upcoming releases request.
|
||||||
|
type UpcomingParams struct {
|
||||||
|
// Limit sets the number of items (1-50, default 24).
|
||||||
|
Limit int
|
||||||
|
|
||||||
|
// Page is the page number (starts at 1).
|
||||||
|
Page int
|
||||||
|
|
||||||
|
// Type filters by content type: "all", "movie", "show" (default "all").
|
||||||
|
Type string
|
||||||
|
|
||||||
|
// Locale sets the language for localized titles.
|
||||||
|
Locale string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpcomingItem represents an upcoming release.
|
||||||
|
type UpcomingItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
TMDbID *int `json:"tmdbId,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year *int `json:"year,omitempty"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
PosterURL *string `json:"posterUrl,omitempty"`
|
||||||
|
BackdropURL *string `json:"backdropUrl,omitempty"`
|
||||||
|
Overview *string `json:"overview,omitempty"`
|
||||||
|
Genres []string `json:"genres,omitempty"`
|
||||||
|
RatingIMDb *string `json:"ratingImdb,omitempty"`
|
||||||
|
RatingTMDb *string `json:"ratingTmdb,omitempty"`
|
||||||
|
PopularityTMDb *float64 `json:"popularityTmdb,omitempty"`
|
||||||
|
ReleaseDate string `json:"releaseDate"`
|
||||||
|
HasTorrents bool `json:"hasTorrents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpcomingResponse is the paginated response from the upcoming endpoint.
|
||||||
|
type UpcomingResponse struct {
|
||||||
|
Items []UpcomingItem `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upcoming returns upcoming releases, optionally filtered by content type.
|
||||||
|
func (c *Client) Upcoming(ctx context.Context, params UpcomingParams) (*UpcomingResponse, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
addIntParam(q, "limit", params.Limit)
|
||||||
|
addIntParam(q, "page", params.Page)
|
||||||
|
addStringParam(q, "type", params.Type)
|
||||||
|
addStringParam(q, "locale", params.Locale)
|
||||||
|
|
||||||
|
var resp UpcomingResponse
|
||||||
|
if err := c.doJSON(ctx, "/api/v1/upcoming", q, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
66
upcoming_test.go
Normal file
66
upcoming_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package torrentclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpcoming(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/upcoming" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/upcoming", r.URL.Path)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if got := q.Get("type"); got != "movie" {
|
||||||
|
t.Errorf("type = %q, want movie", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("limit"); got != "10" {
|
||||||
|
t.Errorf("limit = %q, want 10", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(UpcomingResponse{
|
||||||
|
Items: []UpcomingItem{
|
||||||
|
{ID: 1, Title: "Future Movie", ContentType: "movie", ReleaseDate: "2026-05-01", HasTorrents: false},
|
||||||
|
},
|
||||||
|
Total: 25,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
resp, err := c.Upcoming(context.Background(), UpcomingParams{Type: "movie", Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Total != 25 {
|
||||||
|
t.Errorf("Total = %d, want 25", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 1 {
|
||||||
|
t.Fatalf("len(Items) = %d, want 1", len(resp.Items))
|
||||||
|
}
|
||||||
|
if resp.Items[0].ReleaseDate != "2026-05-01" {
|
||||||
|
t.Errorf("ReleaseDate = %q", resp.Items[0].ReleaseDate)
|
||||||
|
}
|
||||||
|
if resp.Items[0].HasTorrents {
|
||||||
|
t.Error("HasTorrents should be false for upcoming")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpcoming_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(WithBaseURL(srv.URL), WithRetry(0, 0, 0))
|
||||||
|
_, err := c.Upcoming(context.Background(), UpcomingParams{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue