feat: initial commit — unarr CLI

Search, inspect, stream, and download torrents from the terminal.
Replaces the entire *arr stack with a single binary.
This commit is contained in:
Deivid Soto 2026-03-28 11:29:42 +01:00
commit 29cf0a0126
85 changed files with 10178 additions and 0 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
.git
.github
*.md
LICENSE
.goreleaser.yml
unarr
dist/

10
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,10 @@
# Default owners for everything in the repo
* @torrentclaw/maintainers
# CI/CD and release
.github/ @torrentclaw/maintainers
.goreleaser.yml @torrentclaw/maintainers
Dockerfile @torrentclaw/maintainers
# Core engine
internal/engine/ @torrentclaw/maintainers

33
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,33 @@
---
name: Bug Report
about: Report a bug or unexpected behavior
title: ""
labels: bug
assignees: ""
---
## Description
<!-- A clear description of the bug. -->
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
<!-- What should happen. -->
## Actual Behavior
<!-- What actually happens. Include error messages or output. -->
## Environment
- **OS**: (e.g., Ubuntu 24.04, macOS 15, Windows 11)
- **Architecture**: (e.g., amd64, arm64)
- **unarr version**: (`unarr version`)
- **Go version** (if built from source): (`go version`)
- **Install method**: (Homebrew / GitHub Release / go install / Docker)

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Discussion
url: https://github.com/torrentclaw/torrentclaw-cli/discussions
about: Ask questions or start a discussion

View file

@ -0,0 +1,23 @@
---
name: Feature Request
about: Suggest a new feature or improvement
title: ""
labels: enhancement
assignees: ""
---
## Problem
<!-- What problem does this feature solve? -->
## Proposed Solution
<!-- How would you like it to work? -->
## Alternatives Considered
<!-- Any other approaches you've thought about. -->
## Additional Context
<!-- Screenshots, links, or anything else relevant. -->

24
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,24 @@
## Summary
<!-- What does this PR do? Keep it to 1-3 bullet points. -->
-
## Related Issues
<!-- Link any related issues: Fixes #123, Closes #456 -->
## Test Plan
<!-- How did you verify this works? -->
- [ ] Tests pass (`make test`)
- [ ] Linter passes (`make lint`)
- [ ] Tested manually (describe below)
## Checklist
- [ ] Code follows the project style (`make fmt && make vet`)
- [ ] Tests added for new functionality
- [ ] Documentation updated (if public API changed)
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)

22
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,22 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "dependencies"
commit-message:
prefix: "chore(deps)"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "ci"
commit-message:
prefix: "ci(deps)"

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

@ -0,0 +1,131 @@
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: Checkout go-client (local replace dependency)
uses: actions/checkout@v4
with:
repository: torrentclaw/go-client
path: ../go-client
- 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 ./...
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- name: Checkout go-client (local replace dependency)
uses: actions/checkout@v4
with:
repository: torrentclaw/go-client
path: ../go-client
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: go build -o unarr ./cmd/unarr/
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout go-client (local replace dependency)
uses: actions/checkout@v4
with:
repository: torrentclaw/go-client
path: ../go-client
- 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: v2.1.6
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout go-client (local replace dependency)
uses: actions/checkout@v4
with:
repository: torrentclaw/go-client
path: ../go-client
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Run tests with coverage
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.out
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
vet:
name: Vet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout go-client (local replace dependency)
uses: actions/checkout@v4
with:
repository: torrentclaw/go-client
path: ../go-client
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Run go vet
run: go vet ./...

39
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
name: GoReleaser
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Checkout go-client (local replace dependency)
run: git clone --depth 1 https://github.com/torrentclaw/go-client "$GITHUB_WORKSPACE/../go-client"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Binary (root only, not cmd/ directory)
/unarr
/unarr.exe
# Test binary
*.test
# Output of go coverage
*.out
coverage.html
# Go profiling
*.prof
# Go workspace
go.work
go.work.sum
# Go debugger (delve)
__debug_bin*
# Environment files (may contain API keys)
.env
.env.*
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# GoReleaser
dist/
# Docker
tmp/

44
.golangci.yml Normal file
View file

@ -0,0 +1,44 @@
run:
timeout: 5m
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gosec
- bodyclose
- copyloopvar
- durationcheck
- errname
- errorlint
- exhaustive
- gofmt
- goimports
- misspell
- nilerr
- prealloc
- unconvert
- unparam
- wastedassign
linters-settings:
gosec:
excludes:
- G104 # Allow unhandled errors in fire-and-forget (notifications)
errcheck:
exclude-functions:
- (*os/exec.Cmd).Start # Fire-and-forget for notifications
exhaustive:
default-signifies-exhaustive: true
misspell:
locale: US
issues:
exclude-dirs:
- dist
max-issues-per-linter: 50
max-same-issues: 5

54
.goreleaser.yml Normal file
View file

@ -0,0 +1,54 @@
version: 2
project_name: unarr
builds:
- main: ./cmd/unarr/
binary: unarr
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X github.com/torrentclaw/torrentclaw-cli/internal/cmd.Version={{.Version}}
upx:
- enabled: true
goos:
- linux
- windows
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
brews:
- repository:
owner: torrentclaw
name: homebrew-tap
name: unarr
homepage: https://github.com/torrentclaw/torrentclaw-cli
description: "unarr — replaces the entire *arr stack"
license: MIT
install: |
bin.install "unarr"

View file

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

47
CHANGELOG.md Normal file
View file

@ -0,0 +1,47 @@
# 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).
## [Unreleased]
### Added
- Daemon mode with background download management (`unarr start`)
- One-shot download command (`unarr download`)
- Stream to media player (`unarr stream`)
- Setup wizard for first-time configuration (`unarr setup`)
- Doctor command for diagnostics (`unarr doctor`)
- Status command for daemon monitoring (`unarr status`)
- Download engine with torrent support (debrid and usenet coming soon)
- File organization (Movies/TV Shows directory structure)
- Post-download verification
- Desktop notifications (Linux, macOS)
- Docker support with multi-stage build
- Cross-platform install scripts (shell, PowerShell)
- Dependabot for automated dependency updates
- golangci-lint configuration with gosec
### Changed
- Renamed `internal/commands/` to `internal/cmd/`
## [0.1.0] - 2025-02-14
### Added
- Initial release
- Search across 30+ torrent sources with advanced filters
- TrueSpec torrent inspection (quality, codec, seeds, score)
- Watch command (streaming providers + torrent alternatives)
- Popular and recent content browsing
- System statistics
- Interactive configuration
- JSON output mode (`--json`) for scripting
- Colored terminal output with `--no-color` support
- Homebrew tap distribution
- GoReleaser with UPX compression
- CI pipeline (test, build, lint, vet)
- Lefthook git hooks (gofmt, go vet, conventional commits)
[Unreleased]: https://github.com/torrentclaw/torrentclaw-cli/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/torrentclaw/torrentclaw-cli/releases/tag/v0.1.0

84
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,84 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

200
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,200 @@
# Contributing to unarr
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-cli.git
cd torrentclaw-cli
```
3. **Set up the Go client** (local dependency):
```bash
# Clone the go-client next to the CLI
cd ..
git clone https://github.com/torrentclaw/go-client.git
cd torrentclaw-cli
```
4. **Create a branch** for your change:
```bash
git checkout -b feature/my-feature
```
5. **Make your changes**, write tests, and ensure everything passes
6. **Commit** with a clear message (see [Commit Messages](#commit-messages))
7. **Push** to your fork and [open a Pull Request](https://github.com/torrentclaw/torrentclaw-cli/compare)
## Development Setup
You need **Go 1.22+** installed.
### Build and Run
```bash
make build # Build the binary
./unarr --help # Test it
# Or install to GOPATH/bin
make install
```
### 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`, build, and run `golangci-lint` (if installed)
- **commit-msg**: validate the message follows [Conventional Commits](#commit-messages)
### Make Targets
```bash
make build # Build the binary
make test # Run tests
make coverage # Run tests with coverage
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 install # Install to GOPATH/bin
make all # fmt + vet + lint + test + build
make install-hooks # Install lefthook git hooks
```
## Project Structure
```
torrentclaw-cli/
├── cmd/unarr/ # Entry point
│ └── main.go
├── internal/
│ ├── cmd/ # Cobra command definitions
│ │ ├── root.go # Root command + global flags
│ │ ├── search.go # Search command
│ │ ├── inspect.go # Inspect (TrueSpec) command
│ │ ├── watch.go # Watch (streaming + torrents)
│ │ ├── popular.go # Popular/recent content
│ │ ├── config.go # Interactive configuration
│ │ ├── setup.go # First-time setup wizard
│ │ ├── daemon.go # Daemon mode (start/stop)
│ │ ├── download.go # One-shot download
│ │ ├── stream.go # Stream to media player
│ │ ├── doctor.go # Diagnostics
│ │ ├── status.go # Daemon status
│ │ └── stubs.go # Stub commands (future)
│ ├── config/ # Configuration management
│ │ ├── config.go # Config struct + TOML parsing
│ │ └── paths.go # XDG-compliant paths
│ ├── engine/ # Download engine
│ │ ├── manager.go # Download orchestration
│ │ ├── task.go # Task state machine
│ │ ├── torrent.go # BitTorrent downloader
│ │ ├── stream.go # Streaming engine
│ │ ├── organize.go # File organization
│ │ └── ... # Verify, resolve, notify, etc.
│ ├── agent/ # API client + daemon
│ │ ├── client.go # HTTP client
│ │ └── daemon.go # Daemon lifecycle
│ ├── ui/ # Output formatting
│ │ ├── table.go # Table rendering
│ │ └── format.go # Size, rating, time formatting
│ └── parser/ # Torrent parsing
│ └── torrent.go # Magnet URI, hash, name parsing
├── go.mod
├── Makefile
└── README.md
```
## Code Style
- Run `gofmt` on all code (or `make fmt`)
- Run `golangci-lint` (or `make lint`)
- Follow existing patterns in the codebase
- Keep commands in separate files under `internal/cmd/`
- Keep output formatting in `internal/ui/`
## Running Tests
```bash
# All tests
make test
# Specific test
go test -run TestParse -v ./internal/parser/...
# With coverage report
make coverage
```
## 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 search by genre
feat(inspect): add HDR detection
fix(search): handle empty results
docs: update README
test: add parser edge case tests
chore: update CI matrix to Go 1.24
```
## 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
## Adding a New Command
1. Create `internal/cmd/mycommand.go`
2. Define a `newMyCommandCmd() *cobra.Command` function
3. Add it to `rootCmd.AddCommand(...)` in `root.go`
4. Add any UI rendering helpers to `internal/ui/table.go`
## Reporting Bugs
[Open an issue](https://github.com/torrentclaw/torrentclaw-cli/issues/new?labels=bug) with:
- **Description** — what went wrong
- **Steps to reproduce** — minimal commands to trigger the bug
- **Expected behavior** — what you expected to happen
- **Actual behavior** — what actually happened
- **Environment** — Go version, OS, CLI version (`unarr version`)
## 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
---
Thank you for helping make unarr better!

49
Dockerfile Normal file
View file

@ -0,0 +1,49 @@
# ---- Build stage ----
# Build context must be the parent directory containing both torrentclaw-cli/
# and go-client/. Use: docker build -f torrentclaw-cli/Dockerfile .
# Or use the provided docker-build.sh script.
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git ca-certificates
# Copy go-client dependency (local replace in go.mod -> ../go-client)
WORKDIR /deps
COPY go-client/ /deps/go-client/
# Copy go.mod/go.sum first for layer caching
WORKDIR /src
COPY torrentclaw-cli/go.mod torrentclaw-cli/go.sum ./
RUN go mod edit -replace github.com/torrentclaw/go-client=/deps/go-client
RUN go mod download
# Copy source (changes here won't invalidate mod cache)
COPY torrentclaw-cli/ .
RUN go mod edit -replace github.com/torrentclaw/go-client=/deps/go-client
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /unarr ./cmd/unarr/
# ---- Runtime stage ----
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
# Non-root user (UID 1000 matches typical host user for volume permissions)
RUN addgroup -g 1000 unarr && adduser -u 1000 -G unarr -D -h /home/unarr unarr
# Default directories
RUN mkdir -p /config /downloads /data && \
chown -R unarr:unarr /config /downloads /data
USER unarr
COPY --from=builder /unarr /usr/local/bin/unarr
# Environment: point config/data to container paths
ENV UNARR_CONFIG_DIR=/config
ENV UNARR_DOWNLOAD_DIR=/downloads
ENV XDG_DATA_HOME=/data
VOLUME ["/config", "/downloads", "/data"]
ENTRYPOINT ["unarr"]
CMD ["start"]

21
LICENSE Normal file
View file

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

47
Makefile Normal file
View file

@ -0,0 +1,47 @@
.PHONY: all build test lint coverage clean fmt vet check install-hooks
BINARY = unarr
all: fmt vet lint test build
## Build the binary
build:
go build -o $(BINARY) ./cmd/unarr/
## 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
## Install binary to GOPATH/bin
install:
go install ./cmd/unarr/
## Remove generated files
clean:
rm -f $(BINARY) coverage.out coverage.html

216
README.md Normal file
View file

@ -0,0 +1,216 @@
# unarr
[![CI](https://github.com/torrentclaw/torrentclaw-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/torrentclaw/torrentclaw-cli/actions/workflows/ci.yml)
[![Go Report Card](https://goreportcard.com/badge/github.com/torrentclaw/torrentclaw-cli)](https://goreportcard.com/report/github.com/torrentclaw/torrentclaw-cli)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Go Version](https://img.shields.io/github/go-mod/go-version/torrentclaw/torrentclaw-cli)](go.mod)
Powerful terminal tool for torrent search and management.
Search 30+ torrent sources, inspect torrent quality, discover popular content, find streaming providers, and manage your media collection — all from your terminal.
<!-- GIF demo placeholder -->
<!-- ![unarr Demo](docs/demo.gif) -->
## Installation
### Go install
```bash
go install github.com/torrentclaw/torrentclaw-cli/cmd/unarr@latest
```
### Homebrew (macOS/Linux)
```bash
brew install torrentclaw/tap/unarr
```
### GitHub Releases
Download prebuilt binaries for Linux, macOS, and Windows from [GitHub Releases](https://github.com/torrentclaw/torrentclaw-cli/releases).
### Build from source
```bash
git clone https://github.com/torrentclaw/torrentclaw-cli.git
cd torrentclaw-cli
make build
```
## Quick Start
```bash
# Configure (first time)
unarr config
# Search for content
unarr search "breaking bad" --type show --quality 1080p
# Inspect a torrent
unarr inspect "magnet:?xt=urn:btih:ABC123&dn=Movie.2023.1080p.BluRay.x265"
# Popular content
unarr popular --limit 20
# Recently added
unarr recent
# Where to watch (streaming + torrents)
unarr watch "oppenheimer" --country ES
# System statistics
unarr stats
```
## Commands
### Search
Search the unarr catalog with advanced filters.
```bash
unarr search "inception" --sort seeders --min-rating 7 --lang es
```
**Filters:**
| Flag | Description | Example |
|------|-------------|---------|
| `--type` | Content type | `movie`, `show` |
| `--quality` | Video quality | `480p`, `720p`, `1080p`, `2160p` |
| `--lang` | Audio language (ISO 639) | `es`, `en`, `fr` |
| `--genre` | Genre filter | `Action`, `Comedy`, `Drama` |
| `--year-min` | Minimum release year | `2020` |
| `--year-max` | Maximum release year | `2026` |
| `--min-rating` | Minimum IMDb/TMDb rating | `7` |
| `--sort` | Sort order | `relevance`, `seeders`, `year`, `rating`, `added` |
| `--limit` | Results per page (1-50) | `10` |
| `--page` | Page number | `2` |
| `--country` | Country for streaming info | `US`, `ES` |
### Inspect
TrueSpec analysis — parse a torrent and show detailed specs.
```bash
unarr inspect "Oppenheimer.2023.1080p.BluRay.x265"
```
Accepts magnet URIs, 40-character info hashes, or torrent names.
Output includes: quality, codec, size, seeds, languages, source, quality score, and health.
### Watch
Find where to watch — streaming services alongside torrent options.
```bash
unarr watch "oppenheimer" --country ES
```
Shows legal streaming options first (subscription, free, rent, buy), then torrent alternatives.
### Popular
Show trending content ranked by community engagement.
```bash
unarr popular --limit 20
```
### Recent
Show the most recently added content.
```bash
unarr recent --limit 20
```
### Stats
Display unarr system statistics.
```bash
unarr stats
```
### Config
Interactive configuration setup.
```bash
unarr config
```
Saves to `~/.config/unarr/config.yaml`.
## Alias
You can use `un` as a shorthand for `unarr`:
```bash
un search "breaking bad" --type show
un popular --limit 5
```
## Global Flags
| Flag | Description |
|------|-------------|
| `--json` | Output as JSON (for piping to `jq`, scripts) |
| `--no-color` | Disable colored output |
| `--api-key` | API key (overrides config file and env) |
| `--config` | Custom config file path |
## JSON Output
All commands support `--json` for scripting:
```bash
# Pipe to jq
unarr search "matrix" --json | jq '.results[].title'
# Save to file
unarr popular --json > popular.json
# Use in scripts
SEEDS=$(unarr search "inception" --json | jq '.results[0].torrents[0].seeders')
```
## Configuration
Config file location: `~/.config/unarr/config.yaml`
```yaml
api_url: https://torrentclaw.com
api_key: tc_your_api_key_here
country: US
```
Environment variables (override config file):
```bash
export UNARR_API_URL=https://torrentclaw.com
export UNARR_API_KEY=tc_your_api_key
export UNARR_COUNTRY=ES
```
## Coming Soon
These commands are stubbed and will be available in future releases:
- `unarrupgrade` — Find a better version of a torrent
- `unarrmoreseed` — Find same quality with more seeders
- `unarrcompare` — Compare two torrents side by side
- `unarrscan` — Scan your media library for upgrades
- `unarradd` — Search and add torrents to your client
- `unarrmonitor` — Watch for new episodes of a series
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style, and guidelines.
## License
MIT License — see [LICENSE](LICENSE) for details.

64
SECURITY.md Normal file
View file

@ -0,0 +1,64 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|--------------------|
| latest | :white_check_mark: |
| < latest | :x: |
Only the latest release receives security updates.
## Reporting a Vulnerability
**Please do NOT report security vulnerabilities through public GitHub issues.**
Instead, report them via **GitHub Security Advisories**:
1. Go to [Security Advisories](https://github.com/torrentclaw/torrentclaw-cli/security/advisories)
2. Click **"Report a vulnerability"**
3. Fill in the details
Alternatively, email **security@torrentclaw.com** with:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
## Response Timeline
- **Acknowledgment**: within 48 hours
- **Initial assessment**: within 5 business days
- **Fix and disclosure**: coordinated with reporter, typically within 30 days
## Scope
The following are in scope:
- Command injection or arbitrary code execution
- Path traversal or file access outside intended directories
- Authentication bypass or credential exposure
- Denial of service in the daemon
- Dependency vulnerabilities with exploitable impact
The following are out of scope:
- Vulnerabilities in torrent protocol itself (BitTorrent DHT, peer exchange)
- Issues requiring physical access to the machine
- Social engineering attacks
## Security Practices
This project follows these security practices:
- **No hardcoded credentials** — API keys stored in config files with 0600 permissions
- **Path traversal protection** — All file operations validated through `safePath()`
- **HTTPS by default** — All API communication uses TLS
- **Response size limits** — API responses capped at 1MB
- **Non-root Docker** — Container runs as unprivileged user (UID 1000)
- **Dependency scanning** — Automated via Dependabot
## Disclosure Policy
We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous.

7
cmd/unarr/main.go Normal file
View file

@ -0,0 +1,7 @@
package main
import "github.com/torrentclaw/torrentclaw-cli/internal/cmd"
func main() {
cmd.Execute()
}

17
docker-build.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
# Build the unarr Docker image.
# Must be run from the torrentclaw-cli directory (or its parent).
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
# Build from parent dir so both torrentclaw-cli/ and go-client/ are in context
docker build \
-f "$SCRIPT_DIR/Dockerfile" \
-t torrentclaw/unarr:latest \
"$PARENT_DIR"
echo ""
echo "✓ Built: torrentclaw/unarr:latest"
docker images torrentclaw/unarr:latest --format " Size: {{.Size}}"

48
docker-compose.yml Normal file
View file

@ -0,0 +1,48 @@
services:
unarr:
build:
context: ..
dockerfile: torrentclaw-cli/Dockerfile
image: torrentclaw/unarr:latest
container_name: unarr
restart: unless-stopped
user: "1000:1000"
# Read-only root filesystem — only volumes are writable
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
# Config: your config.toml lives here
- ./config:/config
# Downloads: finished media goes here
- ~/Media:/downloads
# Data: torrent metadata, piece DB, cache
- unarr-data:/data
environment:
- TZ=${TZ:-UTC}
# Optional overrides (uncomment to use):
# - UNARR_API_KEY=tc_your_key_here
# - UNARR_API_URL=https://torrentclaw.com
# Resource limits — adjust to your needs
deploy:
resources:
limits:
memory: 512M
cpus: "2.0"
# Torrent P2P needs host network or explicit port range
# Option A: host network (simplest, full P2P performance)
network_mode: host
# Option B: bridge network with port mapping (more isolated)
# Uncomment below and comment out network_mode above:
# ports:
# - "6881-6889:6881-6889/tcp"
# - "6881-6889:6881-6889/udp"
volumes:
unarr-data:

123
go.mod Normal file
View file

@ -0,0 +1,123 @@
module github.com/torrentclaw/torrentclaw-cli
go 1.24.0
require (
github.com/BurntSushi/toml v1.6.0
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb
github.com/anacrolix/torrent v1.61.0
github.com/charmbracelet/huh v1.0.0
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/olekukonko/tablewriter v0.0.5
github.com/spf13/cobra v1.8.1
github.com/torrentclaw/go-client v0.2.0
golang.org/x/time v0.14.0
)
require (
github.com/RoaringBitmap/roaring v1.2.3 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 // indirect
github.com/anacrolix/chansync v0.7.0 // indirect
github.com/anacrolix/dht/v2 v2.23.0 // indirect
github.com/anacrolix/envpprof v1.4.0 // indirect
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect
github.com/anacrolix/go-libutp v1.3.2 // indirect
github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
github.com/anacrolix/mmsg v1.0.1 // indirect
github.com/anacrolix/multiless v0.4.0 // indirect
github.com/anacrolix/stm v0.5.0 // indirect
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 // indirect
github.com/anacrolix/upnp v0.1.4 // indirect
github.com/anacrolix/utp v0.1.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.0.6 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.33 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/pion/webrtc/v4 v4.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/crypto v0.44.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
lukechampine.com/blake3 v1.1.6 // indirect
modernc.org/libc v1.22.3 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.21.1 // indirect
zombiezen.com/go/sqlite v0.13.1 // indirect
)

591
go.sum Normal file
View file

@ -0,0 +1,591 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
github.com/RoaringBitmap/roaring v1.2.3 h1:yqreLINqIrX22ErkKI0vY47/ivtJr6n+kMhVOVmhWBY=
github.com/RoaringBitmap/roaring v1.2.3/go.mod h1:plvDsJQpxOC5bw8LRteu/MLWHsHez/3y6cubLI4/1yE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 h1:c02PsmoaChabVqAFm7pqPI1UIkDdDAjUaWa6ZmfxybQ=
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8/go.mod h1:7stWJ39LeusmMI8mjJuhFNRqep//vx0AsaySRoK9or0=
github.com/anacrolix/chansync v0.7.0 h1:wgwxbsJRmOqNjil4INpxHrDp4rlqQhECxR8/WBP4Et0=
github.com/anacrolix/chansync v0.7.0/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k=
github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY=
github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U=
github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=
github.com/anacrolix/envpprof v1.4.0 h1:QHeIcrgHcRChhnxR8l6rlaLlRQx9zd7Q2NII6Zbt83w=
github.com/anacrolix/envpprof v1.4.0/go.mod h1:7QIG4CaX1uexQ3tqd5+BRa/9e2D02Wcertl6Yh0jCB0=
github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b h1:Kuvx/A/TTJuT9x8mn7DeGx2KW9tWn1LI8bira67xdT0=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc=
github.com/anacrolix/go-libutp v1.3.2 h1:WswiaxTIogchbkzNgGHuHRfbrYLpv4o290mlvcx+++M=
github.com/anacrolix/go-libutp v1.3.2/go.mod h1:fCUiEnXJSe3jsPG554A200Qv+45ZzIIyGEvE56SHmyA=
github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68=
github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY=
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU=
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk=
github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/lsan v0.1.0 h1:TbgB8fdVXgBwrNsJGHtht9+9FepNFu5H7dU8ek6XYAY=
github.com/anacrolix/lsan v0.1.0/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s=
github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y=
github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw=
github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc=
github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw=
github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ=
github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY=
github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA=
github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY=
github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y=
github.com/anacrolix/mmsg v1.0.1 h1:TxfpV7kX70m3f/O7ielL/2I3OFkMPjrRCPo7+4X5AWw=
github.com/anacrolix/mmsg v1.0.1/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc=
github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM=
github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM=
github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg=
github.com/anacrolix/stm v0.5.0 h1:9df1KBpttF0TzLgDq51Z+TEabZKMythqgx89f1FQJt8=
github.com/anacrolix/stm v0.5.0/go.mod h1:MOwrSy+jCm8Y7HYfMAwPj7qWVu7XoVvjOiYwJmpeB/M=
github.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk=
github.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g=
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 h1:oLCfNgEOR3/Z98mSwmwTM1pcqCDb/1zIjxCNn7dzVaE=
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1/go.mod h1:21cUWerw9eiu/3T3kyoChu37AVO+YFue1/H15qqubS0=
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
github.com/anacrolix/torrent v1.61.0 h1:vxo+B4SwnoP5AQWbhvnTYIaTgPSX+llYUVuQVsN4Jg8=
github.com/anacrolix/torrent v1.61.0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ=
github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U=
github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic=
github.com/anacrolix/utp v0.1.0 h1:FOpQOmIwYsnENnz7tAGohA+r6iXpRjrq8ssKSre2Cp4=
github.com/anacrolix/utp v0.1.0/go.mod h1:MDwc+vsGEq7RMw6lr2GKOEqjWny5hO5OZXRVNaBJ2Dk=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d h1:2qVb9bsAMtmAfnxXltm+6eBzrrS7SZ52c3SedsulaMI=
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d/go.mod h1:iAr8OjJGLnLmVUr9MZ/rz4PWUy6Ouc2JLYuMArmvAJM=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 h1:OyQmpAN302wAopDgwVjgs2HkFawP9ahIEqkUYz7V7CA=
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916/go.mod h1:DADrR88ONKPPeSGjFp5iEN55Arx3fi2qXZeKCYDpbmU=
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 h1:GClwZI0at7xwV0TpgUMTYr/DoTE7TJZ/tc29LcPcs7o=
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYzd38ICggWqtaE=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/torrentclaw/go-client v0.2.0 h1:WHDJbL2XCPhKV0JhbvNIlAQgq5RL9CBZ/nyeNGxj/hQ=
github.com/torrentclaw/go-client v0.2.0/go.mod h1:6FfY57RS9xS0i/TSiloizkC0RkXobUVLpc5T1hx9dRU=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=

254
install.ps1 Normal file
View file

@ -0,0 +1,254 @@
# unarr — Windows installer (PowerShell 5.1+)
# Usage: irm https://get.unarr.com/install.ps1 | iex
# or: irm https://raw.githubusercontent.com/torrentclaw/torrentclaw-cli/main/install.ps1 | iex
#
# Options (env vars):
# $env:INSTALL_DIR = "C:\path" — where to place the binary
# $env:VERSION = "0.5.0" — specific version
# $env:METHOD = "binary|docker" — force install method
param(
[string]$Method,
[string]$Version,
[string]$InstallDir
)
$ErrorActionPreference = "Stop"
$Repo = "torrentclaw/torrentclaw-cli"
$Binary = "unarr.exe"
# ---- Helpers ----
function Write-Info { param($msg) Write-Host "$msg" -ForegroundColor Cyan }
function Write-Ok { param($msg) Write-Host "$msg" -ForegroundColor Green }
function Write-Warn { param($msg) Write-Host "! $msg" -ForegroundColor Yellow }
function Write-Err { param($msg) Write-Error "$msg"; throw "Installation failed: $msg" }
# ---- Detect architecture ----
function Get-Arch {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
switch ($arch) {
"X64" { return "amd64" }
"Arm64" { return "arm64" }
default {
# Fallback for older PowerShell
if ($env:PROCESSOR_ARCHITECTURE -eq "AMD64") { return "amd64" }
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { return "arm64" }
Write-Err "Unsupported architecture: $arch"
}
}
}
# ---- Detect install directory ----
function Get-InstallDir {
if ($InstallDir) { return $InstallDir }
if ($env:INSTALL_DIR) { return $env:INSTALL_DIR }
# Default: %LOCALAPPDATA%\Programs\unarr
$dir = Join-Path $env:LOCALAPPDATA "Programs\unarr"
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
return $dir
}
# ---- Get latest version ----
function Get-LatestVersion {
if ($Version) { return $Version.TrimStart("v") }
if ($env:VERSION) { return $env:VERSION.TrimStart("v") }
Write-Info "Checking latest version..."
try {
$release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest"
return $release.tag_name.TrimStart("v")
} catch {
# Fallback: follow redirect
try {
$response = Invoke-WebRequest "https://github.com/$Repo/releases/latest" -MaximumRedirection 0 -ErrorAction SilentlyContinue
$location = $response.Headers.Location
if ($location -match "/v?(\d+\.\d+\.\d+)") {
return $Matches[1]
}
} catch {}
}
Write-Err "Could not determine latest version. Set `$env:VERSION='x.y.z' and retry."
}
# ---- Add to PATH ----
function Add-ToPath {
param($dir)
$currentPath = [Environment]::GetEnvironmentVariable("PATH", "User")
if ($currentPath -split ";" -contains $dir) { return }
Write-Info "Adding $dir to user PATH..."
[Environment]::SetEnvironmentVariable("PATH", "$currentPath;$dir", "User")
$env:PATH = "$env:PATH;$dir"
Write-Ok "Added to PATH (restart terminal for full effect)"
}
# ---- Install binary ----
function Install-Binary {
$ver = Get-LatestVersion
$arch = Get-Arch
$dir = Get-InstallDir
$archive = "unarr_${ver}_windows_${arch}.zip"
$url = "https://github.com/$Repo/releases/download/v${ver}/$archive"
Write-Info "Downloading unarr v$ver for windows/$arch..."
$tmpDir = Join-Path $env:TEMP "unarr-install-$(Get-Random)"
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
try {
$zipPath = Join-Path $tmpDir $archive
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
Write-Info "Extracting..."
Expand-Archive -Path $zipPath -DestinationPath $tmpDir -Force
# Find binary
$binPath = Get-ChildItem -Path $tmpDir -Recurse -Filter "unarr.exe" | Select-Object -First 1
if (-not $binPath) {
Write-Err "Binary not found in archive"
}
Copy-Item $binPath.FullName (Join-Path $dir $Binary) -Force
Write-Ok "Installed unarr v$ver to $dir\$Binary"
Add-ToPath $dir
} finally {
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
}
}
# ---- Install Docker ----
function Install-Docker {
$dockerCmd = Get-Command docker -ErrorAction SilentlyContinue
if (-not $dockerCmd) {
Write-Err "Docker not found. Install Docker Desktop: https://docs.docker.com/desktop/install/windows/"
}
Write-Info "Pulling torrentclaw/unarr:latest..."
try {
docker pull torrentclaw/unarr:latest 2>$null
} catch {
Write-Info "Image not on Docker Hub, building from source..."
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
if (-not $gitCmd) {
Write-Err "git not found. Install git or pull the image manually."
}
$tmpDir = Join-Path $env:TEMP "unarr-build-$(Get-Random)"
git clone --depth 1 "https://github.com/$Repo.git" $tmpDir
docker build -t torrentclaw/unarr:latest $tmpDir
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
}
Write-Ok "Docker image ready: torrentclaw/unarr:latest"
Write-Host ""
Write-Host "Quick start:" -ForegroundColor White
Write-Host ""
Write-Host " # 1. Create config directory"
Write-Host " mkdir `$env:APPDATA\unarr"
Write-Host ""
Write-Host " # 2. Run setup (interactive)"
Write-Host " docker run -it --rm -v `$env:APPDATA\unarr:/config torrentclaw/unarr setup"
Write-Host ""
Write-Host " # 3. Start daemon"
Write-Host " docker run -d --name unarr --restart unless-stopped ``"
Write-Host " --read-only --memory 512m ``"
Write-Host " -v `$env:APPDATA\unarr:/config ``"
Write-Host " -v `$HOME\Media:/downloads ``"
Write-Host " torrentclaw/unarr"
Write-Host ""
}
# ---- Uninstall ----
function Uninstall-Unarr {
Write-Info "Uninstalling unarr..."
# Remove binary
$dir = Get-InstallDir
$binPath = Join-Path $dir $Binary
if (Test-Path $binPath) {
Remove-Item $binPath -Force
Write-Ok "Removed $binPath"
}
# Clean empty install dir
if ((Test-Path $dir) -and -not (Get-ChildItem $dir)) {
Remove-Item $dir -Force
}
# Remove Docker
$dockerCmd = Get-Command docker -ErrorAction SilentlyContinue
if ($dockerCmd) {
docker rm -f unarr 2>$null | Out-Null
docker rmi torrentclaw/unarr:latest 2>$null | Out-Null
Write-Ok "Removed Docker container and image"
}
Write-Ok "Uninstalled. Config remains at $env:APPDATA\unarr\ (delete manually if desired)."
exit
}
# ---- Interactive menu ----
function Show-Menu {
Write-Host ""
Write-Host " unarr Installer" -ForegroundColor White
Write-Host " ────────────────────────"
Write-Host ""
Write-Host " Detected: " -NoNewline
Write-Host "windows/$(Get-Arch)" -ForegroundColor Cyan
Write-Host ""
Write-Host " Install method:"
Write-Host ""
Write-Host " 1) " -NoNewline -ForegroundColor White
Write-Host "Binary — standalone .exe, no dependencies"
Write-Host " 2) " -NoNewline -ForegroundColor White
Write-Host "Docker — sandboxed, isolated filesystem access " -NoNewline
Write-Host "(recommended)" -ForegroundColor Green
Write-Host " u) " -NoNewline -ForegroundColor White
Write-Host "Uninstall"
Write-Host ""
$choice = Read-Host " Choice [1/2]"
switch ($choice) {
"1" { return "binary" }
"2" { return "docker" }
"u" { Uninstall-Unarr }
"U" { Uninstall-Unarr }
default { Write-Err "Invalid choice: $choice" }
}
}
# ---- Main ----
function Main {
# Resolve method
$m = if ($Method) { $Method }
elseif ($env:METHOD) { $env:METHOD }
else { Show-Menu }
Write-Host ""
switch ($m) {
"binary" {
Install-Binary
Write-Host ""
Write-Host " Run " -NoNewline
Write-Host "unarr setup" -ForegroundColor White -NoNewline
Write-Host " to get started."
Write-Host ""
}
"docker" {
Install-Docker
}
default {
Write-Err "Unknown method: $m"
}
}
}
Main

