diff --git a/README.md b/README.md
index 2d2904d..a89473a 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,72 @@ irm https://get.torrentclaw.com/install.ps1 | iex
brew install torrentclaw/tap/unarr
```
+### Docker
+
+```bash
+docker run -d --name unarr \
+ --restart unless-stopped \
+ --network host \
+ --read-only --memory 512m \
+ -v ~/.config/torrentclaw:/config \
+ -v ~/Media:/downloads \
+ torrentclaw/unarr
+```
+
+Run setup first to configure your API key:
+
+```bash
+docker run -it --rm \
+ -v ~/.config/torrentclaw:/config \
+ torrentclaw/unarr setup
+```
+
+### Docker Compose
+
+```bash
+mkdir -p torrentclaw && cd torrentclaw
+curl -fsSL https://raw.githubusercontent.com/torrentclaw/unarr/main/docker-compose.yml -o docker-compose.yml
+docker compose up -d
+```
+
+
+docker-compose.yml
+
+```yaml
+services:
+ unarr:
+ image: torrentclaw/unarr:latest
+ container_name: unarr
+ restart: unless-stopped
+ user: "1000:1000"
+ read_only: true
+ tmpfs:
+ - /tmp:size=64m,mode=1777
+ volumes:
+ - ./config:/config
+ - ~/Media:/downloads
+ - unarr-data:/data
+ environment:
+ - TZ=${TZ:-UTC}
+ # - UNARR_API_KEY=tc_your_key_here
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+ cpus: "2.0"
+ # Host network for full P2P performance
+ network_mode: host
+ # Or use bridge with ports:
+ # ports:
+ # - "6881-6889:6881-6889/tcp"
+ # - "6881-6889:6881-6889/udp"
+
+volumes:
+ unarr-data:
+```
+
+
+
### Go install
```bash
diff --git a/internal/cmd/auth_browser.go b/internal/cmd/auth_browser.go
index a74090f..186813a 100644
--- a/internal/cmd/auth_browser.go
+++ b/internal/cmd/auth_browser.go
@@ -8,11 +8,12 @@ import (
"net"
"net/http"
"net/url"
+ "os"
"sync"
"time"
)
-const browserAuthTimeout = 5 * time.Minute
+const browserAuthTimeout = 60 * time.Second
// browserAuth opens a browser for the user to authorize the CLI.
// Returns the API key on success, or an error if the flow fails/times out.
@@ -60,6 +61,14 @@ func browserAuth(apiURL string) (string, error) {
return
}
+ // Check if user rejected the authorization
+ if r.URL.Query().Get("rejected") == "1" {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(w, rejectedHTML)
+ errCh <- fmt.Errorf("authorization rejected by user")
+ return
+ }
+
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "No token received", http.StatusBadRequest)
@@ -88,37 +97,74 @@ func browserAuth(apiURL string) (string, error) {
}()
// Open browser
- authURL := fmt.Sprintf("%s/cli/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port)
+ authURL := fmt.Sprintf("%s/unarr/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port)
openBrowser(authURL)
- // Wait for callback, error, or timeout
+ // Listen for Enter key to skip to manual fallback
+ skipCh := make(chan struct{}, 1)
+ go func() {
+ buf := make([]byte, 1)
+ for {
+ n, err := os.Stdin.Read(buf)
+ if err != nil || n == 0 {
+ return
+ }
+ if buf[0] == '\n' || buf[0] == '\r' {
+ skipCh <- struct{}{}
+ return
+ }
+ }
+ }()
+
+ // Wait for callback with countdown
ctx, cancel := context.WithTimeout(context.Background(), browserAuthTimeout)
defer cancel()
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+ remaining := int(browserAuthTimeout.Seconds())
+
+ // Show initial countdown
+ fmt.Printf("\r Waiting for browser authorization... %ds (Enter to skip) ", remaining)
+
var token string
- select {
- case token = <-tokenCh:
- // Success
- case err := <-errCh:
- shutdownCtx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
- _ = server.Shutdown(shutdownCtx2)
- cancel2()
- return "", err
- case <-ctx.Done():
- shutdownCtx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
- _ = server.Shutdown(shutdownCtx2)
- cancel2()
- return "", fmt.Errorf("timed out waiting for browser authorization")
+ done := false
+ for !done {
+ select {
+ case token = <-tokenCh:
+ fmt.Print("\r\033[K") // clear countdown line
+ done = true
+ case err := <-errCh:
+ fmt.Print("\r\033[K")
+ shutdownServer(server)
+ return "", err
+ case <-ctx.Done():
+ fmt.Print("\r\033[K")
+ shutdownServer(server)
+ return "", fmt.Errorf("timed out waiting for browser authorization")
+ case <-skipCh:
+ fmt.Print("\r\033[K")
+ shutdownServer(server)
+ return "", fmt.Errorf("skipped by user")
+ case <-ticker.C:
+ remaining--
+ if remaining >= 0 {
+ fmt.Printf("\r Waiting for browser authorization... %ds (Enter to skip) ", remaining)
+ }
+ }
}
- // Shutdown the server
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer shutdownCancel()
- _ = server.Shutdown(shutdownCtx)
+ shutdownServer(server)
return token, nil
}
+func shutdownServer(server *http.Server) {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = server.Shutdown(ctx)
+}
+
func generateState() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
@@ -127,6 +173,29 @@ func generateState() (string, error) {
return hex.EncodeToString(b), nil
}
+// rejectedHTML is the page shown in the browser when the user clicks Cancel.
+const rejectedHTML = `
+
+
+
+ unarr — Cancelled
+
+
+
+
+
—
+
Authorization cancelled
+
You can close this tab. Use unarr init to try again.
+
+
+`
+
// callbackHTML is the page shown in the browser after successful authorization.
const callbackHTML = `