333
install.sh Executable file
View file

@ -0,0 +1,333 @@
#!/bin/sh
# unarr — cross-platform installer (Linux / macOS)
# Usage: curl -fsSL https://get.unarr.com/install.sh | sh
# or: curl -fsSL https://raw.githubusercontent.com/torrentclaw/torrentclaw-cli/main/install.sh | sh
#
# Options (env vars):
# INSTALL_DIR=/usr/local/bin — where to place the binary (default: /usr/local/bin or ~/.local/bin)
# VERSION=0.5.0 — specific version (default: latest)
# METHOD=binary|docker — force install method (default: auto-detect)
set -e
REPO="torrentclaw/torrentclaw-cli"
BINARY="unarr"
# ---- Colors (only if terminal) ----
if [ -t 1 ]; then
BOLD="\033[1m"
GREEN="\033[32m"
YELLOW="\033[33m"
RED="\033[31m"
CYAN="\033[36m"
RESET="\033[0m"
else
BOLD="" GREEN="" YELLOW="" RED="" CYAN="" RESET=""
fi
info() { printf "${CYAN}${RESET} %s\n" "$1"; }
ok() { printf "${GREEN}${RESET} %s\n" "$1"; }
warn() { printf "${YELLOW}!${RESET} %s\n" "$1"; }
error() { printf "${RED}${RESET} %s\n" "$1" >&2; exit 1; }
# ---- Detect OS ----
detect_os() {
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$OS" in
linux*) OS="linux" ;;
darwin*) OS="darwin" ;;
mingw*|msys*|cygwin*) OS="windows" ;;
*) error "Unsupported OS: $OS. Use install.ps1 for Windows." ;;
esac
}
# ---- Detect architecture ----
detect_arch() {
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) error "Unsupported architecture: $ARCH" ;;
esac
}
# ---- Detect if we can write to /usr/local/bin ----
detect_install_dir() {
if [ -n "$INSTALL_DIR" ]; then
return
fi
if [ -w "/usr/local/bin" ]; then
INSTALL_DIR="/usr/local/bin"
elif [ -d "$HOME/.local/bin" ]; then
INSTALL_DIR="$HOME/.local/bin"
else
mkdir -p "$HOME/.local/bin"
INSTALL_DIR="$HOME/.local/bin"
fi
}
# ---- Check if command exists ----
has() {
command -v "$1" >/dev/null 2>&1
}
# ---- HTTP download (curl or wget) ----
download() {
url="$1"
output="$2"
if has curl; then
curl -fsSL -o "$output" "$url"
elif has wget; then
wget -qO "$output" "$url"
else
error "Neither curl nor wget found. Install one and retry."
fi
}
# ---- Fetch text (for API) ----
fetch() {
url="$1"
if has curl; then
curl -fsSL "$url"
elif has wget; then
wget -qO- "$url"
fi
}
# ---- Get latest version from GitHub ----
get_latest_version() {
if [ -n "$VERSION" ]; then
return
fi
info "Checking latest version..."
# Try GitHub API first
response=$(fetch "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null || true)
if [ -n "$response" ]; then
# Parse tag_name from JSON (works without jq)
VERSION=$(echo "$response" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/')
fi
if [ -z "$VERSION" ]; then
# Fallback: follow redirect from /releases/latest
if has curl; then
VERSION=$(curl -fsSI "https://github.com/$REPO/releases/latest" 2>/dev/null | grep -i '^location:' | sed 's|.*/v\{0,1\}||' | tr -d '\r\n')
fi
fi
if [ -z "$VERSION" ]; then
error "Could not determine latest version. Set VERSION=x.y.z and retry."
fi
}
# ---- Install via binary ----
install_binary() {
get_latest_version
# Strip leading 'v' if present
VERSION="${VERSION#v}"
archive="${BINARY}_${VERSION}_${OS}_${ARCH}.tar.gz"
url="https://github.com/$REPO/releases/download/v${VERSION}/${archive}"
info "Downloading $BINARY v$VERSION for $OS/$ARCH..."
tmpdir=$(mktemp -d)
download "$url" "$tmpdir/$archive"
info "Extracting..."
tar -xzf "$tmpdir/$archive" -C "$tmpdir"
# Find binary (may be at root or in a subdir)
bin_path=$(find "$tmpdir" -name "$BINARY" -type f | head -1)
if [ -z "$bin_path" ]; then
rm -rf "$tmpdir"
error "Binary not found in archive"
fi
chmod +x "$bin_path"
# Install
if [ -w "$INSTALL_DIR" ]; then
mv "$bin_path" "$INSTALL_DIR/$BINARY"
else
info "Requires sudo to install to $INSTALL_DIR"
sudo mv "$bin_path" "$INSTALL_DIR/$BINARY"
fi
rm -rf "$tmpdir"
ok "Installed $BINARY v$VERSION to $INSTALL_DIR/$BINARY"
# Check PATH
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*)
warn "$INSTALL_DIR is not in your PATH."
printf " Add it with: ${BOLD}export PATH=\"%s:\$PATH\"${RESET}\n" "$INSTALL_DIR"
;;
esac
}
# ---- Install via Docker ----
install_docker() {
if ! has docker; then
error "Docker not found. Install Docker first: https://docs.docker.com/get-docker/"
fi
info "Pulling torrentclaw/unarr:latest..."
docker pull torrentclaw/unarr:latest 2>/dev/null || {
info "Image not on Docker Hub yet, building from source..."
tmpdir=$(mktemp -d)
if has git; then
git clone --depth 1 "https://github.com/$REPO.git" "$tmpdir/unarr"
docker build -t torrentclaw/unarr:latest "$tmpdir/unarr"
rm -rf "$tmpdir"
else
rm -rf "$tmpdir"
error "git not found. Install git or pull the image manually."
fi
}
ok "Docker image ready: torrentclaw/unarr:latest"
printf "\n${BOLD}Quick start:${RESET}\n"
cat <<'DOCKER_USAGE'
# 1. Create config directory
mkdir -p ~/.config/unarr
# 2. Run setup (interactive)
docker run -it --rm \
-v ~/.config/unarr:/config \
torrentclaw/unarr setup
# 3. Start daemon
docker run -d --name unarr \
--restart unless-stopped \
--network host \
--read-only \
--memory 512m \
-v ~/.config/unarr:/config \
-v ~/Media:/downloads \
torrentclaw/unarr
# Or use the provided docker-compose.yml:
# curl -fsSL https://raw.githubusercontent.com/torrentclaw/torrentclaw-cli/main/docker-compose.yml > docker-compose.yml
# docker compose up -d
DOCKER_USAGE
}
# ---- Uninstall ----
uninstall() {
info "Uninstalling $BINARY..."
# Remove binary
for dir in /usr/local/bin "$HOME/.local/bin" /usr/bin; do
if [ -f "$dir/$BINARY" ]; then
if [ -w "$dir" ]; then
rm -f "$dir/$BINARY"
else
sudo rm -f "$dir/$BINARY"
fi
ok "Removed $dir/$BINARY"
fi
done
# Remove Docker
if has docker; then
docker rm -f unarr 2>/dev/null && ok "Removed Docker container 'unarr'"
docker rmi torrentclaw/unarr:latest 2>/dev/null && ok "Removed Docker image"
fi
ok "Uninstalled. Config remains at ~/.config/unarr/ (delete manually if desired)."
exit 0
}
# ---- Interactive menu ----
interactive_menu() {
printf "\n"
printf " ${BOLD}unarr Installer${RESET}\n"
printf " ────────────────────────\n"
printf "\n"
printf " Detected: ${CYAN}$OS/$ARCH${RESET}\n"
printf "\n"
printf " Install method:\n"
printf "\n"
printf " ${BOLD}1)${RESET} Binary — standalone executable, no dependencies\n"
printf " ${BOLD}2)${RESET} Docker — sandboxed, isolated filesystem access ${GREEN}(recommended)${RESET}\n"
if [ "$OS" = "darwin" ] || has brew; then
printf " ${BOLD}3)${RESET} Homebrew — brew install torrentclaw/tap/unarr\n"
fi
printf " ${BOLD}u)${RESET} Uninstall\n"
printf "\n"
printf " Choice [1/2"
if [ "$OS" = "darwin" ] || has brew; then printf "/3"; fi
printf "]: "
read -r choice
case "$choice" in
1) METHOD="binary" ;;
2) METHOD="docker" ;;
3)
if [ "$OS" = "darwin" ] || has brew; then
METHOD="brew"
else
error "Invalid choice"
fi
;;
u|U) uninstall ;;
*) error "Invalid choice: $choice" ;;
esac
}
# ---- Main ----
main() {
detect_os
detect_arch
detect_install_dir
# Non-interactive if METHOD is set
if [ -z "$METHOD" ]; then
# If piped (non-interactive), default to binary
if [ ! -t 0 ]; then
METHOD="binary"
else
interactive_menu
fi
fi
printf "\n"
case "$METHOD" in
binary)
install_binary
printf "\n Run ${BOLD}unarr setup${RESET} to get started.\n\n"
;;
docker)
install_docker
;;
brew)
if ! has brew; then
error "Homebrew not found. Install it first: https://brew.sh"
fi
info "Installing via Homebrew..."
brew install torrentclaw/tap/unarr
ok "Installed via Homebrew"
printf "\n Run ${BOLD}unarr setup${RESET} to get started.\n\n"
;;
*)
error "Unknown method: $METHOD"
;;
esac
}
main "$@"

148
internal/agent/client.go Normal file
View file

@ -0,0 +1,148 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client communicates with the /api/internal/agent/* endpoints.
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
userAgent string
}
// NewClient creates an agent API client.
func NewClient(baseURL, apiKey, userAgent string) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
userAgent: userAgent,
}
}
// Register registers the CLI agent with the server and returns user info + features.
func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
var resp RegisterResponse
if err := c.doPost(ctx, "/api/internal/agent/register", req, &resp); err != nil {
return nil, fmt.Errorf("register: %w", err)
}
return &resp, nil
}
// Heartbeat sends a periodic keep-alive signal.
func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) error {
var resp StatusResponse
if err := c.doPost(ctx, "/api/internal/agent/heartbeat", req, &resp); err != nil {
return fmt.Errorf("heartbeat: %w", err)
}
return nil
}
// ClaimTasks polls for pending download tasks and claims them atomically.
func (c *Client) ClaimTasks(ctx context.Context, agentID string) ([]Task, error) {
url := fmt.Sprintf("/api/internal/agent/tasks?agentId=%s", agentID)
var resp TasksResponse
if err := c.doGet(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("claim tasks: %w", err)
}
return resp.Tasks, nil
}
// ReportStatus reports download progress or completion for a task.
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
var resp StatusResponse
if err := c.doPost(ctx, "/api/internal/agent/status", update, &resp); err != nil {
return nil, fmt.Errorf("report status: %w", err)
}
return &resp, nil
}
// doPost sends a JSON POST request and decodes the response.
func (c *Client) doPost(ctx context.Context, path string, body any, dst any) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return c.handleResponse(resp, dst)
}
// doGet sends a GET request and decodes the response.
func (c *Client) doGet(ctx context.Context, path string, dst any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return c.handleResponse(resp, dst)
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
}
func (c *Client) handleResponse(resp *http.Response, dst any) error {
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if resp.StatusCode >= 400 {
// Try to parse as JSON error
var errResp ErrorResponse
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
return fmt.Errorf("API error %d: %s", resp.StatusCode, errResp.Error)
}
// Non-JSON response (e.g. HTML error page) — truncate to something readable
msg := string(body)
if len(msg) > 120 || strings.Contains(msg, "<html") || strings.Contains(msg, "<!DOCTYPE") {
msg = fmt.Sprintf("server returned %s (non-JSON response, likely a server error)", resp.Status)
}
return fmt.Errorf("API error %d: %s", resp.StatusCode, msg)
}
if dst != nil {
if err := json.Unmarshal(body, dst); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}

View file

@ -0,0 +1,285 @@
package agent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestRegister(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/api/internal/agent/register" {
t.Errorf("path = %s, want /api/internal/agent/register", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("auth = %q, want Bearer test-key", r.Header.Get("Authorization"))
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("content-type = %q, want application/json", r.Header.Get("Content-Type"))
}
var req RegisterRequest
json.NewDecoder(r.Body).Decode(&req)
if req.AgentID != "agent-123" {
t.Errorf("agentId = %q, want agent-123", req.AgentID)
}
json.NewEncoder(w).Encode(RegisterResponse{
Success: true,
User: UserInfo{Name: "David", Email: "d@test.com", Plan: "pro", IsPro: true},
Features: FeatureFlags{
Debrid: true,
Usenet: false,
Torrent: true,
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.Register(context.Background(), RegisterRequest{
AgentID: "agent-123",
Name: "Test Machine",
OS: "linux",
Arch: "amd64",
Version: "0.2.0",
})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if !resp.Success {
t.Error("expected Success=true")
}
if resp.User.Name != "David" {
t.Errorf("user.name = %q, want David", resp.User.Name)
}
if !resp.User.IsPro {
t.Error("expected IsPro=true")
}
if !resp.Features.Debrid {
t.Error("expected debrid=true")
}
if !resp.Features.Torrent {
t.Error("expected torrent=true")
}
if resp.Features.Usenet {
t.Error("expected usenet=false")
}
}
func TestHeartbeat(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/heartbeat" {
t.Errorf("path = %s, want /api/internal/agent/heartbeat", r.URL.Path)
}
var req HeartbeatRequest
json.NewDecoder(r.Body).Decode(&req)
if req.AgentID != "agent-123" {
t.Errorf("agentId = %q, want agent-123", req.AgentID)
}
json.NewEncoder(w).Encode(StatusResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
err := c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "agent-123"})
if err != nil {
t.Fatalf("Heartbeat failed: %v", err)
}
}
func TestClaimTasks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Query().Get("agentId") != "agent-123" {
t.Errorf("agentId param = %q, want agent-123", r.URL.Query().Get("agentId"))
}
json.NewEncoder(w).Encode(TasksResponse{
Tasks: []Task{
{
ID: "task-uuid-1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "The Matrix (1999)",
PreferredMethod: "auto",
},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
tasks, err := c.ClaimTasks(context.Background(), "agent-123")
if err != nil {
t.Fatalf("ClaimTasks failed: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("len(tasks) = %d, want 1", len(tasks))
}
if tasks[0].ID != "task-uuid-1" {
t.Errorf("task.ID = %q, want task-uuid-1", tasks[0].ID)
}
if tasks[0].InfoHash != "abc123def456abc123def456abc123def456abc1" {
t.Errorf("task.InfoHash = %q", tasks[0].InfoHash)
}
if tasks[0].PreferredMethod != "auto" {
t.Errorf("task.PreferredMethod = %q, want auto", tasks[0].PreferredMethod)
}
}
func TestReportStatus(t *testing.T) {
var received StatusUpdate
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/status" {
t.Errorf("path = %s, want /api/internal/agent/status", r.URL.Path)
}
json.NewDecoder(r.Body).Decode(&received)
json.NewEncoder(w).Encode(StatusResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
_, err := c.ReportStatus(context.Background(), StatusUpdate{
TaskID: "task-uuid-1",
Status: "downloading",
Progress: 42,
DownloadedBytes: 1073741824,
TotalBytes: 2147483648,
SpeedBps: 5242880,
ETA: 120,
ResolvedMethod: "torrent",
FileName: "The.Matrix.1999.1080p.mkv",
})
if err != nil {
t.Fatalf("ReportStatus failed: %v", err)
}
if received.TaskID != "task-uuid-1" {
t.Errorf("taskId = %q, want task-uuid-1", received.TaskID)
}
if received.Progress != 42 {
t.Errorf("progress = %d, want 42", received.Progress)
}
if received.ResolvedMethod != "torrent" {
t.Errorf("resolvedMethod = %q, want torrent", received.ResolvedMethod)
}
}
func TestClaimTasksEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(TasksResponse{Tasks: []Task{}})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
tasks, err := c.ClaimTasks(context.Background(), "agent-123")
if err != nil {
t.Fatalf("ClaimTasks failed: %v", err)
}
if len(tasks) != 0 {
t.Errorf("expected empty tasks, got %d", len(tasks))
}
}
func TestAPIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid API key"})
}))
defer srv.Close()
c := NewClient(srv.URL, "bad-key", "unarr-test")
_, err := c.Register(context.Background(), RegisterRequest{AgentID: "x"})
if err == nil {
t.Fatal("expected error for 401 response")
}
if got := err.Error(); got == "" {
t.Error("error message should not be empty")
}
}
func TestAPIError404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(ErrorResponse{Error: "Task not found"})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
_, err := c.ReportStatus(context.Background(), StatusUpdate{TaskID: "missing"})
if err == nil {
t.Fatal("expected error for 404 response")
}
}
func TestReportStatusCancelled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(StatusResponse{Success: true, Cancelled: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.ReportStatus(context.Background(), StatusUpdate{TaskID: "task-1", Status: "downloading"})
if err != nil {
t.Fatalf("ReportStatus failed: %v", err)
}
if !resp.Cancelled {
t.Error("expected cancelled=true")
}
}
func TestReportStatusPaused(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(StatusResponse{Success: true, Paused: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.ReportStatus(context.Background(), StatusUpdate{TaskID: "task-1", Status: "downloading"})
if err != nil {
t.Fatalf("ReportStatus failed: %v", err)
}
if !resp.Paused {
t.Error("expected paused=true")
}
if resp.Cancelled {
t.Error("expected cancelled=false")
}
}
func TestReportStatusDeleteFiles(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(StatusResponse{Success: true, Cancelled: true, DeleteFiles: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.ReportStatus(context.Background(), StatusUpdate{TaskID: "task-1"})
if err != nil {
t.Fatalf("ReportStatus failed: %v", err)
}
if !resp.Cancelled {
t.Error("expected cancelled=true")
}
if !resp.DeleteFiles {
t.Error("expected deleteFiles=true")
}
}
func TestUserAgent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") != "unarr/0.2.0" {
t.Errorf("User-Agent = %q, want unarr/0.2.0", r.Header.Get("User-Agent"))
}
json.NewEncoder(w).Encode(StatusResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr/0.2.0")
c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "x"})
}

154
internal/agent/daemon.go Normal file
View file

@ -0,0 +1,154 @@
package agent
import (
"context"
"fmt"
"log"
"runtime"
"time"
)
// DaemonConfig holds daemon runtime settings.
type DaemonConfig struct {
AgentID string
AgentName string
Version string
DownloadDir string
PollInterval time.Duration
HeartbeatInterval time.Duration
}
// Daemon manages the main loop: register, heartbeat, poll tasks.
type Daemon struct {
cfg DaemonConfig
client *Client
// Callbacks
OnTasksClaimed func(tasks []Task)
// State
User UserInfo
Features FeatureFlags
Info AgentInfo
}
// NewDaemon creates a daemon with the given config and agent client.
func NewDaemon(cfg DaemonConfig, client *Client) *Daemon {
if cfg.PollInterval == 0 {
cfg.PollInterval = 30 * time.Second
}
if cfg.HeartbeatInterval == 0 {
cfg.HeartbeatInterval = 30 * time.Second
}
return &Daemon{
cfg: cfg,
client: client,
}
}
// Register registers the agent and fetches user info + features.
func (d *Daemon) Register(ctx context.Context) error {
req := RegisterRequest{
AgentID: d.cfg.AgentID,
Name: d.cfg.AgentName,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: d.cfg.Version,
DownloadDir: d.cfg.DownloadDir,
}
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
req.DiskTotalBytes = total
}
resp, err := d.client.Register(ctx, req)
if err != nil {
return fmt.Errorf("register: %w", err)
}
d.User = resp.User
d.Features = resp.Features
d.Info = AgentInfo{
ID: d.cfg.AgentID,
Name: d.cfg.AgentName,
User: resp.User,
Features: resp.Features,
StartedAt: time.Now(),
}
return nil
}
// Run starts the main daemon loop. Blocks until ctx is cancelled.
func (d *Daemon) Run(ctx context.Context) error {
// Register
if err := d.Register(ctx); err != nil {
return err
}
log.Printf("Agent registered: %s (%s) [%s]", d.User.Name, d.User.Email, d.User.Plan)
log.Printf("Features: torrent=%v debrid=%v usenet=%v", d.Features.Torrent, d.Features.Debrid, d.Features.Usenet)
log.Printf("Polling every %s, heartbeat every %s", d.cfg.PollInterval, d.cfg.HeartbeatInterval)
heartbeatTicker := time.NewTicker(d.cfg.HeartbeatInterval)
defer heartbeatTicker.Stop()
pollTicker := time.NewTicker(d.cfg.PollInterval)
defer pollTicker.Stop()
// Initial poll immediately
d.poll(ctx)
for {
select {
case <-ctx.Done():
log.Println("Daemon shutting down...")
return nil
case <-heartbeatTicker.C:
d.heartbeat(ctx)
case <-pollTicker.C:
d.poll(ctx)
}
}
}
func (d *Daemon) heartbeat(ctx context.Context) {
req := HeartbeatRequest{
AgentID: d.cfg.AgentID,
Name: d.cfg.AgentName,
Version: d.cfg.Version,
OS: runtime.GOOS,
DownloadDir: d.cfg.DownloadDir,
}
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
req.DiskTotalBytes = total
}
if err := d.client.Heartbeat(ctx, req); err != nil {
log.Printf("Heartbeat failed: %v", err)
}
}
func (d *Daemon) poll(ctx context.Context) {
tasks, err := d.client.ClaimTasks(ctx, d.cfg.AgentID)
if err != nil {
log.Printf("Poll failed: %v", err)
return
}
d.Info.LastPollAt = time.Now()
if len(tasks) == 0 {
return
}
log.Printf("Claimed %d task(s)", len(tasks))
if d.OnTasksClaimed != nil {
d.OnTasksClaimed(tasks)
}
}

View file

@ -0,0 +1,17 @@
//go:build !windows
package agent
import "syscall"
// DiskInfo returns free and total bytes for the filesystem containing path.
func DiskInfo(path string) (freeBytes, totalBytes int64, err error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, 0, err
}
// Available blocks * block size
freeBytes = int64(stat.Bavail) * int64(stat.Bsize)
totalBytes = int64(stat.Blocks) * int64(stat.Bsize)
return freeBytes, totalBytes, nil
}

View file

@ -0,0 +1,31 @@
//go:build windows
package agent
import (
"syscall"
"unsafe"
)
// DiskInfo returns free and total bytes for the filesystem containing path.
func DiskInfo(path string) (freeBytes, totalBytes int64, err error) {
kernel32 := syscall.NewLazyDLL("kernel32.dll")
getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW")
pathPtr, err := syscall.UTF16PtrFromString(path)
if err != nil {
return 0, 0, err
}
var freeBytesAvailable, totalNumberOfBytes uint64
r1, _, e1 := getDiskFreeSpaceEx.Call(
uintptr(unsafe.Pointer(pathPtr)),
uintptr(unsafe.Pointer(&freeBytesAvailable)),
uintptr(unsafe.Pointer(&totalNumberOfBytes)),
0,
)
if r1 == 0 {
return 0, 0, e1
}
return int64(freeBytesAvailable), int64(totalNumberOfBytes), nil
}

115
internal/agent/types.go Normal file
View file

@ -0,0 +1,115 @@
package agent
import "time"
// RegisterRequest is sent by the CLI on startup to register itself.
type RegisterRequest struct {
AgentID string `json:"agentId"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Version string `json:"version,omitempty"`
DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
}
// RegisterResponse is returned by the server after registration.
type RegisterResponse struct {
Success bool `json:"success"`
User UserInfo `json:"user"`
Features FeatureFlags `json:"features"`
}
// UserInfo holds the authenticated user's profile.
type UserInfo struct {
Name string `json:"name"`
Email string `json:"email"`
Plan string `json:"plan"`
IsPro bool `json:"isPro"`
}
// FeatureFlags indicates which download methods are available.
type FeatureFlags struct {
Debrid bool `json:"debrid"`
Usenet bool `json:"usenet"`
UsenetServer *UsenetServerInfo `json:"usenetServer,omitempty"`
Torrent bool `json:"torrent"`
}
// UsenetServerInfo holds NNTP connection details.
type UsenetServerInfo struct {
Host string `json:"host"`
Port int `json:"port"`
SSL bool `json:"ssl"`
}
// HeartbeatRequest is sent every 30s to keep the agent alive.
type HeartbeatRequest struct {
AgentID string `json:"agentId"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
}
// Task represents a download task claimed from the server.
type Task struct {
ID string `json:"id"`
InfoHash string `json:"infoHash"`
Title string `json:"title"`
ContentID *int `json:"contentId,omitempty"`
IMDbID string `json:"imdbId,omitempty"`
PreferredMethod string `json:"preferredMethod"` // auto | debrid | usenet | torrent
Mode string `json:"mode,omitempty"` // download | stream
}
// TasksResponse wraps the array of tasks returned by the server.
type TasksResponse struct {
Tasks []Task `json:"tasks"`
}
// StatusUpdate is sent by the CLI to report download progress.
type StatusUpdate struct {
TaskID string `json:"taskId"`
Status string `json:"status,omitempty"` // downloading | completed | failed
Progress int `json:"progress,omitempty"` // 0-100
DownloadedBytes int64 `json:"downloadedBytes,omitempty"`
TotalBytes int64 `json:"totalBytes,omitempty"`
SpeedBps int64 `json:"speedBps,omitempty"`
ETA int `json:"eta,omitempty"` // seconds remaining
ResolvedMethod string `json:"resolvedMethod,omitempty"`
FileName string `json:"fileName,omitempty"`
FilePath string `json:"filePath,omitempty"`
StreamURL string `json:"streamUrl,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
// StatusResponse is returned by the status endpoint.
// Includes flags the CLI must act on.
type StatusResponse struct {
Success bool `json:"success"`
Cancelled bool `json:"cancelled,omitempty"`
Paused bool `json:"paused,omitempty"`
DeleteFiles bool `json:"deleteFiles,omitempty"`
StreamRequested bool `json:"streamRequested,omitempty"`
}
// ErrorResponse is returned on API errors.
type ErrorResponse struct {
Error string `json:"error"`
Details any `json:"details,omitempty"`
}
// AgentInfo holds metadata about the running agent for display.
type AgentInfo struct {
ID string
Name string
User UserInfo
Features FeatureFlags
StartedAt time.Time
LastPollAt time.Time
ActiveTasks int
}

109
internal/cmd/config.go Normal file
View file

@ -0,0 +1,109 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
)
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Configure unarr",
Long: `Interactive setup for unarr.
Configures the API URL, API key, default country, and saves to config file.`,
Example: ` unarr config`,
RunE: func(cmd *cobra.Command, args []string) error {
return runConfig()
},
}
return cmd
}
func runConfig() error {
if !isTerminal() {
return fmt.Errorf("interactive config requires a terminal (use --api-key flag or env vars instead)")
}
reader := bufio.NewReader(os.Stdin)
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
cfg := loadConfig()
fmt.Println()
bold.Println(" unarr Configuration")
fmt.Println()
// API URL
currentURL := cfg.Auth.APIURL
fmt.Printf(" API URL [%s]: ", currentURL)
apiURL, _ := reader.ReadString('\n')
apiURL = strings.TrimSpace(apiURL)
if apiURL == "" {
apiURL = currentURL
}
// API Key
currentKey := cfg.Auth.APIKey
keyDisplay := ""
if currentKey != "" {
if len(currentKey) > 8 {
keyDisplay = currentKey[:8] + "..."
} else {
keyDisplay = currentKey
}
}
fmt.Printf(" API Key [%s]: ", keyDisplay)
apiKey, _ := reader.ReadString('\n')
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
apiKey = currentKey
}
// Country
currentCountry := cfg.General.Country
fmt.Printf(" Default country [%s]: ", currentCountry)
country, _ := reader.ReadString('\n')
country = strings.TrimSpace(country)
if country == "" {
country = currentCountry
}
// Apply changes
cfg.Auth.APIURL = apiURL
cfg.Auth.APIKey = apiKey
cfg.General.Country = country
// Save
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("could not save config: %w", err)
}
fmt.Println()
green.Printf(" Configuration saved to %s\n", configPath)
fmt.Println()
return nil
}
// isTerminal checks if stdin is a terminal.
func isTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}

280
internal/cmd/daemon.go Normal file
View file

@ -0,0 +1,280 @@
package cmd
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
)
// newStartCmd creates the top-level `unarr start` command.
func newStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start the download daemon (foreground)",
Long: `Start the unarr daemon in the foreground.
Registers with the server, polls for download tasks, and executes them
using the configured download method. Press Ctrl+C to stop gracefully.`,
Example: ` unarr start
unarr start --config /path/to/config.toml`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonStart()
},
}
}
// newStopCmd creates the top-level `unarr stop` placeholder.
func newStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the running daemon",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(" Use Ctrl+C in the terminal where the daemon is running.")
fmt.Println(" (Signal-based stop coming in a future release)")
return nil
},
}
}
// newDaemonCmd creates `unarr daemon` for administrative subcommands.
func newDaemonCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "daemon",
Short: "Daemon administration (install, uninstall, logs)",
Long: "Administrative commands for managing the daemon as a system service.",
}
cmd.AddCommand(
newDaemonInstallCmd(),
newDaemonUninstallCmd(),
)
return cmd
}
func newDaemonInstallCmd() *cobra.Command {
return &cobra.Command{
Use: "install",
Short: "Install daemon as a system service (systemd/launchd)",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(" Service installation coming in a future release.")
fmt.Println(" For now, use: unarr start")
return nil
},
}
}
func newDaemonUninstallCmd() *cobra.Command {
return &cobra.Command{
Use: "uninstall",
Short: "Remove daemon system service",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(" Service uninstall coming in a future release.")
return nil
},
}
}
func runDaemonStart() error {
cfg := loadConfig()
bold := color.New(color.Bold)
// Validate config
if cfg.Auth.APIKey == "" {
return fmt.Errorf("no API key configured — run 'unarr setup' first")
}
if cfg.Agent.ID == "" {
return fmt.Errorf("no agent ID — run 'unarr setup' first")
}
if cfg.Download.Dir == "" {
return fmt.Errorf("no download directory — run 'unarr setup' first")
}
// Validate configured paths are safe
if err := cfg.ValidatePaths(); err != nil {
return fmt.Errorf("unsafe configuration: %w", err)
}
// Ensure download dir exists
if err := os.MkdirAll(cfg.Download.Dir, 0o755); err != nil {
return fmt.Errorf("create download dir: %w", err)
}
fmt.Println()
bold.Println(" unarr Daemon")
fmt.Println()
// Parse intervals
pollInterval, _ := time.ParseDuration(cfg.Daemon.PollInterval)
if pollInterval == 0 {
pollInterval = 30 * time.Second
}
heartbeatInterval, _ := time.ParseDuration(cfg.Daemon.HeartbeatInterval)
if heartbeatInterval == 0 {
heartbeatInterval = 30 * time.Second
}
// Create agent client
ac := agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version)
// Create daemon
daemonCfg := agent.DaemonConfig{
AgentID: cfg.Agent.ID,
AgentName: cfg.Agent.Name,
Version: Version,
DownloadDir: cfg.Download.Dir,
PollInterval: pollInterval,
HeartbeatInterval: heartbeatInterval,
}
d := agent.NewDaemon(daemonCfg, ac)
// Create progress reporter
reporter := engine.NewProgressReporter(ac, 3*time.Second)
// Parse speed limits
maxDl, _ := config.ParseSpeed(cfg.Download.MaxDownloadSpeed)
maxUl, _ := config.ParseSpeed(cfg.Download.MaxUploadSpeed)
// Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
DataDir: cfg.Download.Dir,
StallTimeout: 90 * time.Second,
MaxTimeout: 30 * time.Minute,
MaxDownloadRate: maxDl,
MaxUploadRate: maxUl,
SeedEnabled: false,
})
if err != nil {
return fmt.Errorf("create torrent downloader: %w", err)
}
if maxDl > 0 || maxUl > 0 {
dlStr, ulStr := "unlimited", "unlimited"
if maxDl > 0 {
dlStr = formatSpeedLog(maxDl)
}
if maxUl > 0 {
ulStr = formatSpeedLog(maxUl)
}
log.Printf("Speed limits: download=%s upload=%s", dlStr, ulStr)
}
// Create download manager
manager := engine.NewManager(engine.ManagerConfig{
MaxConcurrent: cfg.Download.MaxConcurrent,
OutputDir: cfg.Download.Dir,
Notifications: cfg.Notifications.Enabled,
Organize: engine.OrganizeConfig{
Enabled: cfg.Organize.Enabled,
MoviesDir: cfg.Organize.MoviesDir,
TVShowsDir: cfg.Organize.TVShowsDir,
},
}, reporter, torrentDl)
// Wire: server-side signals -> manager actions + stream tasks
reporter.SetCancelHandler(func(taskID string) {
manager.CancelTask(taskID)
cancelStreamTask(taskID)
})
reporter.SetPauseHandler(func(taskID string) {
manager.PauseTask(taskID)
cancelStreamTask(taskID)
})
reporter.SetDeleteFilesHandler(func(taskID string) {
manager.CancelAndDeleteFiles(taskID)
cancelStreamTask(taskID)
})
// Wire: stream requested on active download → start HTTP server
reporter.SetStreamRequestedHandler(func(taskID string) {
task := manager.GetTask(taskID)
if task == nil {
log.Printf("[%s] stream requested but task not found in manager", taskID[:8])
return
}
if task.GetStreamURL() != "" {
return // already streaming
}
srv, err := torrentDl.StartStream(taskID)
if err != nil {
log.Printf("[%s] stream failed: %v", taskID[:8], err)
return
}
// Register server before setting URL to avoid TOCTOU race
streamRegistry.mu.Lock()
streamRegistry.servers[taskID] = srv
streamRegistry.mu.Unlock()
task.SetStreamURL(srv.URL())
})
// Wire: daemon claimed tasks -> manager
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
d.OnTasksClaimed = func(tasks []agent.Task) {
for _, t := range tasks {
if t.Mode == "stream" {
go handleStreamTask(ctx, t, reporter, cfg)
} else if manager.HasCapacity() {
manager.Submit(ctx, t)
} else {
log.Printf("[%s] skipped: no capacity (max %d)", t.ID[:8], cfg.Download.MaxConcurrent)
}
}
}
// Signal handling
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// Start progress reporter in background
go reporter.Run(ctx)
// Start daemon (blocks)
errCh := make(chan error, 1)
go func() {
errCh <- d.Run(ctx)
}()
// Wait for signal or error
select {
case sig := <-sigCh:
fmt.Printf("\n Received %s, shutting down...\n", sig)
cancel()
// Give active downloads 30s to finish
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
manager.Shutdown(shutdownCtx)
fmt.Println(" Daemon stopped.")
return nil
case err := <-errCh:
cancel()
return err
}
}
func formatSpeedLog(bps int64) string {
switch {
case bps >= 1024*1024*1024:
return fmt.Sprintf("%.1f GB/s", float64(bps)/(1024*1024*1024))
case bps >= 1024*1024:
return fmt.Sprintf("%.1f MB/s", float64(bps)/(1024*1024))
case bps >= 1024:
return fmt.Sprintf("%.0f KB/s", float64(bps)/1024)
default:
return fmt.Sprintf("%d B/s", bps)
}
}

211
internal/cmd/doctor.go Normal file
View file

@ -0,0 +1,211 @@
package cmd
import (
"context"
"fmt"
"os"
"runtime"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
)
func newDoctorCmd() *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: "Diagnose CLI configuration and connectivity",
Long: "Run diagnostic checks on API connectivity, config validity, disk space, and capabilities.",
RunE: func(cmd *cobra.Command, args []string) error {
return runDoctor()
},
}
}
func runDoctor() error {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
red := color.New(color.FgRed)
yellow := color.New(color.FgYellow)
fmt.Println()
bold.Println(" unarr Diagnostics")
fmt.Println()
pass := 0
fail := 0
warn := 0
check := func(name string, fn func() (string, error)) {
msg, err := fn()
if err != nil {
red.Printf(" x %s", name)
if msg != "" {
fmt.Printf(" — %s", msg)
}
fmt.Println()
fail++
} else if msg != "" && msg[0] == '!' {
yellow.Printf(" ! %s", name)
fmt.Printf(" — %s", msg[1:])
fmt.Println()
warn++
} else {
green.Printf(" + %s", name)
if msg != "" {
fmt.Printf(" — %s", msg)
}
fmt.Println()
pass++
}
}
// Config
bold.Println(" Config")
cfg := loadConfig()
check("Config file", func() (string, error) {
path := config.FilePath()
if cfgFile != "" {
path = cfgFile
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return path + " (not found, run unarr setup)", fmt.Errorf("missing")
}
return path, nil
})
check("API key configured", func() (string, error) {
key := apiKeyFlag
if key == "" {
key = cfg.Auth.APIKey
}
if key == "" {
return "run unarr setup to configure", fmt.Errorf("missing")
}
if len(key) > 8 {
return key[:8] + "...", nil
}
return "set", nil
})
fmt.Println()
bold.Println(" Connectivity")
// API connectivity
check("API reachable", func() (string, error) {
client := getClient()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
start := time.Now()
_, err := client.Health(ctx)
elapsed := time.Since(start)
if err != nil {
return cfg.Auth.APIURL, err
}
return fmt.Sprintf("%s (%dms)", cfg.Auth.APIURL, elapsed.Milliseconds()), nil
})
// Agent registration
check("Agent registration", func() (string, error) {
key := apiKeyFlag
if key == "" {
key = cfg.Auth.APIKey
}
if key == "" {
return "no API key", fmt.Errorf("skipped")
}
if cfg.Agent.ID == "" {
return "no agent ID, run unarr setup", fmt.Errorf("not registered")
}
ac := agent.NewClient(cfg.Auth.APIURL, key, "unarr/"+Version)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := ac.Register(ctx, agent.RegisterRequest{
AgentID: cfg.Agent.ID,
Name: cfg.Agent.Name,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: Version,
})
if err != nil {
return "", err
}
return fmt.Sprintf("%s (%s) [%s]", resp.User.Name, resp.User.Email, resp.User.Plan), nil
})
fmt.Println()
bold.Println(" Downloads")
check("Download directory", func() (string, error) {
dir := cfg.Download.Dir
if dir == "" {
return "not configured, run unarr setup", fmt.Errorf("missing")
}
fi, err := os.Stat(dir)
if os.IsNotExist(err) {
return dir + " (does not exist)", fmt.Errorf("missing")
}
if !fi.IsDir() {
return dir + " (not a directory)", fmt.Errorf("invalid")
}
return dir, nil
})
check("Download dir writable", func() (string, error) {
dir := cfg.Download.Dir
if dir == "" {
return "", fmt.Errorf("not configured")
}
tmpFile := dir + "/.unarr_write_test"
f, err := os.Create(tmpFile)
if err != nil {
return "", fmt.Errorf("not writable: %w", err)
}
f.Close()
os.Remove(tmpFile)
return "OK", nil
})
check("Disk space", func() (string, error) {
dir := cfg.Download.Dir
if dir == "" {
return "", fmt.Errorf("not configured")
}
var stat syscall.Statfs_t
if err := syscall.Statfs(dir, &stat); err != nil {
return "", err
}
available := int64(stat.Bavail) * int64(stat.Bsize)
gb := float64(available) / (1024 * 1024 * 1024)
msg := fmt.Sprintf("%.1f GB free", gb)
if gb < 10 {
return "!" + msg + " (low)", nil
}
return msg, nil
})
fmt.Println()
bold.Println(" Version")
check("unarr version", func() (string, error) {
return fmt.Sprintf("%s (%s/%s)", Version, runtime.GOOS, runtime.GOARCH), nil
})
// Summary
fmt.Println()
if fail == 0 && warn == 0 {
green.Println(" All checks passed!")
} else if fail == 0 {
yellow.Printf(" %d passed, %d warnings\n", pass, warn)
} else {
red.Printf(" %d passed, %d failed, %d warnings\n", pass, fail, warn)
}
fmt.Println()
return nil
}

149
internal/cmd/download.go Normal file
View file

@ -0,0 +1,149 @@
package cmd
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
"github.com/torrentclaw/torrentclaw-cli/internal/parser"
)
func newDownloadCmd() *cobra.Command {
var method string
cmd := &cobra.Command{
Use: "download <info_hash|magnet>",
Short: "Download a torrent (one-shot, no daemon needed)",
Long: `Download a specific torrent by info hash or magnet link.
This is a standalone download it does not require the daemon to be running.`,
Example: ` unarr download abc123def456abc123def456abc123def456abc1
unarr download "magnet:?xt=urn:btih:..." --method torrent`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runDownload(args[0], method)
},
}
cmd.Flags().StringVar(&method, "method", "torrent", "download method: torrent (default)")
return cmd
}
func runDownload(input, method string) error {
cfg := loadConfig()
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
// Parse input
parsed := parser.Parse(input)
infoHash := parsed.InfoHash
if infoHash == "" {
// Treat as info hash directly if 40 hex chars
input = strings.TrimSpace(input)
if len(input) == 40 {
infoHash = strings.ToLower(input)
} else {
return fmt.Errorf("invalid input: provide a 40-char info hash or magnet URI")
}
}
outputDir := cfg.Download.Dir
if outputDir == "" {
home, _ := os.UserHomeDir()
outputDir = home
}
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
fmt.Println()
bold.Printf(" Downloading %s...\n", infoHash[:16]+"...")
fmt.Printf(" Method: %s | Output: %s\n", method, outputDir)
fmt.Println()
// Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
DataDir: outputDir,
StallTimeout: 90 * time.Second,
MaxTimeout: 60 * time.Minute,
SeedEnabled: false,
})
if err != nil {
return fmt.Errorf("create downloader: %w", err)
}
// Create a dummy reporter (no API reporting for one-shot)
reporter := engine.NewProgressReporter(
agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
5*time.Second,
)
manager := engine.NewManager(engine.ManagerConfig{
MaxConcurrent: 1,
OutputDir: outputDir,
Organize: engine.OrganizeConfig{
Enabled: cfg.Organize.Enabled,
MoviesDir: cfg.Organize.MoviesDir,
TVShowsDir: cfg.Organize.TVShowsDir,
},
}, reporter, torrentDl)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Signal handling
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Println("\n Cancelling download...")
cancel()
}()
// Start progress reporter
go reporter.Run(ctx)
// Submit task
task := agent.Task{
ID: "oneshot-" + infoHash[:8],
InfoHash: infoHash,
Title: parsed.Name,
PreferredMethod: method,
}
manager.Submit(ctx, task)
manager.Wait()
// Check result
active := manager.ActiveTasks()
if len(active) == 0 {
green.Println(" Download complete!")
} else {
for _, t := range active {
if t.GetStatus() == engine.StatusFailed {
return fmt.Errorf("download failed: %s", t.ErrorMessage)
}
}
}
// Shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
manager.Shutdown(shutdownCtx)
cancel()
log.SetOutput(os.Stderr) // suppress cleanup logs
fmt.Println()
return nil
}

102
internal/cmd/inspect.go Normal file
View file

@ -0,0 +1,102 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/parser"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newInspectCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "inspect <magnet|hash|name>",
Short: "Inspect a torrent — TrueSpec analysis",
Long: `Analyze a torrent by magnet URI, info hash, or name.
Parses the torrent metadata (quality, codec, language, year), queries unarr
for enriched data, and shows a detailed TrueSpec report including quality score,
seed health, and available alternatives.`,
Example: ` unarr inspect "magnet:?xt=urn:btih:ABC123&dn=Movie.2023.1080p"
unarr inspect abc123def456... (40-char info hash)
unarr inspect "Oppenheimer.2023.1080p.BluRay.x265"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
input := args[0]
parsed := parser.Parse(input)
client := getClient()
ctx := context.Background()
// Determine search query
searchQuery := parsed.Name
if searchQuery == "" && parsed.InfoHash != "" {
searchQuery = parsed.InfoHash
}
if searchQuery == "" {
return fmt.Errorf("could not extract a name or hash from input")
}
// Clean the name for searching
cleanQuery := parser.ExtractSearchQuery(searchQuery)
if cleanQuery == "" {
cleanQuery = searchQuery
}
// Search for enriched data
params := tc.SearchParams{
Query: cleanQuery,
Quality: parsed.Quality,
}
resp, err := client.Search(ctx, params)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
// Find matching result
if len(resp.Results) == 0 {
if jsonOut {
return json.NewEncoder(os.Stdout).Encode(map[string]any{
"parsed": parsed,
"found": false,
})
}
ui.PrintInspect(searchQuery, parsed.Year, nil, magnetURI(input, parsed))
return nil
}
result := resp.Results[0]
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"parsed": parsed,
"found": true,
"content": result,
})
}
year := ui.FormatYear(result.Year)
ui.PrintInspect(result.Title, year, result.Torrents, magnetURI(input, parsed))
return nil
},
}
return cmd
}
func magnetURI(input string, parsed parser.ParsedTorrent) string {
if parsed.IsMagnet {
return input
}
if parsed.InfoHash != "" {
return fmt.Sprintf("magnet:?xt=urn:btih:%s", parsed.InfoHash)
}
return ""
}

51
internal/cmd/popular.go Normal file
View file

@ -0,0 +1,51 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newPopularCmd() *cobra.Command {
var (
limit int
page int
)
cmd := &cobra.Command{
Use: "popular",
Short: "Show popular content",
Long: "Display the most popular movies and TV shows, ranked by community engagement.",
Example: ` unarr popular
unarr popular --limit 20
unarr popular --page 2 --json`,
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
resp, err := client.Popular(context.Background(), tc.PopularParams{Limit: limit, Page: page})
if err != nil {
return fmt.Errorf("failed to fetch popular content: %w", err)
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
ui.PrintPopularItems(resp.Items)
return nil
},
}
cmd.Flags().IntVar(&limit, "limit", 10, "number of results")
cmd.Flags().IntVar(&page, "page", 0, "page number")
return cmd
}

51
internal/cmd/recent.go Normal file
View file

@ -0,0 +1,51 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newRecentCmd() *cobra.Command {
var (
limit int
page int
)
cmd := &cobra.Command{
Use: "recent",
Short: "Show recently added content",
Long: "Display the most recently added movies and TV shows to the catalog.",
Example: ` unarr recent
unarr recent --limit 20
unarr recent --page 2 --json`,
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
resp, err := client.Recent(context.Background(), tc.RecentParams{Limit: limit, Page: page})
if err != nil {
return fmt.Errorf("failed to fetch recent content: %w", err)
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
ui.PrintRecentItems(resp.Items)
return nil
},
}
cmd.Flags().IntVar(&limit, "limit", 10, "number of results")
cmd.Flags().IntVar(&page, "page", 0, "page number")
return cmd
}

126
internal/cmd/root.go Normal file
View file

@ -0,0 +1,126 @@
package cmd
import (
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
tc "github.com/torrentclaw/go-client"
)
var (
cfgFile string
apiKeyFlag string
jsonOut bool
noColor bool
rootCmd *cobra.Command
apiClient *tc.Client
appCfg config.Config
cfgLoaded bool
)
func init() {
rootCmd = &cobra.Command{
Use: "unarr",
Short: "unarr — torrent search and management",
Long: `unarr is a powerful terminal tool for torrent search and management.
Search 30+ torrent sources, inspect torrent quality, discover popular content,
find streaming providers, and manage your media collection all from your terminal.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if noColor || os.Getenv("NO_COLOR") != "" {
color.NoColor = true
}
},
SilenceUsage: true,
SilenceErrors: true,
}
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default ~/.config/unarr/config.toml)")
rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "API key (overrides config file and env)")
rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "output as JSON (for piping)")
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output")
rootCmd.AddCommand(
newSetupCmd(),
newStartCmd(),
newStopCmd(),
newDaemonCmd(),
newDownloadCmd(),
newStatusCmd(),
newSearchCmd(),
newInspectCmd(),
newPopularCmd(),
newRecentCmd(),
newStatsCmd(),
newWatchCmd(),
newConfigCmd(),
newDoctorCmd(),
newVersionCmd(),
// Stubs for future commands
newStubCmd("upgrade", "Find a better version of a torrent"),
newStubCmd("moreseed", "Find same quality with more seeders"),
newStubCmd("compare", "Compare two torrents side by side"),
newStubCmd("scan", "Scan your media library for upgrades"),
newStreamCmd(),
newStubCmd("add", "Search and add torrents to your client"),
newStubCmd("monitor", "Watch for new episodes of a series"),
newStubCmd("open", "Open content in the browser"),
)
}
// Execute runs the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, color.RedString("Error: %s", err))
os.Exit(1)
}
}
// loadConfig loads config once (lazy initialization).
func loadConfig() config.Config {
if cfgLoaded {
return appCfg
}
var err error
appCfg, err = config.Load(cfgFile)
if err != nil {
fmt.Fprintln(os.Stderr, color.YellowString("Warning: config load failed: %s", err))
appCfg = config.Default()
}
appCfg.ApplyEnvOverrides()
cfgLoaded = true
return appCfg
}
// getClient returns a configured API client, initializing it on first use.
func getClient() *tc.Client {
if apiClient != nil {
return apiClient
}
cfg := loadConfig()
var opts []tc.Option
if cfg.Auth.APIURL != "" {
opts = append(opts, tc.WithBaseURL(cfg.Auth.APIURL))
}
apiKey := apiKeyFlag
if apiKey == "" {
apiKey = cfg.Auth.APIKey
}
if apiKey != "" {
opts = append(opts, tc.WithAPIKey(apiKey))
}
opts = append(opts, tc.WithUserAgent("unarr/"+Version))
apiClient = tc.NewClient(opts...)
return apiClient
}

89
internal/cmd/search.go Normal file
View file

@ -0,0 +1,89 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newSearchCmd() *cobra.Command {
var (
contentType string
quality string
lang string
genre string
yearMin int
yearMax int
minRating float64
sort string
limit int
page int
country string
)
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search for movies and TV shows",
Long: `Search the catalog with advanced filters.
Results include torrent quality scores, seed health, and metadata from 30+ sources.`,
Example: ` unarr search "breaking bad" --type show --quality 1080p
unarr search "oppenheimer" --sort seeders --limit 5
unarr search "inception" --lang es --min-rating 7
unarr search "matrix" --json | jq '.results[].title'`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
params := tc.SearchParams{
Query: strings.Join(args, " "),
Type: contentType,
Quality: quality,
Language: lang,
Genre: genre,
YearMin: yearMin,
YearMax: yearMax,
MinRating: minRating,
Sort: sort,
Limit: limit,
Page: page,
Country: country,
}
resp, err := client.Search(context.Background(), params)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
ui.PrintSearchResults(resp)
return nil
},
}
cmd.Flags().StringVar(&contentType, "type", "", "content type: movie, show")
cmd.Flags().StringVar(&quality, "quality", "", "video quality: 480p, 720p, 1080p, 2160p")
cmd.Flags().StringVar(&lang, "lang", "", "audio language (ISO 639 code, e.g. es, en)")
cmd.Flags().StringVar(&genre, "genre", "", "genre filter (e.g. Action, Comedy, Drama)")
cmd.Flags().IntVar(&yearMin, "year-min", 0, "minimum release year")
cmd.Flags().IntVar(&yearMax, "year-max", 0, "maximum release year")
cmd.Flags().Float64Var(&minRating, "min-rating", 0, "minimum IMDb/TMDb rating (0-10)")
cmd.Flags().StringVar(&sort, "sort", "", "sort order: relevance, seeders, year, rating, added")
cmd.Flags().IntVar(&limit, "limit", 0, "results per page (1-50)")
cmd.Flags().IntVar(&page, "page", 0, "page number")
cmd.Flags().StringVar(&country, "country", "", "country code for streaming availability (e.g. US, ES)")
return cmd
}

281
internal/cmd/setup.go Normal file
View file

@ -0,0 +1,281 @@
package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/charmbracelet/huh"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
)
func newSetupCmd() *cobra.Command {
var apiURL string
cmd := &cobra.Command{
Use: "setup",
Short: "First-time configuration wizard",
Long: "Interactive setup that configures API key, download directory, and preferred download method.",
RunE: func(cmd *cobra.Command, args []string) error {
return runSetup(apiURL)
},
}
cmd.Flags().StringVar(&apiURL, "api-url", "", "API URL override (default: https://torrentclaw.com)")
return cmd
}
func runSetup(apiURLOverride string) error {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
cyan := color.New(color.FgCyan)
fmt.Println()
bold.Println(" unarr Setup")
fmt.Println()
cfg := loadConfig()
// Determine API URL
apiURL := cfg.Auth.APIURL
if apiURLOverride != "" {
apiURL = apiURLOverride
}
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
// Open browser to API keys page
keysURL := apiURL + "/profile?tab=apikey"
fmt.Printf(" Opening %s ...\n", keysURL)
openBrowser(keysURL)
fmt.Println()
// Step 1: API Key
apiKey := cfg.Auth.APIKey
err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("API Key").
Description("Copy it from the page that just opened in your browser").
Placeholder("tc_...").
Value(&apiKey).
Validate(func(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return fmt.Errorf("API key is required")
}
if !strings.HasPrefix(s, "tc_") {
return fmt.Errorf("API key should start with tc_")
}
return nil
}),
),
).Run()
if err != nil {
return err
}
apiKey = strings.TrimSpace(apiKey)
// Validate API key by registering with the server
fmt.Print(" Verifying API key... ")
agentID := cfg.Agent.ID
if agentID == "" {
agentID = uuid.New().String()
}
hostname, _ := os.Hostname()
agentName := cfg.Agent.Name
if agentName == "" {
agentName = hostname
}
ac := agent.NewClient(apiURL, apiKey, "unarr/"+Version)
resp, err := ac.Register(context.Background(), agent.RegisterRequest{
AgentID: agentID,
Name: agentName,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: Version,
DownloadDir: cfg.Download.Dir,
})
if err != nil {
color.Red("FAILED")
fmt.Println()
return fmt.Errorf("API key validation failed: %w", err)
}
green.Println("OK")
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Println()
// Step 2: Download directory
downloadDir := cfg.Download.Dir
if downloadDir == "" {
downloadDir = defaultDownloadDir()
}
err = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Download Directory").
Description("Where should downloaded files be saved?").
Value(&downloadDir),
),
).Run()
if err != nil {
return err
}
downloadDir = expandHome(strings.TrimSpace(downloadDir))
// Step 3: Preferred download method
method := cfg.Download.PreferredMethod
if method == "" {
method = "auto"
}
methodOptions := []huh.Option[string]{
huh.NewOption("Auto (torrent, debrid when available)", "auto"),
huh.NewOption("Torrent only (BitTorrent P2P)", "torrent"),
}
if resp.Features.Debrid {
methodOptions = append(methodOptions,
huh.NewOption("Debrid only (Real-Debrid, AllDebrid...)", "debrid"),
)
}
if resp.Features.Usenet {
methodOptions = append(methodOptions,
huh.NewOption("Usenet only (requires Pro)", "usenet"),
)
}
err = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Download Method").
Description("How do you want to download?").
Options(methodOptions...).
Value(&method),
),
).Run()
if err != nil {
return err
}
// Step 4: Agent name
err = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Device Name").
Description("A name for this machine (shown in the web dashboard)").
Value(&agentName),
),
).Run()
if err != nil {
return err
}
// Save config
cfg.Auth.APIKey = apiKey
cfg.Auth.APIURL = apiURL
cfg.Agent.ID = agentID
cfg.Agent.Name = strings.TrimSpace(agentName)
cfg.Download.Dir = downloadDir
cfg.Download.PreferredMethod = method
// Set organize dirs based on download dir
if cfg.Organize.MoviesDir == "" {
cfg.Organize.MoviesDir = filepath.Join(downloadDir, "Movies")
}
if cfg.Organize.TVShowsDir == "" {
cfg.Organize.TVShowsDir = filepath.Join(downloadDir, "TV Shows")
}
// Validate paths before saving
if err := cfg.ValidatePaths(); err != nil {
return fmt.Errorf("unsafe configuration: %w", err)
}
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
// Summary
fmt.Println()
green.Println(" Setup complete!")
fmt.Println()
fmt.Printf(" User: %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Printf(" Downloads: %s\n", downloadDir)
fmt.Printf(" Method: %s\n", method)
fmt.Printf(" Agent: %s (%s)\n", agentName, agentID[:8]+"...")
fmt.Printf(" Config: %s\n", configPath)
fmt.Println()
// Features summary
features := []string{}
if resp.Features.Torrent {
features = append(features, "Torrent")
}
if resp.Features.Debrid {
features = append(features, "Debrid")
}
if resp.Features.Usenet {
features = append(features, "Usenet")
}
cyan.Printf(" Available: %s\n", strings.Join(features, ", "))
fmt.Println()
fmt.Println(" Next: run", bold.Sprint("unarr daemon start"), "to begin downloading")
fmt.Println()
return nil
}
// openBrowser opens a URL in the default browser.
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default: // linux, freebsd
cmd = exec.Command("xdg-open", url)
}
cmd.Start() // fire and forget
}
func defaultDownloadDir() string {
home, _ := os.UserHomeDir()
candidates := []string{
filepath.Join(home, "Media"),
filepath.Join(home, "Downloads", "unarr"),
}
for _, d := range candidates {
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
return d
}
}
return filepath.Join(home, "Media")
}
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, path[2:])
}
return path
}

40
internal/cmd/stats.go Normal file
View file

@ -0,0 +1,40 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newStatsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "stats",
Short: "Show system statistics",
Long: "Display aggregator statistics including content counts, torrent sources, and recent ingestion history.",
Example: ` unarr stats`,
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
resp, err := client.Stats(context.Background())
if err != nil {
return fmt.Errorf("failed to fetch stats: %w", err)
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
ui.PrintStats(resp)
return nil
},
}
return cmd
}

47
internal/cmd/status.go Normal file
View file

@ -0,0 +1,47 @@
package cmd
import (
"fmt"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show daemon status and active downloads",
Long: "Display the current state of the daemon, active downloads, and recent activity.",
RunE: func(cmd *cobra.Command, args []string) error {
return runStatus()
},
}
}
func runStatus() error {
bold := color.New(color.Bold)
dim := color.New(color.FgHiBlack)
fmt.Println()
bold.Printf(" unarr %s\n", Version)
fmt.Println()
cfg := loadConfig()
if cfg.Auth.APIKey == "" {
dim.Println(" Not configured. Run 'unarr setup' first.")
fmt.Println()
return nil
}
fmt.Printf(" Agent: %s (%s)\n", cfg.Agent.Name, cfg.Agent.ID[:8]+"...")
fmt.Printf(" Downloads: %s\n", cfg.Download.Dir)
fmt.Printf(" Method: %s\n", cfg.Download.PreferredMethod)
fmt.Println()
dim.Println(" Daemon not running. Start with 'unarr daemon start'")
dim.Println(" (Live status will be shown here when daemon is running)")
fmt.Println()
return nil
}

206
internal/cmd/stream.go Normal file
View file

@ -0,0 +1,206 @@
package cmd
import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
"github.com/torrentclaw/torrentclaw-cli/internal/parser"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newStreamCmd() *cobra.Command {
var (
port int
noOpen bool
playerCmd string
)
cmd := &cobra.Command{
Use: "stream <magnet|infohash>",
Short: "Stream a torrent directly to a media player",
Long: `Stream a torrent by info hash or magnet link.
Downloads sequentially and serves the video over HTTP.
Automatically opens mpv, vlc, or your browser.`,
Example: ` unarr stream abc123def456abc123def456abc123def456abc1
unarr stream "magnet:?xt=urn:btih:..." --port 8080
unarr stream <hash> --player mpv
unarr stream <hash> --no-open`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runStream(args[0], port, noOpen, playerCmd)
},
}
cmd.Flags().IntVar(&port, "port", 0, "HTTP server port (default: random available)")
cmd.Flags().BoolVar(&noOpen, "no-open", false, "don't open a player, just print the URL")
cmd.Flags().StringVar(&playerCmd, "player", "", "media player command (default: auto-detect)")
return cmd
}
func runStream(input string, port int, noOpen bool, playerCmd string) error {
cfg := loadConfig()
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
dim := color.New(color.FgHiBlack)
// Parse input
parsed := parser.Parse(input)
magnetOrHash := input
if parsed.InfoHash != "" && !parsed.IsMagnet {
magnetOrHash = parsed.InfoHash
} else if parsed.InfoHash == "" {
trimmed := strings.TrimSpace(input)
if len(trimmed) == 40 {
magnetOrHash = strings.ToLower(trimmed)
} else if !strings.HasPrefix(trimmed, "magnet:") {
return fmt.Errorf("invalid input: provide a 40-char info hash or magnet URI")
}
}
// Data directory
dataDir := cfg.Download.Dir
if dataDir == "" {
dataDir = filepath.Join(os.TempDir(), "unarr-stream")
}
// Create engine
eng, err := engine.NewStreamEngine(engine.StreamConfig{
DataDir: dataDir,
Port: port,
MetaTimeout: 60 * time.Second,
NoOpen: noOpen,
PlayerCmd: playerCmd,
})
if err != nil {
return fmt.Errorf("create stream engine: %w", err)
}
// Signal handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Println("\n Shutting down...")
cancel()
}()
// Header
fmt.Println()
bold.Println(" unarr Stream")
fmt.Println()
// Start engine (metadata + file selection)
dim.Println(" Waiting for metadata...")
if err := eng.Start(ctx, magnetOrHash); err != nil {
eng.Shutdown(context.Background())
return err
}
fileName := eng.FileName()
fileSize := eng.FileLength()
bold.Printf(" File: %s (%s)\n", fileName, ui.FormatBytes(fileSize))
if !eng.IsVideoFile() {
yellow.Println(" Warning: no video files found, streaming largest file")
}
// Start HTTP server
srv := engine.NewStreamServer(eng, port)
streamURL, err := srv.Start(ctx)
if err != nil {
eng.Shutdown(context.Background())
return fmt.Errorf("start server: %w", err)
}
fmt.Printf(" URL: %s\n", streamURL)
fmt.Println()
// Buffer before opening player
dim.Print(" Buffering...")
err = eng.WaitBuffer(ctx, func(buffered, target int64) {
pct := int(float64(buffered) / float64(target) * 100)
if pct > 100 {
pct = 100
}
fmt.Printf("\r Buffering: %d%% (%s / %s) ",
pct, ui.FormatBytes(buffered), ui.FormatBytes(target))
})
if err != nil {
srv.Shutdown(context.Background())
eng.Shutdown(context.Background())
return err
}
fmt.Println()
// Start progress tracking
eng.StartProgressLoop(ctx)
// Open player
if !noOpen {
playerName, _, openErr := engine.OpenPlayer(streamURL, playerCmd)
if openErr != nil {
yellow.Printf(" Could not open player: %s\n", openErr)
fmt.Printf(" Open this URL in your player: %s\n", streamURL)
} else {
green.Printf(" Opened in %s\n", playerName)
}
} else {
fmt.Printf(" Open this URL in your player: %s\n", streamURL)
}
fmt.Println()
// Progress loop until Ctrl+C
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
completed := false
for {
select {
case <-ctx.Done():
goto shutdown
case <-ticker.C:
p := eng.Progress()
pct := 0
if p.TotalBytes > 0 {
pct = int(float64(p.DownloadedBytes) / float64(p.TotalBytes) * 100)
}
fmt.Printf("\r %d%% | %s/s | Peers: %d | Seeds: %d ",
pct, ui.FormatBytes(p.SpeedBps), p.Peers, p.Seeds)
if pct >= 100 && !completed {
completed = true
fmt.Println()
green.Println(" Download complete! Stream server still running. Ctrl+C to exit.")
}
}
}
shutdown:
fmt.Println()
fmt.Println()
dim.Println(" Cleaning up...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
eng.Shutdown(shutdownCtx)
fmt.Println(" Done.")
fmt.Println()
return nil
}

View file

@ -0,0 +1,140 @@
package cmd
import (
"context"
"log"
"sync"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
// streamRegistry tracks active stream tasks and servers for cancellation.
var streamRegistry = struct {
mu sync.Mutex
cancels map[string]context.CancelFunc
servers map[string]*engine.StreamServer // servers for active download streams
}{
cancels: make(map[string]context.CancelFunc),
servers: make(map[string]*engine.StreamServer),
}
// cancelStreamTask cancels a running stream task and shuts down any stream server.
func cancelStreamTask(taskID string) {
streamRegistry.mu.Lock()
if cancel, ok := streamRegistry.cancels[taskID]; ok {
cancel()
delete(streamRegistry.cancels, taskID)
}
if srv, ok := streamRegistry.servers[taskID]; ok {
srv.Shutdown(context.Background())
delete(streamRegistry.servers, taskID)
}
streamRegistry.mu.Unlock()
}
// handleStreamTask manages a streaming task lifecycle outside the Manager.
// It creates a StreamEngine, buffers, starts an HTTP server, and reports
// progress until the task is cancelled or the download completes.
func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine.ProgressReporter, cfg config.Config) {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
// Register for web-initiated cancellation
streamRegistry.mu.Lock()
streamRegistry.cancels[at.ID] = cancel
streamRegistry.mu.Unlock()
defer func() {
streamRegistry.mu.Lock()
delete(streamRegistry.cancels, at.ID)
streamRegistry.mu.Unlock()
}()
task := engine.NewTaskFromAgent(at)
task.ResolvedMethod = engine.MethodTorrent
reporter.Track(task)
defer reporter.ReportFinal(context.Background(), task)
// 1. Create StreamEngine
eng, err := engine.NewStreamEngine(engine.StreamConfig{
DataDir: cfg.Download.Dir,
MetaTimeout: 60 * time.Second,
})
if err != nil {
task.ErrorMessage = "create stream engine: " + err.Error()
task.Transition(engine.StatusFailed)
return
}
defer eng.Shutdown(context.Background())
// 2. Wait for metadata + select file
task.Transition(engine.StatusResolving)
if err := eng.Start(ctx, at.InfoHash); err != nil {
task.ErrorMessage = err.Error()
task.Transition(engine.StatusFailed)
return
}
task.FileName = eng.FileName()
task.TotalBytes = eng.FileLength()
task.Transition(engine.StatusDownloading)
log.Printf("[%s] stream: %s (%s)", at.ID[:8], eng.FileName(), ui.FormatBytes(eng.FileLength()))
// 3. Buffer initial data
if err := eng.WaitBuffer(ctx, nil); err != nil {
task.ErrorMessage = "buffering failed: " + err.Error()
task.Transition(engine.StatusFailed)
return
}
// 4. Start HTTP server
srv := engine.NewStreamServer(eng, 0)
streamURL, err := srv.Start(ctx)
if err != nil {
task.ErrorMessage = "start HTTP server: " + err.Error()
task.Transition(engine.StatusFailed)
return
}
defer srv.Shutdown(context.Background())
// 5. Report stream URL — the reporter will send this to the web
task.StreamURL = streamURL
log.Printf("[%s] stream ready: %s", at.ID[:8], streamURL)
// 6. Progress loop
eng.StartProgressLoop(ctx)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Printf("[%s] stream stopped", at.ID[:8])
return
case <-ticker.C:
p := eng.Progress()
task.UpdateProgress(engine.Progress{
DownloadedBytes: p.DownloadedBytes,
TotalBytes: p.TotalBytes,
SpeedBps: p.SpeedBps,
Peers: p.Peers,
Seeds: p.Seeds,
FileName: p.FileName,
})
if p.DownloadedBytes >= p.TotalBytes && p.TotalBytes > 0 {
task.Transition(engine.StatusCompleted)
log.Printf("[%s] stream download complete, server stays up until cancelled", at.ID[:8])
// Don't return — keep HTTP server running so the player
// can finish reading. The stream stops when the user
// cancels from the web or the daemon shuts down.
<-ctx.Done()
return
}
}
}
}

22
internal/cmd/stubs.go Normal file
View file

@ -0,0 +1,22 @@
package cmd
import (
"fmt"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
func newStubCmd(name, short string) *cobra.Command {
return &cobra.Command{
Use: name,
Short: short + " (coming soon)",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println()
color.New(color.FgYellow).Printf(" ⚠️ '%s' is coming in a future release.\n", name)
fmt.Println()
fmt.Println(" Follow progress at: https://github.com/torrentclaw/torrentclaw-cli")
fmt.Println()
},
}
}

4
internal/cmd/version.go Normal file
View file

@ -0,0 +1,4 @@
package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.2.0-dev"

View file

@ -0,0 +1,20 @@
package cmd
import (
"fmt"
"runtime"
"github.com/spf13/cobra"
)
func newVersionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Show unarr version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("unarr %s (%s/%s)\n", Version, runtime.GOOS, runtime.GOARCH)
},
}
return cmd
}

80
internal/cmd/watch.go Normal file
View file

@ -0,0 +1,80 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newWatchCmd() *cobra.Command {
var country string
cmd := &cobra.Command{
Use: "watch <query>",
Short: "Find where to watch — streaming + torrents",
Long: `Search for content and show streaming availability alongside torrent options.
Shows legal streaming options first (subscription, free, rent, buy),
then torrent alternatives below. Helps you decide the best way to watch.`,
Example: ` unarr watch "oppenheimer"
unarr watch "breaking bad" --country ES
unarr watch "inception" --json`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
ctx := context.Background()
if country == "" {
country = loadConfig().General.Country
}
// Search for the content with country for streaming info
resp, err := client.Search(ctx, tc.SearchParams{
Query: strings.Join(args, " "),
Limit: 1,
Country: country,
})
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(resp.Results) == 0 {
fmt.Println("No results found.")
return nil
}
result := resp.Results[0]
// Fetch watch providers
providers, err := client.WatchProviders(ctx, result.ID, country)
if err != nil {
// Non-fatal: we can still show torrent results
providers = nil
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"content": result,
"providers": providers,
})
}
year := ui.FormatYear(result.Year)
ui.PrintWatchProviders(result.Title, year, providers, result.Torrents)
return nil
},
}
cmd.Flags().StringVar(&country, "country", "", "country code for streaming availability (e.g. US, ES)")
return cmd
}

288
internal/config/config.go Normal file
View file

@ -0,0 +1,288 @@
package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/BurntSushi/toml"
)
// Config holds all persistent CLI configuration.
type Config struct {
Auth AuthConfig `toml:"auth"`
Agent AgentConfig `toml:"agent"`
Download DownloadConfig `toml:"downloads"`
Organize OrganizeConfig `toml:"organize"`
Daemon DaemonConfig `toml:"daemon"`
Notifications NotificationsConfig `toml:"notifications"`
General GeneralConfig `toml:"general"`
}
type AuthConfig struct {
APIKey string `toml:"api_key"`
APIURL string `toml:"api_url"`
}
type AgentConfig struct {
ID string `toml:"id"`
Name string `toml:"name"`
}
type DownloadConfig struct {
Dir string `toml:"dir"`
PreferredMethod string `toml:"preferred_method"`
MaxConcurrent int `toml:"max_concurrent"`
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
}
type OrganizeConfig struct {
Enabled bool `toml:"enabled"`
MoviesDir string `toml:"movies_dir"`
TVShowsDir string `toml:"tv_shows_dir"`
}
type DaemonConfig struct {
PollInterval string `toml:"poll_interval"`
HeartbeatInterval string `toml:"heartbeat_interval"`
}
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
}
type GeneralConfig struct {
Country string `toml:"country"`
Locale string `toml:"locale"`
NoColor bool `toml:"no_color"`
}
// Default returns a Config with sensible defaults.
func Default() Config {
return Config{
Auth: AuthConfig{
APIURL: "https://torrentclaw.com",
},
Download: DownloadConfig{
PreferredMethod: "auto",
MaxConcurrent: 3,
},
Organize: OrganizeConfig{
Enabled: true,
},
Daemon: DaemonConfig{
PollInterval: "30s",
HeartbeatInterval: "30s",
},
Notifications: NotificationsConfig{
Enabled: true,
},
General: GeneralConfig{
Country: "US",
Locale: "en",
},
}
}
// Load reads config from the default or specified path.
// Falls back to defaults for any missing values.
// If the file does not exist, returns defaults without error.
func Load(path string) (Config, error) {
if path == "" {
path = FilePath()
}
cfg := Default()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, fmt.Errorf("read config: %w", err)
}
if err := toml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse config: %w", err)
}
// Re-apply defaults for zero values that should have defaults
if cfg.Auth.APIURL == "" {
cfg.Auth.APIURL = "https://torrentclaw.com"
}
if cfg.Download.PreferredMethod == "" {
cfg.Download.PreferredMethod = "auto"
}
if cfg.Download.MaxConcurrent == 0 {
cfg.Download.MaxConcurrent = 3
}
if cfg.General.Country == "" {
cfg.General.Country = "US"
}
return cfg, nil
}
// Save writes config to the default or specified path using atomic write.
func Save(cfg Config, path string) error {
if path == "" {
path = FilePath()
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
var buf strings.Builder
encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(cfg); err != nil {
return fmt.Errorf("encode config: %w", err)
}
// Atomic write: write to temp, then rename
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, []byte(buf.String()), 0o600); err != nil {
return fmt.Errorf("write temp config: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("rename config: %w", err)
}
return nil
}
// ParseSpeed parses a human-readable speed string into bytes/s.
// Supports: "10MB", "500KB", "1GB", "1024", "0" (unlimited).
func ParseSpeed(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" || s == "0" {
return 0, nil
}
s = strings.ToUpper(s)
multiplier := int64(1)
switch {
case strings.HasSuffix(s, "GB"):
multiplier = 1024 * 1024 * 1024
s = strings.TrimSuffix(s, "GB")
case strings.HasSuffix(s, "MB"):
multiplier = 1024 * 1024
s = strings.TrimSuffix(s, "MB")
case strings.HasSuffix(s, "KB"):
multiplier = 1024
s = strings.TrimSuffix(s, "KB")
}
n, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0, fmt.Errorf("invalid speed %q: %w", s, err)
}
if n < 0 {
return 0, fmt.Errorf("speed cannot be negative: %s", s)
}
return int64(n * float64(multiplier)), nil
}
// ApplyEnvOverrides applies UNARR_* environment variable overrides.
func (c *Config) ApplyEnvOverrides() {
if v := os.Getenv("UNARR_API_KEY"); v != "" {
c.Auth.APIKey = v
}
if v := os.Getenv("UNARR_API_URL"); v != "" {
c.Auth.APIURL = v
}
if v := os.Getenv("UNARR_COUNTRY"); v != "" {
c.General.Country = v
}
if v := os.Getenv("UNARR_DOWNLOAD_DIR"); v != "" {
c.Download.Dir = v
}
}
// dangerousPaths are system-critical directories that should never be used as
// download or organize targets (per platform).
var dangerousPaths = func() map[string]bool {
m := map[string]bool{}
// Unix
for _, p := range []string{
"/", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/boot", "/dev", "/proc", "/sys",
"/etc", "/var", "/tmp", "/root",
// macOS
"/System", "/Library", "/private", "/private/etc", "/private/tmp", "/private/var",
} {
m[p] = true
}
// Windows
if runtime.GOOS == "windows" {
for _, drive := range []string{"C", "D"} {
for _, p := range []string{
drive + `:\`,
drive + `:\Windows`,
drive + `:\Windows\System32`,
drive + `:\Program Files`,
drive + `:\Program Files (x86)`,
} {
m[filepath.Clean(p)] = true
}
}
}
return m
}()
// ValidatePaths checks that configured directories are safe to write to.
// Returns an error if any path points to a system directory or the user's
// home directory root (must use a subdirectory).
func (c *Config) ValidatePaths() error {
home, _ := os.UserHomeDir()
check := func(label, dir string) error {
if dir == "" {
return nil
}
abs, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("%s: invalid path %q: %w", label, dir, err)
}
clean := filepath.Clean(abs)
if dangerousPaths[clean] {
return fmt.Errorf("%s: refusing to use system directory %q", label, clean)
}
// Block home root — require a subdirectory
if home != "" && clean == filepath.Clean(home) {
return fmt.Errorf("%s: use a subdirectory of your home, not %q itself", label, clean)
}
// Block hidden dirs under home (e.g. ~/.ssh, ~/.gnupg)
if home != "" && strings.HasPrefix(clean, filepath.Clean(home)+string(filepath.Separator)) {
rel, _ := filepath.Rel(home, clean)
first := strings.SplitN(rel, string(filepath.Separator), 2)[0]
if strings.HasPrefix(first, ".") && first != ".local" && first != ".config" {
return fmt.Errorf("%s: refusing to use hidden directory %q", label, clean)
}
}
return nil
}
if err := check("downloads.dir", c.Download.Dir); err != nil {
return err
}
if err := check("organize.movies_dir", c.Organize.MoviesDir); err != nil {
return err
}
if err := check("organize.tv_shows_dir", c.Organize.TVShowsDir); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,202 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestDefault(t *testing.T) {
cfg := Default()
if cfg.Auth.APIURL != "https://torrentclaw.com" {
t.Errorf("default APIURL = %q, want https://torrentclaw.com", cfg.Auth.APIURL)
}
if cfg.Download.PreferredMethod != "auto" {
t.Errorf("default PreferredMethod = %q, want auto", cfg.Download.PreferredMethod)
}
if cfg.Download.MaxConcurrent != 3 {
t.Errorf("default MaxConcurrent = %d, want 3", cfg.Download.MaxConcurrent)
}
if cfg.General.Country != "US" {
t.Errorf("default Country = %q, want US", cfg.General.Country)
}
if cfg.Daemon.HeartbeatInterval != "30s" {
t.Errorf("default HeartbeatInterval = %q, want 30s", cfg.Daemon.HeartbeatInterval)
}
}
func TestLoadMissingFile(t *testing.T) {
cfg, err := Load("/nonexistent/path/config.toml")
if err != nil {
t.Fatalf("Load nonexistent should return defaults, got err: %v", err)
}
if cfg.Auth.APIURL != "https://torrentclaw.com" {
t.Errorf("missing file should return default APIURL, got %q", cfg.Auth.APIURL)
}
}
func TestSaveAndLoad(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
cfg := Default()
cfg.Auth.APIKey = "tc_test123"
cfg.Auth.APIURL = "https://custom.example.com"
cfg.General.Country = "ES"
cfg.Download.Dir = "/media/downloads"
cfg.Agent.ID = "agent-uuid-123"
cfg.Agent.Name = "Test Machine"
if err := Save(cfg, path); err != nil {
t.Fatalf("Save failed: %v", err)
}
// File should exist
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("config file was not created")
}
// No .tmp file left behind
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Error("temp file was not cleaned up")
}
// Load it back
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if loaded.Auth.APIKey != "tc_test123" {
t.Errorf("APIKey = %q, want tc_test123", loaded.Auth.APIKey)
}
if loaded.Auth.APIURL != "https://custom.example.com" {
t.Errorf("APIURL = %q, want https://custom.example.com", loaded.Auth.APIURL)
}
if loaded.General.Country != "ES" {
t.Errorf("Country = %q, want ES", loaded.General.Country)
}
if loaded.Download.Dir != "/media/downloads" {
t.Errorf("Dir = %q, want /media/downloads", loaded.Download.Dir)
}
if loaded.Agent.ID != "agent-uuid-123" {
t.Errorf("AgentID = %q, want agent-uuid-123", loaded.Agent.ID)
}
if loaded.Agent.Name != "Test Machine" {
t.Errorf("AgentName = %q, want Test Machine", loaded.Agent.Name)
}
}
func TestLoadPreservesDefaults(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// Write partial config (only auth section)
os.WriteFile(path, []byte(`[auth]
api_key = "tc_partial"
`), 0o644)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Auth.APIKey != "tc_partial" {
t.Errorf("APIKey = %q, want tc_partial", cfg.Auth.APIKey)
}
// Defaults should be preserved for missing sections
if cfg.Auth.APIURL != "https://torrentclaw.com" {
t.Errorf("APIURL should default, got %q", cfg.Auth.APIURL)
}
if cfg.Download.MaxConcurrent != 3 {
t.Errorf("MaxConcurrent should default to 3, got %d", cfg.Download.MaxConcurrent)
}
if cfg.General.Country != "US" {
t.Errorf("Country should default to US, got %q", cfg.General.Country)
}
}
func TestApplyEnvOverrides(t *testing.T) {
cfg := Default()
t.Setenv("UNARR_API_KEY", "tc_env_key")
t.Setenv("UNARR_API_URL", "https://env.example.com")
t.Setenv("UNARR_COUNTRY", "DE")
t.Setenv("UNARR_DOWNLOAD_DIR", "/env/downloads")
cfg.ApplyEnvOverrides()
if cfg.Auth.APIKey != "tc_env_key" {
t.Errorf("APIKey = %q, want tc_env_key", cfg.Auth.APIKey)
}
if cfg.Auth.APIURL != "https://env.example.com" {
t.Errorf("APIURL = %q, want https://env.example.com", cfg.Auth.APIURL)
}
if cfg.General.Country != "DE" {
t.Errorf("Country = %q, want DE", cfg.General.Country)
}
if cfg.Download.Dir != "/env/downloads" {
t.Errorf("Dir = %q, want /env/downloads", cfg.Download.Dir)
}
}
func TestSaveCreatesDirectory(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "nested", "deep", "config.toml")
cfg := Default()
if err := Save(cfg, path); err != nil {
t.Fatalf("Save with nested dir failed: %v", err)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Error("config file was not created in nested dir")
}
}
func TestParseSpeed(t *testing.T) {
tests := []struct {
input string
want int64
}{
{"0", 0},
{"", 0},
{"10MB", 10 * 1024 * 1024},
{"500KB", 500 * 1024},
{"1GB", 1024 * 1024 * 1024},
{"1.5MB", int64(1.5 * 1024 * 1024)},
{"10mb", 10 * 1024 * 1024},
{"1024", 1024},
}
for _, tt := range tests {
got, err := ParseSpeed(tt.input)
if err != nil {
t.Errorf("ParseSpeed(%q) error: %v", tt.input, err)
continue
}
if got != tt.want {
t.Errorf("ParseSpeed(%q) = %d, want %d", tt.input, got, tt.want)
}
}
// Error cases
if _, err := ParseSpeed("abc"); err == nil {
t.Error("ParseSpeed(\"abc\") should error")
}
if _, err := ParseSpeed("-5MB"); err == nil {
t.Error("ParseSpeed(\"-5MB\") should error")
}
}
func TestLoadInvalidTOML(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
os.WriteFile(path, []byte(`not valid toml [[[`), 0o644)
_, err := Load(path)
if err == nil {
t.Error("expected error for invalid TOML, got nil")
}
}

View file

@ -0,0 +1,100 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestValidatePaths_Dangerous(t *testing.T) {
dangerous := []string{"/", "/etc", "/bin", "/sbin", "/usr", "/lib", "/lib64",
"/boot", "/dev", "/proc", "/sys", "/var", "/tmp", "/root",
"/System", "/Library", "/private"}
for _, d := range dangerous {
// Test all three path fields
for _, field := range []string{"download", "movies", "tvshows"} {
cfg := Default()
switch field {
case "download":
cfg.Download.Dir = d
case "movies":
cfg.Organize.MoviesDir = d
case "tvshows":
cfg.Organize.TVShowsDir = d
}
if err := cfg.ValidatePaths(); err == nil {
t.Errorf("ValidatePaths() should reject %s=%q", field, d)
}
}
}
}
func TestValidatePaths_HomeRoot(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
cfg := Default()
cfg.Download.Dir = home
if err := cfg.ValidatePaths(); err == nil {
t.Errorf("ValidatePaths() should reject home root %q", home)
}
}
func TestValidatePaths_HiddenDir(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
cfg := Default()
cfg.Download.Dir = filepath.Join(home, ".ssh")
if err := cfg.ValidatePaths(); err == nil {
t.Error("ValidatePaths() should reject ~/.ssh")
}
}
func TestValidatePaths_Valid(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
valid := []string{
filepath.Join(home, "Downloads"),
filepath.Join(home, "Media"),
filepath.Join(home, "Media", "Movies"),
"/mnt/storage/downloads",
}
for _, d := range valid {
cfg := Default()
cfg.Download.Dir = d
if err := cfg.ValidatePaths(); err != nil {
t.Errorf("ValidatePaths() should accept %q, got: %v", d, err)
}
}
}
func TestValidatePaths_AllowedHiddenDirs(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
// .local and .config are whitelisted
allowed := []string{
filepath.Join(home, ".local", "share", "unarr"),
filepath.Join(home, ".config", "unarr"),
}
for _, d := range allowed {
cfg := Default()
cfg.Download.Dir = d
if err := cfg.ValidatePaths(); err != nil {
t.Errorf("ValidatePaths() should allow %q, got: %v", d, err)
}
}
}

58
internal/config/paths.go Normal file
View file

@ -0,0 +1,58 @@
package config
import (
"os"
"path/filepath"
"runtime"
)
const appName = "unarr"
// Dir returns the configuration directory following XDG conventions.
// - Linux: ~/.config/unarr
// - macOS: ~/Library/Application Support/unarr
// - Windows: %APPDATA%/unarr
//
// Overridable via UNARR_CONFIG_DIR env var.
func Dir() string {
if d := os.Getenv("UNARR_CONFIG_DIR"); d != "" {
return d
}
switch runtime.GOOS {
case "darwin":
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support", appName)
case "windows":
return filepath.Join(os.Getenv("APPDATA"), appName)
default: // linux, freebsd, etc.
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, appName)
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", appName)
}
}
// FilePath returns the full path to the config file.
func FilePath() string {
return filepath.Join(Dir(), "config.toml")
}
// DataDir returns the data directory for logs, cache, etc.
// - Linux: ~/.local/share/unarr
// - macOS: ~/Library/Application Support/unarr
// - Windows: %LOCALAPPDATA%/unarr
func DataDir() string {
switch runtime.GOOS {
case "darwin":
return Dir() // macOS uses same dir for config and data
case "windows":
return filepath.Join(os.Getenv("LOCALAPPDATA"), appName)
default:
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, appName)
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", appName)
}
}

View file

@ -0,0 +1,53 @@
package config
import (
"os"
"strings"
"testing"
)
func TestDir(t *testing.T) {
dir := Dir()
if dir == "" {
t.Error("Dir() returned empty string")
}
if !strings.Contains(dir, "unarr") {
t.Errorf("Dir() = %q, should contain 'unarr'", dir)
}
}
func TestFilePath(t *testing.T) {
path := FilePath()
if !strings.HasSuffix(path, "config.toml") {
t.Errorf("FilePath() = %q, should end with config.toml", path)
}
}
func TestDataDir(t *testing.T) {
dir := DataDir()
if dir == "" {
t.Error("DataDir() returned empty string")
}
if !strings.Contains(dir, "unarr") {
t.Errorf("DataDir() = %q, should contain 'unarr'", dir)
}
}
func TestDirOverrideEnv(t *testing.T) {
t.Setenv("UNARR_CONFIG_DIR", "/custom/path")
dir := Dir()
if dir != "/custom/path" {
t.Errorf("Dir() with env = %q, want /custom/path", dir)
}
}
func TestDirXDGOverride(t *testing.T) {
// Clear the custom env so XDG takes effect
os.Unsetenv("UNARR_CONFIG_DIR")
t.Setenv("XDG_CONFIG_HOME", "/xdg/config")
dir := Dir()
if dir != "/xdg/config/unarr" {
t.Errorf("Dir() with XDG = %q, want /xdg/config/unarr", dir)
}
}

41
internal/engine/debrid.go Normal file
View file

@ -0,0 +1,41 @@
package engine
import (
"context"
"fmt"
tc "github.com/torrentclaw/go-client"
)
// DebridDownloader downloads via debrid services (Real-Debrid, AllDebrid, etc.).
// Currently a stub — Available() works, Download() returns not-implemented.
type DebridDownloader struct {
apiClient *tc.Client
}
// NewDebridDownloader creates a debrid downloader stub.
func NewDebridDownloader(apiClient *tc.Client) *DebridDownloader {
return &DebridDownloader{apiClient: apiClient}
}
func (d *DebridDownloader) Method() DownloadMethod { return MethodDebrid }
func (d *DebridDownloader) Available(ctx context.Context, task *Task) (bool, error) {
if d.apiClient == nil {
return false, nil
}
resp, err := d.apiClient.DebridCheckCache(ctx, "", "", []string{task.InfoHash})
if err != nil {
return false, err
}
cached, ok := resp.Cached[task.InfoHash]
return ok && cached, nil
}
func (d *DebridDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
return nil, fmt.Errorf("debrid download not implemented yet (coming in a future release)")
}
func (d *DebridDownloader) Pause(_ string) error { return nil }
func (d *DebridDownloader) Cancel(_ string) error { return nil }
func (d *DebridDownloader) Shutdown(_ context.Context) error { return nil }

362
internal/engine/manager.go Normal file
View file

@ -0,0 +1,362 @@
package engine
import (
"context"
"log"
"sync"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
// ManagerConfig holds download manager settings.
type ManagerConfig struct {
MaxConcurrent int
OutputDir string
Organize OrganizeConfig
Notifications bool // send desktop notifications on complete/fail
}
// Manager orchestrates concurrent downloads with method resolution and fallback.
type Manager struct {
cfg ManagerConfig
reporter *ProgressReporter
downloaders map[DownloadMethod]Downloader
activeMu sync.RWMutex
active map[string]*Task
sem chan struct{}
wg sync.WaitGroup
}
// NewManager creates a download manager.
func NewManager(cfg ManagerConfig, reporter *ProgressReporter, downloaders ...Downloader) *Manager {
if cfg.MaxConcurrent <= 0 {
cfg.MaxConcurrent = 3
}
dlMap := make(map[DownloadMethod]Downloader)
for _, d := range downloaders {
dlMap[d.Method()] = d
}
return &Manager{
cfg: cfg,
reporter: reporter,
downloaders: dlMap,
active: make(map[string]*Task),
sem: make(chan struct{}, cfg.MaxConcurrent),
}
}
// Submit queues a task for download. Non-blocking if capacity available.
func (m *Manager) Submit(ctx context.Context, at agent.Task) {
task := NewTaskFromAgent(at)
m.activeMu.Lock()
m.active[task.ID] = task
m.activeMu.Unlock()
m.reporter.Track(task)
// Acquire semaphore slot
select {
case m.sem <- struct{}{}:
case <-ctx.Done():
return
}
m.wg.Add(1)
go func() {
defer m.wg.Done()
defer func() { <-m.sem }()
m.processTask(ctx, task)
}()
}
// HasCapacity returns true if there's room for more downloads.
func (m *Manager) HasCapacity() bool {
return len(m.sem) < cap(m.sem)
}
// ActiveCount returns the number of in-progress downloads.
func (m *Manager) ActiveCount() int {
m.activeMu.RLock()
defer m.activeMu.RUnlock()
return len(m.active)
}
// GetTask returns a single active task by ID, or nil.
func (m *Manager) GetTask(taskID string) *Task {
m.activeMu.RLock()
defer m.activeMu.RUnlock()
return m.active[taskID]
}
// ActiveTasks returns a snapshot of all active tasks.
func (m *Manager) ActiveTasks() []*Task {
m.activeMu.RLock()
defer m.activeMu.RUnlock()
tasks := make([]*Task, 0, len(m.active))
for _, t := range m.active {
tasks = append(tasks, t)
}
return tasks
}
// CancelTask cancels an active download by task ID (keeps partial files).
func (m *Manager) CancelTask(taskID string) {
m.activeMu.RLock()
task, ok := m.active[taskID]
m.activeMu.RUnlock()
if !ok {
return
}
if dl, exists := m.downloaders[task.ResolvedMethod]; exists {
dl.Pause(taskID) // stop download, keep files
}
task.mu.Lock()
task.ErrorMessage = "cancelled by user"
task.mu.Unlock()
task.Transition(StatusCancelled)
log.Printf("[%s] cancelled: %s", taskID[:8], task.Title)
}
// PauseTask pauses an active download (keeps partial files for resume).
func (m *Manager) PauseTask(taskID string) {
m.activeMu.RLock()
task, ok := m.active[taskID]
m.activeMu.RUnlock()
if !ok {
return
}
if dl, exists := m.downloaders[task.ResolvedMethod]; exists {
dl.Pause(taskID) // stop download, keep files for resume
}
task.Transition(StatusCancelled) // will be re-created as pending by server
log.Printf("[%s] paused: %s", taskID[:8], task.Title)
}
// CancelAndDeleteFiles cancels a download and removes its files from disk.
func (m *Manager) CancelAndDeleteFiles(taskID string) {
m.activeMu.RLock()
task, ok := m.active[taskID]
m.activeMu.RUnlock()
if !ok {
return
}
if dl, exists := m.downloaders[task.ResolvedMethod]; exists {
dl.Cancel(taskID) // stop download + delete files
}
task.mu.Lock()
task.ErrorMessage = "cancelled by user"
task.mu.Unlock()
task.Transition(StatusCancelled)
log.Printf("[%s] cancelled + files deleted: %s", taskID[:8], task.Title)
}
// Wait blocks until all active downloads finish.
func (m *Manager) Wait() {
m.wg.Wait()
}
// Shutdown stops accepting tasks and waits for active downloads to finish.
func (m *Manager) Shutdown(ctx context.Context) {
// Wait for goroutines with timeout
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
log.Println("shutdown timeout, cancelling active downloads")
}
// Shutdown all downloaders
for _, d := range m.downloaders {
if err := d.Shutdown(ctx); err != nil {
log.Printf("downloader shutdown: %v", err)
}
}
// Clean active map
m.activeMu.Lock()
m.active = make(map[string]*Task)
m.activeMu.Unlock()
}
func (m *Manager) processTask(ctx context.Context, task *Task) {
defer func() {
m.activeMu.Lock()
delete(m.active, task.ID)
m.activeMu.Unlock()
}()
// 1. Resolve method
if err := task.Transition(StatusResolving); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
method, err := resolveMethod(ctx, task, m.downloaders)
if err != nil {
m.fail(ctx, task, "no method available: "+err.Error())
return
}
task.ResolvedMethod = method
log.Printf("[%s] resolved method: %s", task.ID[:8], method)
// 2. Download
if err := task.Transition(StatusDownloading); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
progressCh := make(chan Progress, 16)
// Drain progress channel (just for logging; reporter reads directly from task)
go func() {
for range progressCh {
// Progress already applied via task.UpdateProgress in the downloader
}
}()
dl := m.downloaders[method]
result, err := dl.Download(ctx, task, m.cfg.OutputDir, progressCh)
close(progressCh)
if err != nil {
// Try fallback
if tryFallback(task, m.downloaders) {
log.Printf("[%s] %s failed, trying fallback: %v", task.ID[:8], method, err)
if err := task.Transition(StatusResolving); err == nil {
m.processTaskRetry(ctx, task)
return
}
}
m.fail(ctx, task, err.Error())
return
}
// 3. Verify
if err := task.Transition(StatusVerifying); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
if err := verify(result); err != nil {
m.fail(ctx, task, "verification failed: "+err.Error())
return
}
// 4. Organize
if err := task.Transition(StatusOrganizing); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
finalPath, err := organize(result, task, m.cfg.Organize)
if err != nil {
log.Printf("[%s] organize warning: %v (keeping in download dir)", task.ID[:8], err)
finalPath = result.FilePath
}
task.mu.Lock()
task.FilePath = finalPath
task.mu.Unlock()
// 5. Complete
if method == MethodTorrent && m.cfg.Organize.Enabled {
// Could add seeding here in the future
}
if err := task.Transition(StatusCompleted); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
log.Printf("[%s] completed: %s -> %s", task.ID[:8], task.Title, finalPath)
if m.cfg.Notifications {
desktopNotify("Download complete", task.Title)
}
m.reporter.ReportFinal(ctx, task)
}
// processTaskRetry handles fallback after a method failure.
func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
method, err := resolveMethod(ctx, task, m.downloaders)
if err != nil {
m.fail(ctx, task, "fallback failed: "+err.Error())
return
}
task.ResolvedMethod = method
log.Printf("[%s] fallback to: %s", task.ID[:8], method)
if err := task.Transition(StatusDownloading); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
progressCh := make(chan Progress, 16)
go func() {
for range progressCh {
}
}()
dl := m.downloaders[method]
result, err := dl.Download(ctx, task, m.cfg.OutputDir, progressCh)
close(progressCh)
if err != nil {
m.fail(ctx, task, err.Error())
return
}
// Verify + Organize + Complete (same as processTask)
task.Transition(StatusVerifying)
if err := verify(result); err != nil {
m.fail(ctx, task, "verification failed: "+err.Error())
return
}
task.Transition(StatusOrganizing)
finalPath, _ := organize(result, task, m.cfg.Organize)
if finalPath == "" {
finalPath = result.FilePath
}
task.mu.Lock()
task.FilePath = finalPath
task.mu.Unlock()
task.Transition(StatusCompleted)
log.Printf("[%s] completed (fallback): %s -> %s", task.ID[:8], task.Title, finalPath)
m.reporter.ReportFinal(ctx, task)
}
func (m *Manager) fail(ctx context.Context, task *Task, msg string) {
task.mu.Lock()
task.ErrorMessage = msg
task.mu.Unlock()
task.Transition(StatusFailed)
log.Printf("[%s] FAILED: %s — %s", task.ID[:8], task.Title, msg)
if m.cfg.Notifications {
desktopNotify("Download failed", task.Title+": "+msg)
}
m.reporter.ReportFinal(ctx, task)
}

View file

@ -0,0 +1,85 @@
package engine
import (
"context"
"testing"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
func TestManagerSubmitAndWait(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
dl := &mockDownloader{method: MethodTorrent, available: true}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: t.TempDir(),
}, reporter, dl)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go reporter.Run(ctx)
mgr.Submit(ctx, agent.Task{
ID: "test-task-1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Test Movie",
PreferredMethod: "torrent",
})
mgr.Wait()
// Task should have been processed (completed or failed depending on verify)
// Since mock returns a file that doesn't exist, it may fail at verify
// This is expected — we're testing the pipeline works
}
func TestManagerHasCapacity(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
mgr := NewManager(ManagerConfig{MaxConcurrent: 2}, reporter)
if !mgr.HasCapacity() {
t.Error("new manager should have capacity")
}
}
func TestManagerActiveCount(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
mgr := NewManager(ManagerConfig{MaxConcurrent: 3}, reporter)
if mgr.ActiveCount() != 0 {
t.Errorf("ActiveCount = %d, want 0", mgr.ActiveCount())
}
}
func TestManagerShutdown(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
dl := &mockDownloader{method: MethodTorrent, available: true}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: t.TempDir(),
}, reporter, dl)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
mgr.Shutdown(ctx)
// Should not hang
}

58
internal/engine/method.go Normal file
View file

@ -0,0 +1,58 @@
package engine
import "context"
// DownloadMethod identifies a download strategy.
type DownloadMethod string
const (
MethodTorrent DownloadMethod = "torrent"
MethodDebrid DownloadMethod = "debrid"
MethodUsenet DownloadMethod = "usenet"
)
// Progress is emitted by downloaders during a download.
type Progress struct {
DownloadedBytes int64
TotalBytes int64
SpeedBps int64 // bytes per second
ETA int // seconds remaining
Peers int // connected peers (torrent only)
Seeds int // connected seeds (torrent only)
FileName string
}
// Result is returned when a download completes successfully.
type Result struct {
FilePath string
FileName string
Method DownloadMethod
Size int64
}
// Downloader is the interface every download method must implement.
type Downloader interface {
// Method returns which method this downloader implements.
Method() DownloadMethod
// Available reports whether this method can handle the given task.
// For torrent: always true if infoHash is set.
// For debrid: checks if cached on debrid service.
// For usenet: checks if NZB is available.
Available(ctx context.Context, task *Task) (bool, error)
// Download starts the download. It blocks until completion or error.
// Progress is reported via progressCh at regular intervals.
// outputDir is where files should be written.
Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error)
// Pause suspends an in-progress download but keeps partial files on disk
// so the download can be resumed later.
Pause(taskID string) error
// Cancel aborts an in-progress download and removes partial files.
Cancel(taskID string) error
// Shutdown gracefully shuts down the downloader.
Shutdown(ctx context.Context) error
}

30
internal/engine/notify.go Normal file
View file

@ -0,0 +1,30 @@
package engine
import (
"os/exec"
"runtime"
)
// desktopNotify sends a best-effort desktop notification.
// Silent failure — never blocks or errors.
func desktopNotify(title, body string) {
switch runtime.GOOS {
case "linux":
exec.Command("notify-send", title, body, "--icon=dialog-information", "--app-name=unarr").Start()
case "darwin":
script := `display notification "` + escapeAppleScript(body) + `" with title "` + escapeAppleScript(title) + `"`
exec.Command("osascript", "-e", script).Start()
}
// Windows: no-op for now
}
func escapeAppleScript(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
if s[i] == '"' || s[i] == '\\' {
out = append(out, '\\')
}
out = append(out, s[i])
}
return string(out)
}

129
internal/engine/organize.go Normal file
View file

@ -0,0 +1,129 @@
package engine
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
var (
yearRegex = regexp.MustCompile(`\b(19|20)\d{2}\b`)
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
)
// OrganizeConfig holds file organization settings.
type OrganizeConfig struct {
Enabled bool
MoviesDir string
TVShowsDir string
}
// organize moves a downloaded file into the proper directory structure.
// Movies: MoviesDir/Title (Year)/filename.ext
// TV: TVShowsDir/Title/Season XX/filename.ext
func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
if !cfg.Enabled || result == nil || result.FilePath == "" {
return result.FilePath, nil
}
title := task.Title
if title == "" {
title = result.FileName
}
isTV := strings.Contains(strings.ToLower(task.PreferredMethod), "show") ||
seasonRegex.MatchString(result.FileName)
// Detect season for TV
var season string
if m := seasonRegex.FindStringSubmatch(result.FileName); len(m) > 1 {
season = m[1]
isTV = true
}
var destDir string
if isTV && cfg.TVShowsDir != "" {
showName := cleanTitle(title)
destDir = filepath.Join(cfg.TVShowsDir, showName)
if season != "" {
destDir = filepath.Join(destDir, fmt.Sprintf("Season %s", season))
}
} else if cfg.MoviesDir != "" {
movieName := cleanTitle(title)
year := yearRegex.FindString(title)
if year != "" {
destDir = filepath.Join(cfg.MoviesDir, fmt.Sprintf("%s (%s)", movieName, year))
} else {
destDir = filepath.Join(cfg.MoviesDir, movieName)
}
} else {
return result.FilePath, nil // no organize dirs configured
}
// Validate destination is within the expected base directory
var baseDir string
if isTV && cfg.TVShowsDir != "" {
baseDir = cfg.TVShowsDir
} else {
baseDir = cfg.MoviesDir
}
if !isWithinDir(baseDir, destDir) {
return "", fmt.Errorf("path traversal blocked: %q escapes %q", destDir, baseDir)
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return "", fmt.Errorf("create dir: %w", err)
}
destPath := filepath.Join(destDir, filepath.Base(result.FilePath))
// Try rename first (same filesystem), fall back to copy+delete
if err := os.Rename(result.FilePath, destPath); err != nil {
if err := copyFile(result.FilePath, destPath); err != nil {
return "", fmt.Errorf("move file: %w", err)
}
os.Remove(result.FilePath)
}
return destPath, nil
}
// cleanTitle extracts a clean title from a torrent title string.
func cleanTitle(title string) string {
// Remove year and everything after common separators
t := title
if idx := strings.Index(t, " ("); idx > 0 {
t = t[:idx]
}
// Remove resolution and codec markers
for _, pattern := range []string{"1080p", "720p", "2160p", "480p", "BluRay", "WEB-DL", "HDTV", "x264", "x265", "HEVC"} {
if idx := strings.Index(strings.ToLower(t), strings.ToLower(pattern)); idx > 0 {
t = t[:idx]
}
}
t = strings.TrimRight(t, " .-_")
if t == "" {
return title
}
return t
}
func copyFile(src, dst string) error {
s, err := os.Open(src)
if err != nil {
return err
}
defer s.Close()
d, err := os.Create(dst)
if err != nil {
return err
}
defer d.Close()
_, err = io.Copy(d, s)
return err
}

View file

@ -0,0 +1,92 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestOrganizeDisabled(t *testing.T) {
r := &Result{FilePath: "/tmp/file.mkv", FileName: "file.mkv"}
task := &Task{Title: "Movie"}
path, err := organize(r, task, OrganizeConfig{Enabled: false})
if err != nil {
t.Fatal(err)
}
if path != "/tmp/file.mkv" {
t.Errorf("path = %q, want original path when disabled", path)
}
}
func TestOrganizeMovie(t *testing.T) {
tmp := t.TempDir()
srcDir := filepath.Join(tmp, "src")
os.MkdirAll(srcDir, 0o755)
srcFile := filepath.Join(srcDir, "Movie.2023.1080p.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
moviesDir := filepath.Join(tmp, "Movies")
r := &Result{FilePath: srcFile, FileName: "Movie.2023.1080p.mkv"}
task := &Task{Title: "Movie 2023"}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
MoviesDir: moviesDir,
})
if err != nil {
t.Fatal(err)
}
// Should be in Movies/Movie (2023)/
if path == srcFile {
t.Error("file should have moved")
}
if _, err := os.Stat(path); err != nil {
t.Errorf("organized file should exist at %s: %v", path, err)
}
}
func TestOrganizeTVShow(t *testing.T) {
tmp := t.TempDir()
srcFile := filepath.Join(tmp, "Show.S02E05.1080p.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
tvDir := filepath.Join(tmp, "TV Shows")
r := &Result{FilePath: srcFile, FileName: "Show.S02E05.1080p.mkv"}
task := &Task{Title: "Show S02E05"}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
TVShowsDir: tvDir,
})
if err != nil {
t.Fatal(err)
}
// Should detect season from filename S02
if _, err := os.Stat(path); err != nil {
t.Errorf("organized file should exist at %s: %v", path, err)
}
}
func TestCleanTitle(t *testing.T) {
tests := []struct {
input string
want string
}{
{"The Matrix (1999)", "The Matrix"},
{"Oppenheimer 2023 1080p BluRay", "Oppenheimer 2023"},
{"Movie", "Movie"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := cleanTitle(tt.input)
if got != tt.want {
t.Errorf("cleanTitle(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

137
internal/engine/progress.go Normal file
View file

@ -0,0 +1,137 @@
package engine
import (
"context"
"log"
"sync"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
// ActionFunc is called when the server signals an action on a task.
type ActionFunc func(taskID string)
// ProgressReporter aggregates progress from downloads and reports to the API.
// It batches updates to avoid flooding the server.
type ProgressReporter struct {
agentClient *agent.Client
interval time.Duration
onCancel ActionFunc
onPause ActionFunc
onDeleteFiles ActionFunc
onStreamRequested ActionFunc
mu sync.Mutex
latest map[string]*Task // taskID -> task with latest progress
}
// NewProgressReporter creates a reporter that flushes every interval.
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
return &ProgressReporter{
agentClient: ac,
interval: interval,
latest: make(map[string]*Task),
}
}
// SetCancelHandler sets the callback invoked when the server says a task is cancelled.
func (r *ProgressReporter) SetCancelHandler(fn ActionFunc) { r.onCancel = fn }
// SetPauseHandler sets the callback invoked when the server says a task is paused.
func (r *ProgressReporter) SetPauseHandler(fn ActionFunc) { r.onPause = fn }
// SetDeleteFilesHandler sets the callback for cancel+delete files.
func (r *ProgressReporter) SetDeleteFilesHandler(fn ActionFunc) { r.onDeleteFiles = fn }
// SetStreamRequestedHandler sets the callback for stream activation.
func (r *ProgressReporter) SetStreamRequestedHandler(fn ActionFunc) { r.onStreamRequested = fn }
// Track registers a task for progress tracking.
func (r *ProgressReporter) Track(task *Task) {
r.mu.Lock()
defer r.mu.Unlock()
r.latest[task.ID] = task
}
// Untrack removes a task from progress tracking.
func (r *ProgressReporter) Untrack(taskID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.latest, taskID)
}
// Run starts the periodic flush loop. Blocks until ctx is cancelled.
func (r *ProgressReporter) Run(ctx context.Context) error {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
r.flush(context.Background())
return nil
case <-ticker.C:
r.flush(ctx)
}
}
}
func (r *ProgressReporter) flush(ctx context.Context) {
r.mu.Lock()
tasks := make([]*Task, 0, len(r.latest))
for _, t := range r.latest {
tasks = append(tasks, t)
}
r.mu.Unlock()
for _, task := range tasks {
status := task.GetStatus()
if status != StatusDownloading && status != StatusVerifying &&
status != StatusOrganizing && status != StatusSeeding &&
status != StatusCompleted && status != StatusFailed {
continue
}
update := task.ToStatusUpdate()
resp, err := r.agentClient.ReportStatus(ctx, update)
if err != nil {
log.Printf("[%s] progress report failed: %v", task.ID[:8], err)
continue
}
// Handle server-side signals
if resp.Cancelled {
log.Printf("[%s] cancelled by user (via web)", task.ID[:8])
r.Untrack(task.ID)
if resp.DeleteFiles && r.onDeleteFiles != nil {
r.onDeleteFiles(task.ID)
} else if r.onCancel != nil {
r.onCancel(task.ID)
}
} else if resp.Paused {
log.Printf("[%s] paused by user (via web)", task.ID[:8])
r.Untrack(task.ID)
if r.onPause != nil {
r.onPause(task.ID)
}
}
if resp.StreamRequested && task.GetStreamURL() == "" {
log.Printf("[%s] stream requested by user (via web)", task.ID[:8])
if r.onStreamRequested != nil {
r.onStreamRequested(task.ID)
}
}
}
}
// ReportFinal sends a final status update for a completed/failed task.
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) {
update := task.ToStatusUpdate()
if _, err := r.agentClient.ReportStatus(ctx, update); err != nil {
log.Printf("[%s] final report failed: %v", task.ID[:8], err)
}
r.Untrack(task.ID)
}

View file

@ -0,0 +1,75 @@
package engine
import (
"context"
"fmt"
"log"
)
// resolveMethod determines which download method to use for a task.
// For "auto": tries available methods in priority order (torrent > debrid > usenet).
// For specific method: uses only that method.
func resolveMethod(ctx context.Context, task *Task, downloaders map[DownloadMethod]Downloader) (DownloadMethod, error) {
var order []DownloadMethod
switch task.PreferredMethod {
case "torrent":
order = []DownloadMethod{MethodTorrent}
case "debrid":
order = []DownloadMethod{MethodDebrid}
case "usenet":
order = []DownloadMethod{MethodUsenet}
default: // "auto"
order = []DownloadMethod{MethodTorrent, MethodDebrid, MethodUsenet}
}
for _, method := range order {
// Skip already-tried methods
tried := false
for _, tm := range task.TriedMethods {
if tm == method {
tried = true
break
}
}
if tried {
continue
}
dl, ok := downloaders[method]
if !ok {
continue // downloader not registered
}
available, err := dl.Available(ctx, task)
if err != nil {
taskID := task.ID
if len(taskID) > 8 {
taskID = taskID[:8]
}
log.Printf("[%s] %s availability check failed: %v", taskID, method, err)
continue
}
if available {
return method, nil
}
}
return "", fmt.Errorf("no download method available (tried: %v)", task.TriedMethods)
}
// tryFallback attempts to fall back to the next untried download method.
// Returns true if fallback was initiated, false if no more methods.
func tryFallback(task *Task, downloaders map[DownloadMethod]Downloader) bool {
if task.PreferredMethod != "auto" {
return false // specific method requested, no fallback
}
task.TriedMethods = append(task.TriedMethods, task.ResolvedMethod)
available := make([]DownloadMethod, 0, len(downloaders))
for m := range downloaders {
available = append(available, m)
}
return task.HasUntried(available)
}

View file

@ -0,0 +1,141 @@
package engine
import (
"context"
"fmt"
"testing"
)
// mockDownloader implements Downloader for testing.
type mockDownloader struct {
method DownloadMethod
available bool
err error
}
func (m *mockDownloader) Method() DownloadMethod { return m.method }
func (m *mockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return m.available, m.err
}
func (m *mockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
return &Result{Method: m.method, FileName: "test.mkv", FilePath: "/tmp/test.mkv"}, nil
}
func (m *mockDownloader) Pause(_ string) error { return nil }
func (m *mockDownloader) Cancel(_ string) error { return nil }
func (m *mockDownloader) Shutdown(_ context.Context) error { return nil }
func TestResolveMethodAuto(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{PreferredMethod: "auto"}
method, err := resolveMethod(context.Background(), task, downloaders)
if err != nil {
t.Fatal(err)
}
// Torrent is first in auto order
if method != MethodTorrent {
t.Errorf("method = %q, want torrent (first in auto order)", method)
}
}
func TestResolveMethodSpecific(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{PreferredMethod: "debrid"}
method, err := resolveMethod(context.Background(), task, downloaders)
if err != nil {
t.Fatal(err)
}
if method != MethodDebrid {
t.Errorf("method = %q, want debrid", method)
}
}
func TestResolveMethodSkipsTried(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{
PreferredMethod: "auto",
TriedMethods: []DownloadMethod{MethodTorrent},
}
method, err := resolveMethod(context.Background(), task, downloaders)
if err != nil {
t.Fatal(err)
}
if method != MethodDebrid {
t.Errorf("method = %q, want debrid (torrent already tried)", method)
}
}
func TestResolveMethodNoneAvailable(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: false},
}
task := &Task{PreferredMethod: "auto"}
_, err := resolveMethod(context.Background(), task, downloaders)
if err == nil {
t.Error("expected error when no method available")
}
}
func TestResolveMethodAvailabilityError(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: false, err: fmt.Errorf("network error")},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{ID: "test-resolve-err", PreferredMethod: "auto"}
method, err := resolveMethod(context.Background(), task, downloaders)
if err != nil {
t.Fatal(err)
}
// Should fallback to debrid when torrent has error
if method != MethodDebrid {
t.Errorf("method = %q, want debrid (torrent errored)", method)
}
}
func TestTryFallbackAutoMode(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{
PreferredMethod: "auto",
ResolvedMethod: MethodTorrent,
}
if !tryFallback(task, downloaders) {
t.Error("should have fallback available")
}
if len(task.TriedMethods) != 1 || task.TriedMethods[0] != MethodTorrent {
t.Error("torrent should be in tried methods")
}
}
func TestTryFallbackSpecificMode(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{
PreferredMethod: "torrent",
ResolvedMethod: MethodTorrent,
}
if tryFallback(task, downloaders) {
t.Error("should not fallback in specific mode")
}
}

View file

@ -0,0 +1,37 @@
package engine
import (
"fmt"
"path/filepath"
"strings"
)
// isWithinDir checks that resolved is a child of baseDir (prevents path traversal).
// Both paths must be absolute and clean.
func isWithinDir(baseDir, resolved string) bool {
base := filepath.Clean(baseDir)
target := filepath.Clean(resolved)
return target == base || strings.HasPrefix(target, base+string(filepath.Separator))
}
// safePath constructs a path under baseDir and validates it doesn't escape.
// Returns an error if the resulting path is outside baseDir.
// If the resulting path exists and is a symlink that resolves outside baseDir,
// it is also rejected.
func safePath(baseDir, untrusted string) (string, error) {
resolved := filepath.Join(baseDir, untrusted) // Join already cleans
if !isWithinDir(baseDir, resolved) {
return "", fmt.Errorf("path traversal blocked: %q escapes %q", untrusted, baseDir)
}
// Resolve symlinks if the path already exists on disk
if real, err := filepath.EvalSymlinks(resolved); err == nil {
if !isWithinDir(baseDir, real) {
return "", fmt.Errorf("path traversal blocked: %q resolves outside %q via symlink", untrusted, baseDir)
}
return real, nil
}
return resolved, nil
}

View file

@ -0,0 +1,47 @@
package engine
import "testing"
func TestIsWithinDir(t *testing.T) {
tests := []struct {
base string
target string
want bool
}{
{"/data", "/data/file.txt", true},
{"/data", "/data/sub/file.txt", true},
{"/data", "/data", true},
{"/data", "/data/../etc/passwd", false},
{"/data", "/etc/passwd", false},
{"/data", "/", false},
{"/data", "/datafoo", false}, // not a child, just a prefix
}
for _, tt := range tests {
got := isWithinDir(tt.base, tt.target)
if got != tt.want {
t.Errorf("isWithinDir(%q, %q) = %v, want %v", tt.base, tt.target, got, tt.want)
}
}
}
func TestSafePath(t *testing.T) {
tests := []struct {
base string
untrusted string
wantErr bool
}{
{"/data", "movie.mkv", false},
{"/data", "sub/file.mkv", false},
{"/data", "../etc/passwd", true},
{"/data", "../../root/.ssh", true},
{"/data", "normal/../still-ok", false},
}
for _, tt := range tests {
_, err := safePath(tt.base, tt.untrusted)
if (err != nil) != tt.wantErr {
t.Errorf("safePath(%q, %q) error = %v, wantErr %v", tt.base, tt.untrusted, err, tt.wantErr)
}
}
}

316
internal/engine/stream.go Normal file
View file

@ -0,0 +1,316 @@
package engine
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
)
// StreamConfig holds settings for the streaming engine.
type StreamConfig struct {
DataDir string
Port int
BufferBytes int64
MetaTimeout time.Duration
NoOpen bool
PlayerCmd string
}
// StreamStatus represents the current state of the streaming session.
type StreamStatus int
const (
StreamStatusMetadata StreamStatus = iota
StreamStatusBuffering
StreamStatusReady
StreamStatusError
)
// StreamProgress is a snapshot of current streaming stats.
type StreamProgress struct {
Status StreamStatus
DownloadedBytes int64
TotalBytes int64
SpeedBps int64
Peers int
Seeds int
FileName string
}
// StreamEngine manages a single streaming torrent session.
type StreamEngine struct {
client *torrent.Client
cfg StreamConfig
tor *torrent.Torrent
file *torrent.File
bufferTarget int64
totalBytes int64
fileName string
mu sync.RWMutex
status StreamStatus
lastBytes int64
lastTime time.Time
speedBps int64
}
// NewStreamEngine creates a streaming engine with its own torrent client.
func NewStreamEngine(cfg StreamConfig) (*StreamEngine, error) {
if cfg.MetaTimeout == 0 {
cfg.MetaTimeout = 60 * time.Second
}
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
tcfg := torrent.NewDefaultClientConfig()
tcfg.DataDir = cfg.DataDir
tcfg.Seed = false
tcfg.NoUpload = true
tcfg.ListenPort = 0
tcfg.Logger = alog.Default.FilterLevel(alog.Disabled)
client, err := torrent.NewClient(tcfg)
if err != nil {
return nil, fmt.Errorf("create torrent client: %w", err)
}
return &StreamEngine{
client: client,
cfg: cfg,
status: StreamStatusMetadata,
}, nil
}
// Start adds the torrent, waits for metadata, selects the video file,
// and prepares for streaming.
func (s *StreamEngine) Start(ctx context.Context, magnetOrHash string) error {
magnet := magnetOrHash
if !strings.HasPrefix(magnet, "magnet:") {
magnet = buildMagnet(strings.TrimSpace(magnetOrHash))
}
t, err := s.client.AddMagnet(magnet)
if err != nil {
return fmt.Errorf("add magnet: %w", err)
}
s.tor = t
metaCtx, metaCancel := context.WithTimeout(ctx, s.cfg.MetaTimeout)
defer metaCancel()
select {
case <-t.GotInfo():
case <-metaCtx.Done():
return fmt.Errorf("metadata timeout after %s: no peers found", s.cfg.MetaTimeout)
}
if err := s.selectFile(); err != nil {
return err
}
s.totalBytes = s.file.Length()
s.fileName = filepath.Base(s.file.DisplayPath())
s.bufferTarget = s.calculateBufferTarget()
s.lastTime = time.Now()
s.mu.Lock()
s.status = StreamStatusBuffering
s.mu.Unlock()
return nil
}
// selectFile picks the best video file from the torrent.
// Falls back to the largest file if no video is found.
func (s *StreamEngine) selectFile() error {
files := s.tor.Files()
if len(files) == 0 {
return fmt.Errorf("torrent has no files")
}
var bestVideo *torrent.File
var bestAny *torrent.File
for _, f := range files {
ext := strings.ToLower(filepath.Ext(f.DisplayPath()))
if VideoExts[ext] {
if bestVideo == nil || f.Length() > bestVideo.Length() {
bestVideo = f
}
}
if bestAny == nil || f.Length() > bestAny.Length() {
bestAny = f
}
}
if bestVideo != nil {
s.file = bestVideo
} else {
s.file = bestAny
}
// Cancel all other files, download only the selected one
for _, f := range files {
if f == s.file {
f.Download()
} else {
f.SetPriority(torrent.PiecePriorityNone)
}
}
return nil
}
// IsVideoFile returns true if the selected file has a video extension.
func (s *StreamEngine) IsVideoFile() bool {
ext := strings.ToLower(filepath.Ext(s.fileName))
return VideoExts[ext]
}
func (s *StreamEngine) calculateBufferTarget() int64 {
if s.cfg.BufferBytes > 0 {
return s.cfg.BufferBytes
}
fivePercent := s.totalBytes / 20
tenMB := int64(10 * 1024 * 1024)
if fivePercent < tenMB {
return fivePercent
}
return tenMB
}
// contiguousBytes returns the number of bytes completed contiguously
// from the start of the file.
func (s *StreamEngine) contiguousBytes() int64 {
states := s.file.State()
var total int64
for _, ps := range states {
if ps.Complete {
total += ps.Bytes
} else {
break
}
}
return total
}
// WaitBuffer blocks until enough contiguous bytes from the file start
// are downloaded, or the context is cancelled.
func (s *StreamEngine) WaitBuffer(ctx context.Context, progressFn func(buffered, target int64)) error {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
buffered := s.contiguousBytes()
if progressFn != nil {
progressFn(buffered, s.bufferTarget)
}
if buffered >= s.bufferTarget {
s.mu.Lock()
s.status = StreamStatusReady
s.mu.Unlock()
return nil
}
}
}
}
// NewFileReader creates a new reader for the selected file.
// Each HTTP request should get its own reader (not safe for concurrent use).
func (s *StreamEngine) NewFileReader(ctx context.Context) torrent.Reader {
reader := s.file.NewReader()
reader.SetResponsive()
reader.SetReadahead(5 * 1024 * 1024) // 5MB readahead
reader.SetContext(ctx)
return reader
}
// StartProgressLoop starts a goroutine that updates speed/peer stats every second.
// It stops when the context is cancelled.
func (s *StreamEngine) StartProgressLoop(ctx context.Context) {
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
now := time.Now()
downloaded := s.file.BytesCompleted()
s.mu.Lock()
elapsed := now.Sub(s.lastTime).Seconds()
if elapsed > 0 {
s.speedBps = int64(float64(downloaded-s.lastBytes) / elapsed)
if s.speedBps < 0 {
s.speedBps = 0
}
}
s.lastBytes = downloaded
s.lastTime = now
s.mu.Unlock()
}
}
}()
}
// Progress returns a snapshot of the current streaming stats.
func (s *StreamEngine) Progress() StreamProgress {
s.mu.RLock()
status := s.status
speed := s.speedBps
s.mu.RUnlock()
stats := s.tor.Stats()
return StreamProgress{
Status: status,
DownloadedBytes: s.file.BytesCompleted(),
TotalBytes: s.totalBytes,
SpeedBps: speed,
Peers: stats.ActivePeers,
Seeds: stats.ConnectedSeeders,
FileName: s.fileName,
}
}
// FileName returns the name of the selected file.
func (s *StreamEngine) FileName() string { return s.fileName }
// FileLength returns the total size of the selected file in bytes.
func (s *StreamEngine) FileLength() int64 { return s.totalBytes }
// BufferTarget returns the buffer threshold in bytes.
func (s *StreamEngine) BufferTarget() int64 { return s.bufferTarget }
// Shutdown gracefully closes the torrent and client.
func (s *StreamEngine) Shutdown(_ context.Context) error {
if s.tor != nil {
s.tor.Drop()
}
if s.client != nil {
errs := s.client.Close()
if len(errs) > 0 {
return fmt.Errorf("close client: %v", errs[0])
}
}
return nil
}

View file

@ -0,0 +1,74 @@
package engine
import (
"fmt"
"os/exec"
"runtime"
)
// OpenPlayer attempts to open a media player with the given stream URL.
// Returns the player name and the running command.
// If override is set, it uses that command directly.
func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
if override != "" {
cmd := exec.Command(override, url)
if err := cmd.Start(); err != nil {
return override, nil, fmt.Errorf("start %s: %w", override, err)
}
return override, cmd, nil
}
// Try mpv first (best streaming support)
if path, err := exec.LookPath("mpv"); err == nil {
cmd := exec.Command(path, "--no-terminal", url)
if err := cmd.Start(); err == nil {
return "mpv", cmd, nil
}
}
// Try VLC
if path, err := exec.LookPath("vlc"); err == nil {
cmd := exec.Command(path, url)
if err := cmd.Start(); err == nil {
return "vlc", cmd, nil
}
}
// Try cvlc (VLC headless)
if path, err := exec.LookPath("cvlc"); err == nil {
cmd := exec.Command(path, url)
if err := cmd.Start(); err == nil {
return "vlc (headless)", cmd, nil
}
}
// Browser fallback
name, cmd, err := openBrowser(url)
if err != nil {
return "", nil, fmt.Errorf("no player found: install mpv or vlc, or open %s manually", url)
}
return name, cmd, nil
}
func openBrowser(url string) (string, *exec.Cmd, error) {
switch runtime.GOOS {
case "linux":
if path, err := exec.LookPath("xdg-open"); err == nil {
cmd := exec.Command(path, url)
if err := cmd.Start(); err == nil {
return "browser", cmd, nil
}
}
case "darwin":
cmd := exec.Command("/usr/bin/open", url)
if err := cmd.Start(); err == nil {
return "browser", cmd, nil
}
case "windows":
cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
if err := cmd.Start(); err == nil {
return "browser", cmd, nil
}
}
return "", nil, fmt.Errorf("no browser opener found")
}

View file

@ -0,0 +1,142 @@
package engine
import (
"context"
"fmt"
"log"
"net"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/anacrolix/torrent"
)
// fileProvider abstracts where to get a file reader for streaming.
type fileProvider interface {
NewFileReader(ctx context.Context) torrent.Reader
FileName() string
}
// StreamServer serves a torrent file over HTTP with Range request support.
type StreamServer struct {
provider fileProvider
server *http.Server
port int
url string
}
// NewStreamServer creates a new HTTP server for streaming via StreamEngine.
func NewStreamServer(engine *StreamEngine, port int) *StreamServer {
return &StreamServer{
provider: engine,
port: port,
}
}
// NewStreamServerFromFile creates a server that streams directly from a torrent.File.
// Used for streaming an active download without a separate StreamEngine.
func NewStreamServerFromFile(file *torrent.File, port int) *StreamServer {
return &StreamServer{
provider: &torrentFileProvider{file: file},
port: port,
}
}
// torrentFileProvider wraps a torrent.File to implement fileProvider.
type torrentFileProvider struct {
file *torrent.File
}
func (p *torrentFileProvider) NewFileReader(ctx context.Context) torrent.Reader {
reader := p.file.NewReader()
reader.SetResponsive()
reader.SetReadahead(5 * 1024 * 1024)
reader.SetContext(ctx)
return reader
}
func (p *torrentFileProvider) FileName() string {
return filepath.Base(p.file.DisplayPath())
}
// Start begins serving the file on localhost. Returns the full URL.
func (ss *StreamServer) Start(ctx context.Context) (string, error) {
mux := http.NewServeMux()
mux.HandleFunc("/stream", ss.handler)
addr := fmt.Sprintf("127.0.0.1:%d", ss.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return "", fmt.Errorf("listen on %s: %w", addr, err)
}
// Extract actual port (important when port=0)
ss.port = listener.Addr().(*net.TCPAddr).Port
ss.url = fmt.Sprintf("http://127.0.0.1:%d/stream", ss.port)
ss.server = &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
if err := ss.server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("stream server error: %v", err)
}
}()
return ss.url, nil
}
// URL returns the full stream URL.
func (ss *StreamServer) URL() string { return ss.url }
// Port returns the bound port.
func (ss *StreamServer) Port() int { return ss.port }
// Shutdown gracefully stops the HTTP server.
func (ss *StreamServer) Shutdown(ctx context.Context) error {
if ss.server != nil {
return ss.server.Shutdown(ctx)
}
return nil
}
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
reader := ss.provider.NewFileReader(r.Context())
defer reader.Close()
w.Header().Set("Content-Type", mimeTypeFromExt(ss.provider.FileName()))
http.ServeContent(w, r, ss.provider.FileName(), time.Time{}, reader)
}
func mimeTypeFromExt(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".mp4", ".m4v":
return "video/mp4"
case ".mkv":
return "video/x-matroska"
case ".avi":
return "video/x-msvideo"
case ".webm":
return "video/webm"
case ".mov":
return "video/quicktime"
case ".ts":
return "video/mp2t"
case ".flv":
return "video/x-flv"
case ".mpg", ".mpeg":
return "video/mpeg"
case ".wmv":
return "video/x-ms-wmv"
case ".vob":
return "video/x-ms-vob"
default:
return "application/octet-stream"
}
}

View file

@ -0,0 +1,370 @@
package engine
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
// ---------------------------------------------------------------------------
// StreamEngine unit tests (no network)
// ---------------------------------------------------------------------------
func TestStreamBuildMagnet(t *testing.T) {
hash := "abc123def456abc123def456abc123def456abc1"
magnet := buildMagnet(hash)
if !strings.HasPrefix(magnet, "magnet:?xt=urn:btih:"+hash) {
t.Errorf("magnet should start with btih, got: %s", magnet[:60])
}
// Should contain trackers
for _, tracker := range defaultTrackers {
if !strings.Contains(magnet, "tr=") {
t.Errorf("magnet should contain tracker param for %s", tracker)
}
}
}
func TestStreamBuildMagnetPassthrough(t *testing.T) {
// If input already is a magnet, Start should use it directly
// Here we test that buildMagnet produces a valid magnet from a hash
hash := "0000000000000000000000000000000000000000"
magnet := buildMagnet(hash)
if !strings.Contains(magnet, hash) {
t.Error("magnet should contain the info hash")
}
}
func TestVideoExtensions(t *testing.T) {
exts := []string{".mkv", ".mp4", ".avi", ".webm", ".mov", ".ts", ".flv", ".m4v", ".mpg", ".mpeg", ".vob", ".wmv"}
for _, ext := range exts {
if !VideoExts[ext] {
t.Errorf("expected %s to be a video extension", ext)
}
}
nonVideo := []string{".txt", ".zip", ".nfo", ".srt", ".jpg", ".exe"}
for _, ext := range nonVideo {
if VideoExts[ext] {
t.Errorf("expected %s to NOT be a video extension", ext)
}
}
}
func TestCalculateBufferTarget(t *testing.T) {
tests := []struct {
name string
totalBytes int64
bufferBytes int64
want int64
}{
{"small file (<200MB) uses 5%", 100 * 1024 * 1024, 0, 100 * 1024 * 1024 / 20},
{"large file (10GB) caps at 10MB", 10 * 1024 * 1024 * 1024, 0, 10 * 1024 * 1024},
{"medium file (500MB) caps at 10MB", 500 * 1024 * 1024, 0, 10 * 1024 * 1024}, // 5% of 500MB = 25MB > 10MB cap
{"override takes precedence", 10 * 1024 * 1024 * 1024, 5 * 1024 * 1024, 5 * 1024 * 1024},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &StreamEngine{
totalBytes: tt.totalBytes,
cfg: StreamConfig{BufferBytes: tt.bufferBytes},
}
got := s.calculateBufferTarget()
if got != tt.want {
t.Errorf("calculateBufferTarget() = %d, want %d", got, tt.want)
}
})
}
}
func TestIsVideoFile(t *testing.T) {
tests := []struct {
name string
fileName string
want bool
}{
{"mp4", "movie.mp4", true},
{"mkv", "movie.mkv", true},
{"avi", "movie.avi", true},
{"nfo", "movie.nfo", false},
{"txt", "readme.txt", false},
{"srt", "subtitles.srt", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &StreamEngine{fileName: tt.fileName}
if got := s.IsVideoFile(); got != tt.want {
t.Errorf("IsVideoFile(%q) = %v, want %v", tt.fileName, got, tt.want)
}
})
}
}
func TestStreamStatusConstants(t *testing.T) {
// Verify status constants are distinct
statuses := []StreamStatus{
StreamStatusMetadata,
StreamStatusBuffering,
StreamStatusReady,
StreamStatusError,
}
seen := map[StreamStatus]bool{}
for _, s := range statuses {
if seen[s] {
t.Errorf("duplicate status value: %d", s)
}
seen[s] = true
}
}
func TestStreamEngineGetters(t *testing.T) {
s := &StreamEngine{
fileName: "movie.mkv",
totalBytes: 4 * 1024 * 1024 * 1024,
bufferTarget: 10 * 1024 * 1024,
}
if s.FileName() != "movie.mkv" {
t.Errorf("FileName() = %q", s.FileName())
}
if s.FileLength() != 4*1024*1024*1024 {
t.Errorf("FileLength() = %d", s.FileLength())
}
if s.BufferTarget() != 10*1024*1024 {
t.Errorf("BufferTarget() = %d", s.BufferTarget())
}
}
// ---------------------------------------------------------------------------
// StreamServer unit tests
// ---------------------------------------------------------------------------
func TestMimeTypeFromExt(t *testing.T) {
tests := []struct {
filename string
want string
}{
{"movie.mp4", "video/mp4"},
{"movie.m4v", "video/mp4"},
{"movie.mkv", "video/x-matroska"},
{"movie.avi", "video/x-msvideo"},
{"movie.webm", "video/webm"},
{"movie.mov", "video/quicktime"},
{"movie.ts", "video/mp2t"},
{"movie.flv", "video/x-flv"},
{"movie.mpg", "video/mpeg"},
{"movie.mpeg", "video/mpeg"},
{"movie.wmv", "video/x-ms-wmv"},
{"movie.vob", "video/x-ms-vob"},
{"unknown.xyz", "application/octet-stream"},
{"file.MP4", "video/mp4"}, // case insensitive
{"FILE.MKV", "video/x-matroska"},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
got := mimeTypeFromExt(tt.filename)
if got != tt.want {
t.Errorf("mimeTypeFromExt(%q) = %q, want %q", tt.filename, got, tt.want)
}
})
}
}
func TestStreamServerStartShutdown(t *testing.T) {
// Test server lifecycle without a real StreamEngine
// We can't test actual streaming, but we can test the HTTP server mechanics
// Create a minimal engine with just enough state for the server
s := &StreamEngine{
fileName: "test.mp4",
totalBytes: 1024,
}
srv := NewStreamServer(s, 0)
if srv.Port() != 0 {
t.Errorf("initial port should be 0, got %d", srv.Port())
}
// We can't Start() because NewFileReader needs a real torrent File
// But we can test that Shutdown on an un-started server doesn't panic
if err := srv.Shutdown(context.Background()); err != nil {
t.Errorf("shutdown of un-started server should not error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Task integration with stream fields
// ---------------------------------------------------------------------------
func TestNewTaskFromAgentWithMode(t *testing.T) {
at := agent.Task{
ID: "stream-task-1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Movie (2024)",
PreferredMethod: "auto",
Mode: "stream",
}
task := NewTaskFromAgent(at)
if task.Mode != "stream" {
t.Errorf("Mode = %q, want stream", task.Mode)
}
if task.Status != StatusClaimed {
t.Errorf("Status = %q, want claimed", task.Status)
}
}
func TestNewTaskFromAgentDefaultMode(t *testing.T) {
at := agent.Task{
ID: "download-task-1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
PreferredMethod: "auto",
// Mode not set
}
task := NewTaskFromAgent(at)
if task.Mode != "download" {
t.Errorf("Mode = %q, want download (default)", task.Mode)
}
}
func TestToStatusUpdateIncludesStreamURL(t *testing.T) {
task := &Task{
ID: "stream-task-2",
Status: StatusDownloading,
ResolvedMethod: MethodTorrent,
Mode: "stream",
StreamURL: "http://127.0.0.1:43210/stream",
DownloadedBytes: 500,
TotalBytes: 1000,
SpeedBps: 100,
FileName: "movie.mkv",
}
update := task.ToStatusUpdate()
if update.StreamURL != "http://127.0.0.1:43210/stream" {
t.Errorf("StreamURL = %q, want http://127.0.0.1:43210/stream", update.StreamURL)
}
if update.Status != "downloading" {
t.Errorf("Status = %q", update.Status)
}
}
func TestToStatusUpdateNoStreamURL(t *testing.T) {
task := &Task{
ID: "download-task-2",
Status: StatusDownloading,
ResolvedMethod: MethodTorrent,
Mode: "download",
}
update := task.ToStatusUpdate()
if update.StreamURL != "" {
t.Errorf("StreamURL should be empty for download tasks, got %q", update.StreamURL)
}
}
// ---------------------------------------------------------------------------
// StreamServer HTTP test (with mock ReadSeeker)
// ---------------------------------------------------------------------------
func TestStreamHTTPHandler(t *testing.T) {
// We create an HTTP handler manually to test Range request support
// This simulates what StreamServer.handler does, but with a string reader
content := strings.Repeat("X", 1000) // 1KB of data
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reader := strings.NewReader(content)
w.Header().Set("Content-Type", "video/mp4")
http.ServeContent(w, r, "test.mp4", time.Time{}, reader)
})
// Test full content request
t.Run("full request", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/stream", nil)
rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}}
handler.ServeHTTP(rr, req)
if rr.statusCode != http.StatusOK {
t.Errorf("status = %d, want 200", rr.statusCode)
}
if ct := rr.headers.Get("Content-Type"); ct != "video/mp4" {
t.Errorf("Content-Type = %q, want video/mp4", ct)
}
if rr.body.Len() != 1000 {
t.Errorf("body length = %d, want 1000", rr.body.Len())
}
})
// Test Range request
t.Run("range request", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/stream", nil)
req.Header.Set("Range", "bytes=0-99")
rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}}
handler.ServeHTTP(rr, req)
if rr.statusCode != http.StatusPartialContent {
t.Errorf("status = %d, want 206 Partial Content", rr.statusCode)
}
if rr.body.Len() != 100 {
t.Errorf("body length = %d, want 100", rr.body.Len())
}
})
// Test Range request middle
t.Run("range request middle", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/stream", nil)
req.Header.Set("Range", "bytes=500-599")
rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}}
handler.ServeHTTP(rr, req)
if rr.statusCode != http.StatusPartialContent {
t.Errorf("status = %d, want 206", rr.statusCode)
}
if rr.body.Len() != 100 {
t.Errorf("body length = %d, want 100", rr.body.Len())
}
})
// Test HEAD request
t.Run("HEAD request", func(t *testing.T) {
req, _ := http.NewRequest("HEAD", "/stream", nil)
rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}}
handler.ServeHTTP(rr, req)
if rr.statusCode != http.StatusOK {
t.Errorf("status = %d, want 200", rr.statusCode)
}
})
}
// responseRecorder is a minimal http.ResponseWriter for testing
type responseRecorder struct {
statusCode int
headers http.Header
body *strings.Builder
}
func (r *responseRecorder) Header() http.Header { return r.headers }
func (r *responseRecorder) WriteHeader(code int) { r.statusCode = code }
func (r *responseRecorder) Write(b []byte) (int, error) {
if r.statusCode == 0 {
r.statusCode = http.StatusOK
}
return r.body.Write(b)
}
// Ensure responseRecorder implements ReadSeeker expectations
func (r *responseRecorder) ReadFrom(src io.Reader) (int64, error) {
n, err := io.Copy(r.body, src)
return n, err
}

212
internal/engine/task.go Normal file
View file

@ -0,0 +1,212 @@
package engine
import (
"fmt"
"sync"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
// TaskStatus represents the current state of a download task.
type TaskStatus string
const (
StatusPending TaskStatus = "pending"
StatusClaimed TaskStatus = "claimed"
StatusResolving TaskStatus = "resolving"
StatusDownloading TaskStatus = "downloading"
StatusVerifying TaskStatus = "verifying"
StatusOrganizing TaskStatus = "organizing"
StatusSeeding TaskStatus = "seeding"
StatusCompleted TaskStatus = "completed"
StatusFailed TaskStatus = "failed"
StatusCancelled TaskStatus = "cancelled"
)
// validTransitions defines allowed state changes.
var validTransitions = map[TaskStatus][]TaskStatus{
StatusPending: {StatusClaimed},
StatusClaimed: {StatusResolving, StatusCancelled},
StatusResolving: {StatusDownloading, StatusFailed, StatusCancelled},
StatusDownloading: {StatusVerifying, StatusFailed, StatusResolving, StatusCancelled},
StatusVerifying: {StatusOrganizing, StatusFailed},
StatusOrganizing: {StatusSeeding, StatusCompleted},
StatusSeeding: {StatusCompleted},
}
// Task represents a download task with its full lifecycle state.
type Task struct {
mu sync.RWMutex
// From server
ID string
InfoHash string
Title string
ContentID *int
IMDbID string
PreferredMethod string // auto | torrent | debrid | usenet
// Runtime state
Status TaskStatus
Mode string // download | stream
ResolvedMethod DownloadMethod
TriedMethods []DownloadMethod
DownloadedBytes int64
TotalBytes int64
SpeedBps int64
ETA int
FileName string
FilePath string
StreamURL string
ErrorMessage string
// Timestamps
ClaimedAt time.Time
StartedAt time.Time
CompletedAt time.Time
}
// NewTaskFromAgent creates a Task from a server-claimed agent.Task.
func NewTaskFromAgent(at agent.Task) *Task {
mode := at.Mode
if mode == "" {
mode = "download"
}
return &Task{
ID: at.ID,
InfoHash: at.InfoHash,
Title: at.Title,
ContentID: at.ContentID,
IMDbID: at.IMDbID,
PreferredMethod: at.PreferredMethod,
Mode: mode,
Status: StatusClaimed,
ClaimedAt: time.Now(),
}
}
// Transition validates and performs a state transition.
func (t *Task) Transition(to TaskStatus) error {
t.mu.Lock()
defer t.mu.Unlock()
allowed, ok := validTransitions[t.Status]
if !ok {
return fmt.Errorf("no transitions from %s", t.Status)
}
for _, a := range allowed {
if a == to {
t.Status = to
if to == StatusDownloading {
t.StartedAt = time.Now()
}
if to == StatusCompleted || to == StatusFailed {
t.CompletedAt = time.Now()
}
return nil
}
}
return fmt.Errorf("invalid transition: %s -> %s", t.Status, to)
}
// GetStatus returns current status thread-safely.
func (t *Task) GetStatus() TaskStatus {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Status
}
// SetStreamURL sets the stream URL thread-safely.
func (t *Task) SetStreamURL(url string) {
t.mu.Lock()
defer t.mu.Unlock()
t.StreamURL = url
}
// GetStreamURL returns the stream URL thread-safely.
func (t *Task) GetStreamURL() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.StreamURL
}
// UpdateProgress updates download metrics thread-safely.
func (t *Task) UpdateProgress(p Progress) {
t.mu.Lock()
defer t.mu.Unlock()
t.DownloadedBytes = p.DownloadedBytes
t.TotalBytes = p.TotalBytes
t.SpeedBps = p.SpeedBps
t.ETA = p.ETA
if p.FileName != "" {
t.FileName = p.FileName
}
}
// Percent returns download progress as 0-100.
func (t *Task) Percent() int {
t.mu.RLock()
defer t.mu.RUnlock()
if t.TotalBytes <= 0 {
return 0
}
p := int(float64(t.DownloadedBytes) / float64(t.TotalBytes) * 100)
if p > 100 {
return 100
}
return p
}
// ToStatusUpdate converts task state to an API status update.
func (t *Task) ToStatusUpdate() agent.StatusUpdate {
t.mu.RLock()
defer t.mu.RUnlock()
apiStatus := ""
switch t.Status {
case StatusResolving, StatusDownloading, StatusVerifying, StatusOrganizing, StatusSeeding:
apiStatus = "downloading"
case StatusCompleted:
apiStatus = "completed"
case StatusFailed:
apiStatus = "failed"
}
return agent.StatusUpdate{
TaskID: t.ID,
Status: apiStatus,
Progress: t.Percent(),
DownloadedBytes: t.DownloadedBytes,
TotalBytes: t.TotalBytes,
SpeedBps: t.SpeedBps,
ETA: t.ETA,
ResolvedMethod: string(t.ResolvedMethod),
FileName: t.FileName,
FilePath: t.FilePath,
StreamURL: t.StreamURL,
ErrorMessage: t.ErrorMessage,
}
}
// MagnetURI builds a magnet link from the info hash.
func (t *Task) MagnetURI() string {
return "magnet:?xt=urn:btih:" + t.InfoHash
}
// HasUntried returns true if there are download methods not yet attempted.
func (t *Task) HasUntried(available []DownloadMethod) bool {
for _, m := range available {
tried := false
for _, tm := range t.TriedMethods {
if tm == m {
tried = true
break
}
}
if !tried {
return true
}
}
return false
}

View file

@ -0,0 +1,190 @@
package engine
import (
"testing"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
func TestNewTaskFromAgent(t *testing.T) {
at := agent.Task{
ID: "uuid-123",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "The Matrix (1999)",
PreferredMethod: "auto",
}
task := NewTaskFromAgent(at)
if task.ID != "uuid-123" {
t.Errorf("ID = %q, want uuid-123", task.ID)
}
if task.Status != StatusClaimed {
t.Errorf("Status = %q, want claimed", task.Status)
}
if task.ClaimedAt.IsZero() {
t.Error("ClaimedAt should be set")
}
}
func TestTransitionValid(t *testing.T) {
transitions := []struct {
from TaskStatus
to TaskStatus
}{
{StatusClaimed, StatusResolving},
{StatusResolving, StatusDownloading},
{StatusDownloading, StatusVerifying},
{StatusVerifying, StatusOrganizing},
{StatusOrganizing, StatusCompleted},
}
for _, tt := range transitions {
t.Run(string(tt.from)+"->"+string(tt.to), func(t *testing.T) {
task := &Task{Status: tt.from}
if err := task.Transition(tt.to); err != nil {
t.Errorf("valid transition %s -> %s failed: %v", tt.from, tt.to, err)
}
if task.Status != tt.to {
t.Errorf("Status = %q, want %q", task.Status, tt.to)
}
})
}
}
func TestTransitionInvalid(t *testing.T) {
invalid := []struct {
from TaskStatus
to TaskStatus
}{
{StatusPending, StatusDownloading},
{StatusClaimed, StatusCompleted},
{StatusCompleted, StatusDownloading},
{StatusFailed, StatusCompleted},
{StatusVerifying, StatusResolving},
}
for _, tt := range invalid {
t.Run(string(tt.from)+"->"+string(tt.to), func(t *testing.T) {
task := &Task{Status: tt.from}
if err := task.Transition(tt.to); err == nil {
t.Errorf("invalid transition %s -> %s should fail", tt.from, tt.to)
}
})
}
}
func TestTransitionDownloadingSetsStartedAt(t *testing.T) {
task := &Task{Status: StatusResolving}
task.Transition(StatusDownloading)
if task.StartedAt.IsZero() {
t.Error("StartedAt should be set on downloading transition")
}
}
func TestTransitionCompletedSetsCompletedAt(t *testing.T) {
task := &Task{Status: StatusOrganizing}
task.Transition(StatusCompleted)
if task.CompletedAt.IsZero() {
t.Error("CompletedAt should be set")
}
}
func TestTransitionFailedSetsCompletedAt(t *testing.T) {
task := &Task{Status: StatusResolving}
task.Transition(StatusFailed)
if task.CompletedAt.IsZero() {
t.Error("CompletedAt should be set on failure")
}
}
func TestFallbackTransition(t *testing.T) {
// downloading -> resolving (fallback)
task := &Task{Status: StatusDownloading}
if err := task.Transition(StatusResolving); err != nil {
t.Errorf("fallback transition should work: %v", err)
}
}
func TestCancelFromMultipleStates(t *testing.T) {
for _, from := range []TaskStatus{StatusClaimed, StatusResolving, StatusDownloading} {
t.Run(string(from), func(t *testing.T) {
task := &Task{Status: from}
if err := task.Transition(StatusCancelled); err != nil {
t.Errorf("cancel from %s should work: %v", from, err)
}
})
}
}
func TestPercent(t *testing.T) {
task := &Task{DownloadedBytes: 500, TotalBytes: 1000}
if p := task.Percent(); p != 50 {
t.Errorf("Percent = %d, want 50", p)
}
task2 := &Task{DownloadedBytes: 0, TotalBytes: 0}
if p := task2.Percent(); p != 0 {
t.Errorf("Percent = %d, want 0 for zero total", p)
}
}
func TestUpdateProgress(t *testing.T) {
task := &Task{}
task.UpdateProgress(Progress{
DownloadedBytes: 1024,
TotalBytes: 2048,
SpeedBps: 512,
ETA: 2,
FileName: "movie.mkv",
})
if task.DownloadedBytes != 1024 {
t.Errorf("DownloadedBytes = %d", task.DownloadedBytes)
}
if task.FileName != "movie.mkv" {
t.Errorf("FileName = %q", task.FileName)
}
}
func TestToStatusUpdate(t *testing.T) {
task := &Task{
ID: "task-123",
Status: StatusDownloading,
ResolvedMethod: MethodTorrent,
DownloadedBytes: 500,
TotalBytes: 1000,
SpeedBps: 100,
ETA: 5,
FileName: "file.mkv",
}
update := task.ToStatusUpdate()
if update.TaskID != "task-123" {
t.Errorf("TaskID = %q", update.TaskID)
}
if update.Status != "downloading" {
t.Errorf("Status = %q, want downloading", update.Status)
}
if update.Progress != 50 {
t.Errorf("Progress = %d, want 50", update.Progress)
}
if update.ResolvedMethod != "torrent" {
t.Errorf("ResolvedMethod = %q", update.ResolvedMethod)
}
}
func TestMagnetURI(t *testing.T) {
task := &Task{InfoHash: "abc123"}
m := task.MagnetURI()
if m != "magnet:?xt=urn:btih:abc123" {
t.Errorf("MagnetURI = %q", m)
}
}
func TestHasUntried(t *testing.T) {
task := &Task{TriedMethods: []DownloadMethod{MethodTorrent}}
if !task.HasUntried([]DownloadMethod{MethodTorrent, MethodDebrid}) {
t.Error("should have untried (debrid)")
}
if task.HasUntried([]DownloadMethod{MethodTorrent}) {
t.Error("all methods tried")
}
}

433
internal/engine/torrent.go Normal file
View file

@ -0,0 +1,433 @@
package engine
import (
"context"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
"golang.org/x/time/rate"
)
var defaultTrackers = []string{
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://open.demonii.com:1337/announce",
"udp://exodus.desync.com:6969/announce",
}
// TorrentConfig holds settings for the BitTorrent downloader.
type TorrentConfig struct {
DataDir string
StallTimeout time.Duration // no progress for this long = stall (default 90s)
MaxTimeout time.Duration // absolute maximum per torrent (default 30m)
MaxDownloadRate int64 // bytes/s, 0 = unlimited
MaxUploadRate int64 // bytes/s, 0 = unlimited
SeedEnabled bool
SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
SeedTime time.Duration // min seed time after completion (default 0)
}
// TorrentDownloader downloads torrents via BitTorrent P2P.
type TorrentDownloader struct {
client *torrent.Client
cfg TorrentConfig
activeMu sync.Mutex
active map[string]*torrent.Torrent // taskID -> torrent handle
}
// NewTorrentDownloader creates a BitTorrent downloader with a long-lived client.
func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
if cfg.StallTimeout == 0 {
cfg.StallTimeout = 90 * time.Second
}
if cfg.MaxTimeout == 0 {
cfg.MaxTimeout = 30 * time.Minute
}
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
tcfg := torrent.NewDefaultClientConfig()
tcfg.DataDir = cfg.DataDir
tcfg.Seed = cfg.SeedEnabled
tcfg.NoUpload = !cfg.SeedEnabled
tcfg.ListenPort = 0
tcfg.Logger = alog.Default.FilterLevel(alog.Disabled)
if cfg.MaxDownloadRate > 0 {
burst := int(cfg.MaxDownloadRate)
if burst < 256*1024 {
burst = 256 * 1024
}
tcfg.DownloadRateLimiter = rate.NewLimiter(rate.Limit(cfg.MaxDownloadRate), burst)
}
if cfg.MaxUploadRate > 0 {
burst := int(cfg.MaxUploadRate)
if burst < 256*1024 {
burst = 256 * 1024
}
tcfg.UploadRateLimiter = rate.NewLimiter(rate.Limit(cfg.MaxUploadRate), burst)
}
client, err := torrent.NewClient(tcfg)
if err != nil {
return nil, fmt.Errorf("create torrent client: %w", err)
}
return &TorrentDownloader{
client: client,
cfg: cfg,
active: make(map[string]*torrent.Torrent),
}, nil
}
func (d *TorrentDownloader) Method() DownloadMethod { return MethodTorrent }
func (d *TorrentDownloader) Available(_ context.Context, task *Task) (bool, error) {
return task.InfoHash != "", nil
}
func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error) {
magnet := buildMagnet(task.InfoHash)
t, err := d.client.AddMagnet(magnet)
if err != nil {
return nil, fmt.Errorf("add magnet: %w", err)
}
// Track active torrent
d.activeMu.Lock()
d.active[task.ID] = t
d.activeMu.Unlock()
cleanup := func() {
d.activeMu.Lock()
delete(d.active, task.ID)
d.activeMu.Unlock()
if !d.cfg.SeedEnabled {
t.Drop()
}
}
// 1. Wait for metadata
log.Printf("[%s] waiting for metadata...", task.ID[:8])
metaCtx, metaCancel := context.WithTimeout(ctx, d.cfg.StallTimeout)
defer metaCancel()
select {
case <-t.GotInfo():
log.Printf("[%s] metadata received: %s (%d files)", task.ID[:8], t.Name(), len(t.Files()))
case <-metaCtx.Done():
cleanup()
return nil, fmt.Errorf("metadata timeout after %s", d.cfg.StallTimeout)
}
// 2. Select files to download (prefer largest video + matching subs)
totalBytes, fileName := d.selectFiles(t, task.ID)
log.Printf("[%s] downloading %s (%s)", task.ID[:8], fileName, formatBytes(totalBytes))
// 3. Poll progress with stall detection
result, err := d.pollDownload(ctx, t, task, totalBytes, fileName, progressCh)
if err != nil {
cleanup()
return nil, err
}
// 4. Determine file path
filePath := filepath.Join(d.cfg.DataDir, fileName)
if _, statErr := os.Stat(filePath); statErr != nil {
filePath = filepath.Join(d.cfg.DataDir, t.Name())
}
result.FilePath = filePath
result.FileName = fileName
result.Method = MethodTorrent
result.Size = totalBytes
// If seeding enabled, keep alive (don't cleanup).
// The manager handles seeding lifecycle.
if !d.cfg.SeedEnabled {
cleanup()
}
return result, nil
}
func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent, task *Task, totalBytes int64, fileName string, progressCh chan<- Progress) (*Result, error) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
deadline := time.After(d.cfg.MaxTimeout)
lastBytesAt := time.Now()
lastBytes := int64(0)
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("cancelled")
case <-deadline:
return nil, fmt.Errorf("max timeout %s exceeded", d.cfg.MaxTimeout)
case <-ticker.C:
downloaded := t.BytesCompleted()
now := time.Now()
// Speed calculation
speed := downloaded - lastBytes
if speed < 0 {
speed = 0
}
// Stall detection (dual-level like TrueSpec)
if downloaded > lastBytes {
lastBytesAt = now
lastBytes = downloaded
} else if now.Sub(lastBytesAt) > d.cfg.StallTimeout {
stats := t.Stats()
return nil, fmt.Errorf("stalled: no progress for %s (peers: %d, seeds: %d)",
d.cfg.StallTimeout, stats.ActivePeers, stats.ConnectedSeeders)
}
// ETA
var eta int
if speed > 0 {
remaining := totalBytes - downloaded
eta = int(remaining / speed)
}
// Peer stats
stats := t.Stats()
// Report progress
p := Progress{
DownloadedBytes: downloaded,
TotalBytes: totalBytes,
SpeedBps: speed,
ETA: eta,
Peers: stats.ActivePeers,
Seeds: stats.ConnectedSeeders,
FileName: fileName,
}
task.UpdateProgress(p)
select {
case progressCh <- p:
default: // don't block if channel full
}
// Check completion
if downloaded >= totalBytes {
log.Printf("[%s] download complete: %s", task.ID[:8], fileName)
return &Result{}, nil
}
}
}
}
// Pause drops the torrent handle but keeps partial files on disk for resume.
func (d *TorrentDownloader) Pause(taskID string) error {
d.activeMu.Lock()
t, ok := d.active[taskID]
delete(d.active, taskID)
d.activeMu.Unlock()
if !ok {
return nil
}
t.Drop()
log.Printf("[%s] paused (files kept for resume)", taskID[:8])
return nil
}
// Cancel drops the torrent handle and removes partial files from disk.
func (d *TorrentDownloader) Cancel(taskID string) error {
d.activeMu.Lock()
t, ok := d.active[taskID]
delete(d.active, taskID)
d.activeMu.Unlock()
if !ok {
return nil
}
name := t.Name()
t.Drop()
if name != "" {
path, err := safePath(d.cfg.DataDir, name)
if err != nil {
log.Printf("[%s] cancel blocked: %v", taskID[:8], err)
return nil
}
if fi, statErr := os.Stat(path); statErr == nil {
if fi.IsDir() {
os.RemoveAll(path)
} else {
os.Remove(path)
}
log.Printf("[%s] cleaned up partial download: %s", taskID[:8], name)
}
}
return nil
}
func (d *TorrentDownloader) Shutdown(ctx context.Context) error {
d.activeMu.Lock()
for id, t := range d.active {
t.Drop()
delete(d.active, id)
}
d.activeMu.Unlock()
errs := d.client.Close()
if len(errs) > 0 {
return fmt.Errorf("close client: %v", errs[0])
}
return nil
}
// StartStream starts an HTTP server for an active torrent download.
// It selects the largest video file and serves it via HTTP Range requests.
// Returns the running server (caller is responsible for shutdown).
func (d *TorrentDownloader) StartStream(taskID string) (*StreamServer, error) {
d.activeMu.Lock()
t, ok := d.active[taskID]
d.activeMu.Unlock()
if !ok {
return nil, fmt.Errorf("no active torrent for task %s", taskID[:8])
}
// Select largest video file
files := t.Files()
var video *torrent.File
for _, f := range files {
ext := strings.ToLower(filepath.Ext(f.DisplayPath()))
if VideoExts[ext] && (video == nil || f.Length() > video.Length()) {
video = f
}
}
if video == nil {
// No video — use largest file
for _, f := range files {
if video == nil || f.Length() > video.Length() {
video = f
}
}
}
if video == nil {
return nil, fmt.Errorf("torrent has no files")
}
srv := NewStreamServerFromFile(video, 0)
url, err := srv.Start(context.Background())
if err != nil {
return nil, fmt.Errorf("start stream server: %w", err)
}
log.Printf("[%s] stream started: %s → %s", taskID[:8], filepath.Base(video.DisplayPath()), url)
return srv, nil
}
// VideoExts is the canonical set of video file extensions used for file selection.
var VideoExts = map[string]bool{
".mkv": true, ".mp4": true, ".avi": true, ".m4v": true,
".wmv": true, ".ts": true, ".webm": true, ".mov": true,
".mpg": true, ".mpeg": true, ".vob": true, ".flv": true,
}
var subExts = map[string]bool{
".srt": true, ".ass": true, ".sub": true, ".ssa": true, ".vtt": true,
}
// selectFiles picks the largest video file + matching subtitles.
// Falls back to downloading everything if no video file is found.
// Returns the total bytes to download and the primary file name.
func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (totalBytes int64, fileName string) {
files := t.Files()
if len(files) <= 1 {
t.DownloadAll()
return t.Length(), t.Name()
}
// Find largest video file
var video *torrent.File
for _, f := range files {
ext := strings.ToLower(filepath.Ext(f.DisplayPath()))
if VideoExts[ext] && (video == nil || f.Length() > video.Length()) {
video = f
}
}
if video == nil {
// No video (music, software, etc.) — download everything
t.DownloadAll()
return t.Length(), t.Name()
}
// Download only the video
video.Download()
totalBytes = video.Length()
fileName = video.DisplayPath()
// Also download matching subtitles
videoBase := strings.TrimSuffix(video.DisplayPath(), filepath.Ext(video.DisplayPath()))
var subCount int
for _, f := range files {
ext := strings.ToLower(filepath.Ext(f.DisplayPath()))
if subExts[ext] {
fBase := strings.TrimSuffix(f.DisplayPath(), filepath.Ext(f.DisplayPath()))
// Match by prefix (handles Movie.en.srt, Movie.es.srt)
if strings.HasPrefix(fBase, videoBase) || filepath.Dir(f.DisplayPath()) == filepath.Dir(video.DisplayPath()) {
f.Download()
totalBytes += f.Length()
subCount++
}
}
}
skipped := len(files) - 1 - subCount
if skipped > 0 {
log.Printf("[%s] selected: %s (%s) + %d subs, skipped %d files",
taskID[:8], filepath.Base(fileName), formatBytes(video.Length()), subCount, skipped)
}
return totalBytes, fileName
}
func buildMagnet(infoHash string) string {
params := []string{"xt=urn:btih:" + infoHash}
for _, tracker := range defaultTrackers {
params = append(params, "tr="+url.QueryEscape(tracker))
}
return "magnet:?" + strings.Join(params, "&")
}
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), []string{"KB", "MB", "GB", "TB"}[exp])
}

26
internal/engine/usenet.go Normal file
View file

@ -0,0 +1,26 @@
package engine
import (
"context"
"fmt"
)
// UsenetDownloader downloads via Usenet/NZB protocol.
// Currently a stub — not implemented.
type UsenetDownloader struct{}
func NewUsenetDownloader() *UsenetDownloader { return &UsenetDownloader{} }
func (u *UsenetDownloader) Method() DownloadMethod { return MethodUsenet }
func (u *UsenetDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return false, nil // always unavailable until implemented
}
func (u *UsenetDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
return nil, fmt.Errorf("usenet download not implemented yet (coming in a future release)")
}
func (u *UsenetDownloader) Pause(_ string) error { return nil }
func (u *UsenetDownloader) Cancel(_ string) error { return nil }
func (u *UsenetDownloader) Shutdown(_ context.Context) error { return nil }

59
internal/engine/verify.go Normal file
View file

@ -0,0 +1,59 @@
package engine
import (
"fmt"
"os"
"path/filepath"
)
// verify checks that a downloaded file or directory is valid.
func verify(result *Result) error {
if result == nil || result.FilePath == "" {
return fmt.Errorf("no file path in result")
}
fi, err := os.Stat(result.FilePath)
if err != nil {
return fmt.Errorf("file not found: %w", err)
}
// Get actual size — handle both files and directories (multi-file torrents)
var actualSize int64
if fi.IsDir() {
actualSize, err = dirSize(result.FilePath)
if err != nil {
return fmt.Errorf("could not calculate dir size: %w", err)
}
} else {
actualSize = fi.Size()
}
if actualSize == 0 {
return fmt.Errorf("download is empty: %s", result.FilePath)
}
// If we know the expected size, check within 2% tolerance
if result.Size > 0 {
tolerance := int64(float64(result.Size) * 0.02)
if actualSize < result.Size-tolerance {
return fmt.Errorf("size mismatch: expected %d, got %d", result.Size, actualSize)
}
}
return nil
}
// dirSize returns total size of all files in a directory.
func dirSize(path string) (int64, error) {
var total int64
err := filepath.Walk(path, func(_ string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if !fi.IsDir() {
total += fi.Size()
}
return nil
})
return total, err
}

View file

@ -0,0 +1,71 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestVerifyNilResult(t *testing.T) {
if err := verify(nil); err == nil {
t.Error("expected error for nil result")
}
}
func TestVerifyEmptyPath(t *testing.T) {
if err := verify(&Result{}); err == nil {
t.Error("expected error for empty path")
}
}
func TestVerifyMissingFile(t *testing.T) {
err := verify(&Result{FilePath: "/nonexistent/file.mkv"})
if err == nil {
t.Error("expected error for missing file")
}
}
func TestVerifyEmptyFile(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "empty.mkv")
os.WriteFile(path, []byte{}, 0o644)
err := verify(&Result{FilePath: path})
if err == nil {
t.Error("expected error for empty file")
}
}
func TestVerifyValidFile(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "movie.mkv")
os.WriteFile(path, make([]byte, 1024), 0o644)
err := verify(&Result{FilePath: path, Size: 1024})
if err != nil {
t.Errorf("valid file should pass: %v", err)
}
}
func TestVerifySizeMismatch(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "movie.mkv")
os.WriteFile(path, make([]byte, 500), 0o644)
err := verify(&Result{FilePath: path, Size: 1000})
if err == nil {
t.Error("expected error for size mismatch")
}
}
func TestVerifyNoExpectedSize(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "movie.mkv")
os.WriteFile(path, make([]byte, 1024), 0o644)
// Size=0 means unknown, should pass
err := verify(&Result{FilePath: path, Size: 0})
if err != nil {
t.Errorf("no expected size should pass: %v", err)
}
}

114
internal/parser/torrent.go Normal file
View file

@ -0,0 +1,114 @@
package parser
import (
"net/url"
"regexp"
"strings"
)
// ParsedTorrent contains information extracted from a magnet URI, hash, or torrent name.
type ParsedTorrent struct {
InfoHash string
Name string
Quality string
Codec string
Year string
IsMagnet bool
}
var (
hashRegex = regexp.MustCompile(`^[a-fA-F0-9]{40}$`)
qualityRegex = regexp.MustCompile(`(?i)(2160p|1080p|720p|480p|4K|UHD)`)
codecRegex = regexp.MustCompile(`(?i)(x264|x265|h\.?264|h\.?265|HEVC|AVC|AV1|VP9|XviD|DivX)`)
yearRegex = regexp.MustCompile(`(?:^|[\s.(])((?:19|20)\d{2})(?:[\s.)]|$)`)
artifactsRegex = regexp.MustCompile(`(?i)(BluRay|BDRip|HDRip|WEBRip|WEB-DL|HDTV|DVDRip|BRRip|CAM|TS|TC|PROPER|REPACK|REMASTERED|REMUX|EXTENDED|UNRATED|IMAX|DUAL|MULTi|AAC|DTS|DD5\.1|AC3|Atmos|FLAC|EAC3|10bit|HDR10?\+?|DV|DoVi|SDR|YTS|YIFY|RARBG|NTG|SPARKS|AMIABLE|FGT|\[.*?\]|\(.*?\))`)
whitespaceRegex = regexp.MustCompile(`\s+`)
)
// Parse parses a magnet URI, info hash, or torrent name.
func Parse(input string) ParsedTorrent {
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "magnet:") {
return parseMagnet(input)
}
if hashRegex.MatchString(input) {
return ParsedTorrent{
InfoHash: strings.ToLower(input),
}
}
// Treat as a torrent name/filename
return parseName(input)
}
func parseMagnet(uri string) ParsedTorrent {
result := ParsedTorrent{IsMagnet: true}
u, err := url.Parse(uri)
if err != nil {
return result
}
xt := u.Query().Get("xt")
if strings.HasPrefix(xt, "urn:btih:") {
result.InfoHash = strings.ToLower(strings.TrimPrefix(xt, "urn:btih:"))
}
dn := u.Query().Get("dn")
if dn != "" {
result.Name = dn
parsed := parseName(dn)
result.Quality = parsed.Quality
result.Codec = parsed.Codec
result.Year = parsed.Year
}
return result
}
func parseName(name string) ParsedTorrent {
result := ParsedTorrent{Name: name}
if m := qualityRegex.FindString(name); m != "" {
result.Quality = strings.ToLower(m)
if result.Quality == "4k" || result.Quality == "uhd" {
result.Quality = "2160p"
}
}
if m := codecRegex.FindString(name); m != "" {
result.Codec = m
}
if m := yearRegex.FindStringSubmatch(name); len(m) > 1 {
result.Year = m[1]
}
return result
}
// ExtractSearchQuery cleans a torrent name to use as a search query.
func ExtractSearchQuery(name string) string {
q := name
// Remove common release artifacts
for _, re := range []*regexp.Regexp{qualityRegex, codecRegex} {
q = re.ReplaceAllString(q, "")
}
q = artifactsRegex.ReplaceAllString(q, "")
// Replace dots and underscores with spaces
q = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(q)
// Remove year
q = yearRegex.ReplaceAllString(q, " ")
// Collapse whitespace
q = whitespaceRegex.ReplaceAllString(q, " ")
q = strings.TrimSpace(q)
return q
}

View file

@ -0,0 +1,98 @@
package parser
import (
"strings"
"testing"
)
func TestParseMagnet(t *testing.T) {
magnet := "magnet:?xt=urn:btih:ABC123DEF456ABC123DEF456ABC123DEF456ABC1&dn=Oppenheimer.2023.1080p.BluRay.x265"
p := Parse(magnet)
if !p.IsMagnet {
t.Error("expected IsMagnet=true")
}
if p.InfoHash != "abc123def456abc123def456abc123def456abc1" {
t.Errorf("InfoHash = %q, want lowercase 40-char hash", p.InfoHash)
}
if p.Quality != "1080p" {
t.Errorf("Quality = %q, want 1080p", p.Quality)
}
if p.Codec != "x265" {
t.Errorf("Codec = %q, want x265", p.Codec)
}
if p.Year != "2023" {
t.Errorf("Year = %q, want 2023", p.Year)
}
}
func TestParseInfoHash(t *testing.T) {
hash := "abc123def456abc123def456abc123def456abc1" // exactly 40 hex chars
p := Parse(hash)
if p.IsMagnet {
t.Error("expected IsMagnet=false for plain hash")
}
if p.InfoHash != hash {
t.Errorf("InfoHash = %q, want %q", p.InfoHash, hash)
}
}
func TestParseName(t *testing.T) {
tests := []struct {
input string
quality string
codec string
year string
}{
{"The.Matrix.1999.1080p.BluRay.x264", "1080p", "x264", "1999"},
{"Oppenheimer.2023.2160p.UHD.BluRay.x265", "2160p", "x265", "2023"},
{"Movie.720p.HDTV.HEVC", "720p", "HEVC", ""},
{"Show.480p.WEB.AV1", "480p", "AV1", ""},
{"No.Quality.Info.Here", "", "", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
p := Parse(tt.input)
if p.Quality != tt.quality {
t.Errorf("Quality = %q, want %q", p.Quality, tt.quality)
}
if p.Codec != tt.codec {
t.Errorf("Codec = %q, want %q", p.Codec, tt.codec)
}
if p.Year != tt.year {
t.Errorf("Year = %q, want %q", p.Year, tt.year)
}
})
}
}
func TestExtractSearchQuery(t *testing.T) {
tests := []struct {
input string
}{
{"The.Matrix.1999.1080p.BluRay.x264-GROUP"},
{"Oppenheimer.2023.2160p.UHD.BluRay.x265.DTS-HD"},
{"Breaking.Bad.S01E01.720p.WEB-DL"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := ExtractSearchQuery(tt.input)
if got == "" {
t.Errorf("ExtractSearchQuery(%q) returned empty string", tt.input)
}
// Should not contain quality/codec artifacts
if strings.Contains(got, "1080p") || strings.Contains(got, "2160p") || strings.Contains(got, "720p") {
t.Errorf("ExtractSearchQuery(%q) = %q, should not contain resolution", tt.input, got)
}
if strings.Contains(got, "x264") || strings.Contains(got, "x265") {
t.Errorf("ExtractSearchQuery(%q) = %q, should not contain codec", tt.input, got)
}
if strings.Contains(got, "BluRay") {
t.Errorf("ExtractSearchQuery(%q) = %q, should not contain source", tt.input, got)
}
})
}
}

191
internal/ui/format.go Normal file
View file

@ -0,0 +1,191 @@
package ui
import (
"fmt"
"math"
"strconv"
"strings"
"time"
)
// FormatSize converts bytes to human-readable format.
func FormatSize(sizeBytes *int64) string {
if sizeBytes == nil {
return "?"
}
return FormatBytes(*sizeBytes)
}
// FormatBytes converts bytes to human-readable format.
func FormatBytes(b int64) string {
if b == 0 {
return "0 B"
}
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
val := float64(b) / float64(div)
units := []string{"KB", "MB", "GB", "TB"}
if exp >= len(units) {
exp = len(units) - 1
}
return fmt.Sprintf("%.1f %s", val, units[exp])
}
// QualityIndicator returns a colored emoji for quality score (0-100 scale).
func QualityIndicator(score *int) string {
if score == nil {
return " "
}
s := *score
switch {
case s >= 70:
return "🟢"
case s >= 40:
return "🟡"
default:
return "🔴"
}
}
// SeedHealthIndicator returns a colored emoji for seed count.
func SeedHealthIndicator(seeds int) string {
switch {
case seeds > 100:
return "🟢"
case seeds >= 10:
return "🟡"
default:
return "🔴"
}
}
// FormatRating returns a display string for a rating.
func FormatRating(rating *string) string {
if rating == nil {
return "-"
}
return *rating
}
// FormatYear returns a display string for a year.
func FormatYear(year *int) string {
if year == nil {
return "-"
}
return strconv.Itoa(*year)
}
// FormatContentType returns a short display for content type.
func FormatContentType(ct string) string {
switch strings.ToLower(ct) {
case "movie":
return "Movie"
case "show":
return "Show"
default:
return ct
}
}
// Ptr returns a pointer to a value. Useful for tests.
func Ptr[T any](v T) *T {
return &v
}
// TruncateString truncates a string to maxLen with ellipsis.
func TruncateString(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 3 {
return string(runes[:maxLen])
}
return string(runes[:maxLen-3]) + "..."
}
// FormatLanguages joins language codes.
func FormatLanguages(langs []string) string {
if len(langs) == 0 {
return "-"
}
return strings.Join(langs, ", ")
}
// FormatSeedRatio returns a display for seed/leech ratio.
func FormatSeedRatio(seeders, leechers int) string {
if leechers == 0 {
if seeders == 0 {
return "0:0"
}
return fmt.Sprintf("%d:0", seeders)
}
ratio := float64(seeders) / float64(leechers)
return fmt.Sprintf("%.0f:1", math.Round(ratio))
}
// FormatTimeAgo returns a human-readable "time ago" string.
func FormatTimeAgo(t string) string {
parsed, err := time.Parse(time.RFC3339, t)
if err != nil {
return t
}
diff := time.Since(parsed)
switch {
case diff < time.Minute:
return "just now"
case diff < time.Hour:
m := int(diff.Minutes())
return fmt.Sprintf("%dm ago", m)
case diff < 24*time.Hour:
h := int(diff.Hours())
return fmt.Sprintf("%dh ago", h)
case diff < 30*24*time.Hour:
d := int(diff.Hours() / 24)
return fmt.Sprintf("%dd ago", d)
default:
m := int(diff.Hours() / 24 / 30)
return fmt.Sprintf("%dmo ago", m)
}
}
// FormatNumber formats a number with thousands separator.
func FormatNumber(n int) string {
negative := n < 0
if negative {
n = -n
}
s := strconv.Itoa(n)
if len(s) <= 3 {
if negative {
return "-" + s
}
return s
}
var result []byte
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result = append(result, ',')
}
result = append(result, byte(c))
}
if negative {
return "-" + string(result)
}
return string(result)
}
// StringOrDash returns the string value or "-" if nil.
func StringOrDash(s *string) string {
if s == nil {
return "-"
}
return *s
}

165
internal/ui/format_test.go Normal file
View file

@ -0,0 +1,165 @@
package ui
import (
"testing"
)
func TestFormatSize(t *testing.T) {
tests := []struct {
name string
input *int64
want string
}{
{"nil", nil, "?"},
{"zero", ptr(int64(0)), "0 B"},
{"bytes", ptr(int64(500)), "500 B"},
{"kilobytes", ptr(int64(1024)), "1.0 KB"},
{"megabytes", ptr(int64(52428800)), "50.0 MB"},
{"gigabytes", ptr(int64(4294967296)), "4.0 GB"},
{"terabyte", ptr(int64(1099511627776)), "1.0 TB"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatSize(tt.input)
if got != tt.want {
t.Errorf("FormatSize(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFormatBytes(t *testing.T) {
tests := []struct {
input int64
want string
}{
{0, "0 B"},
{100, "100 B"},
{1024, "1.0 KB"},
{1048576, "1.0 MB"},
{1073741824, "1.0 GB"},
{1099511627776, "1.0 TB"},
{3221225472, "3.0 GB"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := FormatBytes(tt.input)
if got != tt.want {
t.Errorf("FormatBytes(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFormatYear(t *testing.T) {
tests := []struct {
input *int
want string
}{
{nil, "-"},
{intPtr(2023), "2023"},
{intPtr(1999), "1999"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := FormatYear(tt.input)
if got != tt.want {
t.Errorf("FormatYear(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFormatNumber(t *testing.T) {
tests := []struct {
input int
want string
}{
{0, "0"},
{999, "999"},
{1000, "1,000"},
{1234567, "1,234,567"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := FormatNumber(tt.input)
if got != tt.want {
t.Errorf("FormatNumber(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestTruncateString(t *testing.T) {
tests := []struct {
input string
maxLen int
want string
}{
{"short", 10, "short"},
{"exactly10!", 10, "exactly10!"},
{"this is too long", 10, "this is..."},
{"ab", 5, "ab"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := TruncateString(tt.input, tt.maxLen)
if got != tt.want {
t.Errorf("TruncateString(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
}
})
}
}
func TestQualityIndicator(t *testing.T) {
tests := []struct {
name string
input *int
want string
}{
{"nil", nil, " "},
{"low", intPtr(30), "🔴"},
{"medium", intPtr(60), "🟡"},
{"high", intPtr(80), "🟢"},
{"perfect", intPtr(100), "🟢"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := QualityIndicator(tt.input)
if got != tt.want {
t.Errorf("QualityIndicator(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestStringOrDash(t *testing.T) {
s := "hello"
if got := StringOrDash(&s); got != "hello" {
t.Errorf("StringOrDash(&hello) = %q, want hello", got)
}
if got := StringOrDash(nil); got != "-" {
t.Errorf("StringOrDash(nil) = %q, want -", got)
}
}
func TestFormatContentType(t *testing.T) {
if got := FormatContentType("movie"); got != "Movie" {
t.Errorf("FormatContentType(movie) = %q, want Movie", got)
}
if got := FormatContentType("show"); got != "Show" {
t.Errorf("FormatContentType(show) = %q, want Show", got)
}
if got := FormatContentType("other"); got != "other" {
t.Errorf("FormatContentType(other) = %q, want other", got)
}
}
func ptr[T any](v T) *T { return &v }
func intPtr(v int) *int { return &v }

424
internal/ui/table.go Normal file
View file

@ -0,0 +1,424 @@
package ui
import (
"fmt"
"io"
"os"
"strings"
"github.com/fatih/color"
"github.com/olekukonko/tablewriter"
tc "github.com/torrentclaw/go-client"
)
var (
titleColor = color.New(color.FgCyan, color.Bold)
headerColor = color.New(color.FgWhite, color.Bold)
successColor = color.New(color.FgGreen)
warnColor = color.New(color.FgYellow)
errorColor = color.New(color.FgRed)
dimColor = color.New(color.FgHiBlack)
boldColor = color.New(color.Bold)
)
// PrintSearchResults renders search results as a colored table.
func PrintSearchResults(resp *tc.SearchResponse) {
if len(resp.Results) == 0 {
warnColor.Println("No results found.")
return
}
fmt.Printf("\n")
dimColor.Printf(" %d results found (page %d)\n\n", resp.Total, resp.Page)
for _, r := range resp.Results {
printSearchResultEntry(os.Stdout, r)
}
}
func printSearchResultEntry(w io.Writer, r tc.SearchResult) {
year := FormatYear(r.Year)
titleColor.Fprintf(w, " %s (%s)", r.Title, year)
dimColor.Fprintf(w, " [%s]", FormatContentType(r.ContentType))
if r.RatingIMDb != nil {
fmt.Fprintf(w, " ⭐ %s", *r.RatingIMDb)
}
if len(r.Genres) > 0 {
dimColor.Fprintf(w, " %s", strings.Join(r.Genres, ", "))
}
fmt.Fprintln(w)
if len(r.Torrents) == 0 {
dimColor.Fprintln(w, " No torrents available")
fmt.Fprintln(w)
return
}
table := tablewriter.NewWriter(w)
table.SetHeader([]string{"", "Quality", "Size", "Seeds", "Source", "Codec", "Lang", "Score"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for _, t := range r.Torrents {
quality := StringOrDash(t.Quality)
size := FormatSize(t.SizeBytes)
seeds := fmt.Sprintf("%s %d", SeedHealthIndicator(t.Seeders), t.Seeders)
source := t.Source
codec := StringOrDash(t.Codec)
langs := FormatLanguages(t.Languages)
score := ""
if t.QualityScore != nil {
score = fmt.Sprintf("%s %d", QualityIndicator(t.QualityScore), *t.QualityScore)
}
table.Append([]string{" ", quality, size, seeds, source, codec, TruncateString(langs, 12), score})
}
table.Render()
fmt.Fprintln(w)
}
// PrintPopularItems renders popular items as a colored table.
func PrintPopularItems(items []tc.PopularItem) {
if len(items) == 0 {
warnColor.Println("No popular items found.")
return
}
fmt.Println()
headerColor.Println(" 🔥 Popular on unarr")
fmt.Println()
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"#", "Title", "Year", "Type", "IMDb", "Seeds"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for i, item := range items {
table.Append([]string{
fmt.Sprintf(" %d", i+1),
TruncateString(item.Title, 40),
FormatYear(item.Year),
FormatContentType(item.ContentType),
FormatRating(item.RatingIMDb),
FormatNumber(item.MaxSeeders),
})
}
table.Render()
fmt.Println()
}
// PrintRecentItems renders recent items as a colored table.
func PrintRecentItems(items []tc.RecentItem) {
if len(items) == 0 {
warnColor.Println("No recent items found.")
return
}
fmt.Println()
headerColor.Println(" 🆕 Recently Added")
fmt.Println()
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"#", "Title", "Year", "Type", "IMDb", "Added"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for i, item := range items {
table.Append([]string{
fmt.Sprintf(" %d", i+1),
TruncateString(item.Title, 40),
FormatYear(item.Year),
FormatContentType(item.ContentType),
FormatRating(item.RatingIMDb),
FormatTimeAgo(item.CreatedAt),
})
}
table.Render()
fmt.Println()
}
// PrintStats renders system statistics.
func PrintStats(stats *tc.StatsResponse) {
fmt.Println()
headerColor.Println(" 📊 unarr Statistics")
fmt.Println()
boldColor.Print(" Content: ")
fmt.Printf("%s movies, %s shows\n", FormatNumber(stats.Content.Movies), FormatNumber(stats.Content.Shows))
boldColor.Print(" Enriched: ")
fmt.Printf("%s with TMDb metadata\n", FormatNumber(stats.Content.TMDbEnriched))
boldColor.Print(" Torrents: ")
fmt.Printf("%s total, %s with seeders\n", FormatNumber(stats.Torrents.Total), FormatNumber(stats.Torrents.WithSeeders))
if len(stats.Torrents.BySource) > 0 {
fmt.Println()
boldColor.Println(" Sources:")
for source, count := range stats.Torrents.BySource {
fmt.Printf(" %-20s %s\n", source, FormatNumber(count))
}
}
if len(stats.RecentIngestions) > 0 {
fmt.Println()
boldColor.Println(" Recent Ingestions:")
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"", "Source", "Status", "Fetched", "New", "Updated"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for _, ing := range stats.RecentIngestions {
status := ing.Status
switch status {
case "completed":
status = successColor.Sprint("✓ done")
case "running":
status = warnColor.Sprint("⟳ running")
case "failed":
status = errorColor.Sprint("✗ failed")
}
table.Append([]string{
" ",
ing.Source,
status,
FormatNumber(ing.Fetched),
FormatNumber(ing.New),
FormatNumber(ing.Updated),
})
}
table.Render()
}
fmt.Println()
}
// PrintInspect renders the TrueSpec inspection output for a torrent.
func PrintInspect(title string, year string, torrents []tc.TorrentInfo, magnetURI string) {
fmt.Println()
titleColor.Printf(" 📋 %s", title)
if year != "" && year != "-" {
titleColor.Printf(" (%s)", year)
}
fmt.Println()
dimColor.Println(" " + strings.Repeat("─", len(title)+10))
if len(torrents) == 0 {
warnColor.Println(" No torrent details found.")
fmt.Println()
if magnetURI != "" {
dimColor.Println(" Magnet:")
fmt.Printf(" %s\n\n", magnetURI)
}
return
}
t := torrents[0]
printField := func(label, value string) {
boldColor.Printf(" %-12s", label+":")
fmt.Println(value)
}
printField("Quality", StringOrDash(t.Quality)+" "+StringOrDash(t.SourceType))
codecStr := StringOrDash(t.Codec)
if t.AudioCodec != nil {
codecStr += " / " + *t.AudioCodec
}
printField("Codec", codecStr)
printField("Size", FormatSize(t.SizeBytes))
printField("Seeds", fmt.Sprintf("%s %d | Leechers: %d", SeedHealthIndicator(t.Seeders), t.Seeders, t.Leechers))
printField("Languages", FormatLanguages(t.Languages))
printField("Source", t.Source)
if t.QualityScore != nil {
printField("Score", fmt.Sprintf("%s %d/100 (Quality Score)", QualityIndicator(t.QualityScore), *t.QualityScore))
}
printField("Health", fmt.Sprintf("%s (%s)", SeedHealthIndicator(t.Seeders), FormatSeedRatio(t.Seeders, t.Leechers)))
if t.HDRType != nil {
printField("HDR", *t.HDRType)
}
if t.ReleaseGroup != nil {
printField("Group", *t.ReleaseGroup)
}
var flags []string
if t.IsProper != nil && *t.IsProper {
flags = append(flags, "PROPER")
}
if t.IsRepack != nil && *t.IsRepack {
flags = append(flags, "REPACK")
}
if t.IsRemastered != nil && *t.IsRemastered {
flags = append(flags, "REMASTERED")
}
if len(flags) > 0 {
printField("Flags", strings.Join(flags, ", "))
}
fmt.Println()
if len(torrents) > 1 {
dimColor.Printf(" + %d more torrents available\n\n", len(torrents)-1)
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"", "Quality", "Size", "Seeds", "Source", "Score"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for i, tt := range torrents[1:] {
score := ""
if tt.QualityScore != nil {
score = fmt.Sprintf("%s %d", QualityIndicator(tt.QualityScore), *tt.QualityScore)
}
table.Append([]string{
fmt.Sprintf(" %d", i+2),
StringOrDash(tt.Quality),
FormatSize(tt.SizeBytes),
fmt.Sprintf("%s %d", SeedHealthIndicator(tt.Seeders), tt.Seeders),
tt.Source,
score,
})
}
table.Render()
fmt.Println()
}
if magnetURI != "" {
dimColor.Println(" Magnet:")
fmt.Printf(" %s\n\n", magnetURI)
}
}
// PrintWatchProviders renders streaming and torrent options.
func PrintWatchProviders(title string, year string, providers *tc.WatchProvidersResponse, torrents []tc.TorrentInfo) {
fmt.Println()
titleColor.Printf(" 🎬 %s", title)
if year != "" && year != "-" {
titleColor.Printf(" (%s)", year)
}
fmt.Printf(" — Where to watch:\n\n")
hasStreaming := false
if providers != nil {
if len(providers.Providers.Flatrate) > 0 {
hasStreaming = true
successColor.Println(" 📺 SUBSCRIPTION (included):")
for _, p := range providers.Providers.Flatrate {
fmt.Printf(" • %s\n", p.Name)
}
fmt.Println()
}
if len(providers.Providers.Free) > 0 {
hasStreaming = true
successColor.Println(" 🆓 FREE:")
for _, p := range providers.Providers.Free {
fmt.Printf(" • %s\n", p.Name)
}
fmt.Println()
}
if len(providers.Providers.Rent) > 0 {
hasStreaming = true
warnColor.Println(" 💰 RENT:")
for _, p := range providers.Providers.Rent {
fmt.Printf(" • %s\n", p.Name)
}
fmt.Println()
}
if len(providers.Providers.Buy) > 0 {
hasStreaming = true
warnColor.Println(" 🛒 BUY:")
for _, p := range providers.Providers.Buy {
fmt.Printf(" • %s\n", p.Name)
}
fmt.Println()
}
}
if !hasStreaming {
dimColor.Println(" 📺 No streaming options found for your country.")
fmt.Println()
}
if len(torrents) > 0 {
headerColor.Println(" 🏴‍☠️ TORRENT:")
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"", "Quality", "Size", "Seeds", "Source", "Score"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for _, t := range torrents {
score := ""
if t.QualityScore != nil {
score = fmt.Sprintf("%s %d", QualityIndicator(t.QualityScore), *t.QualityScore)
}
table.Append([]string{
" ",
StringOrDash(t.Quality),
FormatSize(t.SizeBytes),
fmt.Sprintf("%s %d", SeedHealthIndicator(t.Seeders), t.Seeders),
t.Source,
score,
})
}
table.Render()
fmt.Println()
}
if hasStreaming {
successColor.Println(" 💡 Available on streaming services above.")
}
fmt.Println()
}

29
lefthook.yml Normal file
View file

@ -0,0 +1,29 @@
# 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 ./...
go-build:
glob: "*.go"
run: go build ./...
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