From 1aa4f0c511e33e600199bf5d38d804d9adade1d0 Mon Sep 17 00:00:00 2001 From: "Calmcacil B." <716671+calmcacil@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:12:29 +0200 Subject: [PATCH 1/4] feat!: unify CalmsToolkit CLI --- AGENTS.md | 10 +- CHANGELOG.md | 12 + CONTRIBUTING.md | 45 +- Makefile | 127 +-- README.md | 170 +--- cmd/arr-feed/main.go | 59 -- cmd/arr-feed/main_test.go | 17 - cmd/calmstoolkit-setup/main.go | 314 ------- cmd/calmstoolkit-setup/main_test.go | 21 - cmd/calmstoolkit/main.go | 16 + cmd/media-airtime/main.go | 68 -- cmd/media-airtime/main_test.go | 22 - cmd/media-calendar/main.go | 68 -- cmd/media-calendar/main_test.go | 17 - cmd/media-requests/main.go | 47 - cmd/media-requests/main_test.go | 21 - cmd/media-streams/main.go | 71 -- cmd/media-streams/main_test.go | 17 - docs/ADDING_A_TOOL.md | 12 + docs/ARCHITECTURE.md | 9 + docs/user/CLI_SPEC.md | 17 + docs/user/EXAMPLES_MEDIA_REQUESTS.md | 226 +---- docs/user/MIGRATION_UNIFIED_CLI.md | 25 + docs/user/README_ARR_FEED.md | 306 +------ docs/user/README_MEDIA_REQUESTS.md | 425 +-------- docs/user/UX_DESIGN.md | 222 +---- go.mod | 7 +- go.sum | 18 +- internal/airtime/airtime.go | 35 +- internal/airtime/render.go | 2 +- internal/anisearch/anisearch_test.go | 1255 ++++++++++++++++++++++++++ internal/anisearch/client.go | 312 +++++++ internal/anisearch/input.go | 105 +++ internal/anisearch/mapping.go | 417 +++++++++ internal/anisearch/model.go | 126 +++ internal/anisearch/render.go | 495 ++++++++++ internal/anisearch/search.go | 209 +++++ internal/app/runtime.go | 82 ++ internal/app/runtime_test.go | 29 + internal/arr/client.go | 51 ++ internal/arr/client_test.go | 24 + internal/calendar/calendar.go | 82 +- internal/calendar/render.go | 19 +- internal/cli/root.go | 628 +++++++++++++ internal/cli/root_test.go | 123 +++ internal/cmdutil/cmdutil.go | 136 --- internal/cmdutil/cmdutil_test.go | 90 -- internal/config/config.go | 242 ++++- internal/config/config_test.go | 36 +- internal/console/console.go | 153 ++++ internal/console/console_test.go | 70 ++ internal/core/core.go | 9 + internal/feed/client.go | 17 +- internal/feed/feed.go | 74 +- internal/feed/render.go | 8 +- internal/httputil/client.go | 124 ++- internal/httputil/client_test.go | 48 + internal/requests/requests.go | 16 +- internal/streams/render.go | 22 +- internal/streams/streams.go | 77 +- internal/watch/runner.go | 55 ++ internal/watch/runner_test.go | 28 + llms.txt | 35 +- scripts/tmux-demo.sh | 16 +- 64 files changed, 5008 insertions(+), 2631 deletions(-) delete mode 100644 cmd/arr-feed/main.go delete mode 100644 cmd/arr-feed/main_test.go delete mode 100644 cmd/calmstoolkit-setup/main.go delete mode 100644 cmd/calmstoolkit-setup/main_test.go create mode 100644 cmd/calmstoolkit/main.go delete mode 100644 cmd/media-airtime/main.go delete mode 100644 cmd/media-airtime/main_test.go delete mode 100644 cmd/media-calendar/main.go delete mode 100644 cmd/media-calendar/main_test.go delete mode 100644 cmd/media-requests/main.go delete mode 100644 cmd/media-requests/main_test.go delete mode 100644 cmd/media-streams/main.go delete mode 100644 cmd/media-streams/main_test.go create mode 100644 docs/ADDING_A_TOOL.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/user/CLI_SPEC.md create mode 100644 docs/user/MIGRATION_UNIFIED_CLI.md create mode 100644 internal/anisearch/anisearch_test.go create mode 100644 internal/anisearch/client.go create mode 100644 internal/anisearch/input.go create mode 100644 internal/anisearch/mapping.go create mode 100644 internal/anisearch/model.go create mode 100644 internal/anisearch/render.go create mode 100644 internal/anisearch/search.go create mode 100644 internal/app/runtime.go create mode 100644 internal/app/runtime_test.go create mode 100644 internal/arr/client.go create mode 100644 internal/arr/client_test.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go delete mode 100644 internal/cmdutil/cmdutil.go delete mode 100644 internal/cmdutil/cmdutil_test.go create mode 100644 internal/console/console.go create mode 100644 internal/console/console_test.go create mode 100644 internal/watch/runner.go create mode 100644 internal/watch/runner_test.go diff --git a/AGENTS.md b/AGENTS.md index b5d4c33..a11ba11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # Repository Guidelines ## Project Structure & Module Organization -CalmsToolkit is a Go module with CLI binaries under `cmd/`: `media-requests`, `media-streams`, `media-calendar`, `arr-feed`, and `calmstoolkit-setup`. Shared library code is under `internal/`. Research notes are under `docs/research/`, user docs under `docs/user/`, and action examples in `docs/user/EXAMPLES_MEDIA_REQUESTS.md`. The `bin/` directory is generated by builds and is gitignored, while the Makefile drives reproducible compilation. +CalmsToolkit is a Go module with one Linux CLI at `cmd/calmstoolkit`. Never add a standalone binary. Cobra registration lives in `internal/cli`, runtime injection in `internal/app`, shared rendering in `internal/console`, configuration in `internal/config`, and feature/domain behavior elsewhere under `internal/`. Research notes are under `docs/research/` and normative user docs under `docs/user/`. ## Build, Test, and Development Commands -Use the Make targets to stay consistent: `make build` compiles all tools for your host, `make build-all` cross-compiles for common OS/arch pairs, `make test` runs `go test ./...`, and `make tidy` refreshes module metadata. During iteration you can run a tool directly with `go run ./cmd// -url ...`. Configuration is managed via `~/.config/calmstoolkit/config.json` (generated by `make setup`). +Use `make build`, `make build-all` (Linux amd64/arm64), `make test`, and `make tidy`. `make check` is the full gate. Run commands with `go run ./cmd/calmstoolkit `. Configuration is managed through `calmstoolkit config setup|validate` and `doctor`. ## Coding Style & Naming Conventions Format every change with `gofmt` (or `go fmt ./...`) before committing; the project mirrors idiomatic Go spacing and grouping. Keep exported types, funcs, and consts in UpperCamelCase with doc comments, use lowerCamelCase for locals, and reserve ALL_CAPS for environment variable labels surfaced to users. Group related constants and config structs to keep CLI flag wiring readable. @@ -12,8 +12,14 @@ Format every change with `gofmt` (or `go fmt ./...`) before committing; the proj ## Testing Guidelines Place table-driven tests in `_test.go` files beside the feature under test and cover both happy paths and API failure modes (mock HTTP responses where feasible). Target at least the functionality added in each PR; run `make test` or `go test ./...` before pushing, and capture notable coverage regressions in the PR notes. +New commands must test terminal, plain, JSON/NDJSON, failure, and cancellation paths. Foundational packages `app`, `console`, `config`, `httputil`, and new domain clients target at least 80% coverage. JSON/NDJSON tests must assert schema validity and absence of ANSI sequences. + ## Commit & Pull Request Guidelines Match the existing history by writing imperative, present-tense commit subjects (`Add media calendar filters`). Squash incidental fixups locally so each commit is reviewable. Pull requests should link the relevant issue, summarize behavioral changes, call out new flags or env vars, and include screenshots or sample CLI output when they clarify UX changes. Flag any security-sensitive configuration updates explicitly. ## Configuration & Secrets The shared config file at `~/.config/calmstoolkit/config.json` stores API keys and tokens. It is created with `0600` permissions; never commit it. Local overrides should rely on shell exports or direnv, and scrub logs or fixtures before sharing them in reviews. + +## Mandatory architecture boundaries + +Only `cmd/calmstoolkit/main.go` may call `os.Exit`. Commands return errors through the shared runtime. Use `internal/console` instead of feature-local width, box, palette, or screen-control frameworks, and use typed clients over `internal/httputil`. Logs and diagnostics go to stderr; machine stdout contains envelopes only. Interface changes require user docs and migration notes. Follow `docs/ADDING_A_TOOL.md` and `docs/ARCHITECTURE.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index f9bc84e..43fcf95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [7.0.0] — 2026-07-15 + +### Changed +- Consolidated the toolkit behind one Linux `calmstoolkit` Cobra command tree. +- Added shared runtime injection, output modes, terminal capability detection, and versioned machine envelopes. +- Added config path/environment precedence, aggregate validation, atomic `0600` saves, `doctor`, and `config validate`. +- Hardened HTTP transport defaults, bounded bodies, safe replay/retry, `Retry-After`, and redacted typed errors. + +### Fixed +- Made AniSearch TVDB season-one selection deterministic. +- Preserved configured no-color and watch interval values unless flags are explicitly supplied. + ## [6.0.0] — 2026-06-01 Architecture refactor — no user-facing API changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7a404e..b64f02c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,46 +1,9 @@ # Contributing -## Quick start +Run `make check` before submitting changes. It formats source, verifies module tidiness, runs vet and race tests, and builds Linux amd64/arm64 artifacts. -```bash -make build # Compile all tools -make test # Run all tests -make lint # golangci-lint -make fmt # gofmt -make clean # Remove build artifacts -make clean-all # Remove build artifacts + stray binaries -``` +There is one executable: `cmd/calmstoolkit`. Register commands with Cobra in `internal/cli`; put behavior in a feature or domain package; inject `internal/app.Runtime`; use `internal/console` for terminal/machine output and `internal/httputil` or a typed domain client for HTTP. Only `cmd/calmstoolkit/main.go` may call `os.Exit`. -## Code conventions +Tests should cover terminal/plain/machine output, failure, cancellation, and HTTP fixtures where applicable. JSON and NDJSON stdout must contain only documented envelopes; diagnostics and logs belong on stderr. Never log or fixture tokens, API keys, authorization headers, or secret-bearing URLs. -- `data` with `gofmt` before committing (`make fmt`). -- `go vet ./...` must pass. -- Test with `go test -race -count=1 ./...`. -- All `cmd/*/main.go` files use `internal/cmdutil` for shared flag parsing. -- New tool config structs embed `internal/core.CommonConfig`. -- Shared HTTP calls use `internal/httputil` with retry. -- Logging goes through `log/slog` via `internal/slogx`. -- Build metadata is injected via `internal/buildinfo` and `-ldflags -X`. - -## Project structure - -``` -cmd//main.go — Thin entry point (flag parsing + cfg wiring) -internal// — Feature packages (airtime, calendar, feed, requests, streams) -internal/cmdutil/ — Shared flag/validation helpers -internal/config/ — ToolkitConfig, ArrInstance, Load/Save/Validate -internal/core/ — CommonConfig embedded in every tool -internal/colors/ — ANSI palette system (default, catppuccin) -internal/datex/ — Flexible JSON date/time type -internal/httputil/ — HTTP client wrapper with retry -internal/slogx/ — Structured logger (text/JSON) -internal/buildinfo/ — Version/commit/date injected at build -``` - -## Per-binary tests - -Each `cmd//` has a shallow `main_test.go`. Feature tests live in `internal//_test.go`. Use `httptest.Server` for API fixtures. - -## CI - -See `.github/workflows/ci.yml`. Runs: `gofmt`, `go mod tidy` diff, `go vet`, `go test -race -coverprofile`, `go build ./cmd/...`. +Update command help, user docs, the migration guide when an interface changes, and `docs/ADDING_A_TOOL.md` checklists. Use imperative commit subjects such as `Add calendar filters`. diff --git a/Makefile b/Makefile index 3e03f4f..9d26609 100644 --- a/Makefile +++ b/Makefile @@ -1,118 +1,53 @@ -.PHONY: all build build-all clean clean-all install test help setup setup-install fmt tidy +.PHONY: all build build-all clean install test fmt tidy vet check setup -# Installation parameters -prefix?=/usr/local -INSTALL_DIR?=$(prefix)/bin +prefix ?= /usr/local +INSTALL_DIR ?= $(prefix)/bin +BUILD_DIR := bin +BINARY := calmstoolkit +GOCMD := go -# Binary names and build directory -BUILD_DIR=bin -BINARY_STREAMSTOOL=media-streams -BINARY_CALENDAR=media-calendar -BINARY_REQUESTS=media-requests -BINARY_ARRFEED=arr-feed -BINARY_AIRTIME=media-airtime -BINARY_SETUP=calmstoolkit-setup - -# Go parameters -GOCMD=go -GOBUILD=$(GOCMD) build -GOCLEAN=$(GOCMD) clean -GOTEST=$(GOCMD) test -GOGET=$(GOCMD) get -GOMOD=$(GOCMD) mod - -# Build flags with version injection VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -BUILDINFO=-X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Version=$(VERSION) \ - -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Commit=$(COMMIT) \ - -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Date=$(DATE) -LDFLAGS=-ldflags "-s -w $(BUILDINFO)" +BUILDINFO := -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Version=$(VERSION) \ + -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Commit=$(COMMIT) \ + -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Date=$(DATE) +LDFLAGS := -ldflags "-s -w $(BUILDINFO)" all: clean build -help: - @echo "Available targets:" - @echo " make build - Build for current platform" - @echo " make build-all - Build for all platforms" - @echo " make install - Install to $(INSTALL_DIR) (Linux/macOS)" - @echo " make setup - Interactive config wizard" - @echo " make setup-install - Install setup binary to $(BUILD_DIR)" - @echo " make clean - Remove build artifacts" - @echo " make clean-all - Remove build artifacts and stray repo-root binaries" - @echo " make test - Run tests" - @echo " make fmt - Format Go source files" - @echo " make tidy - Tidy go.mod" - -fmt: - @gofmt -w . - @echo "Formatted all Go source files" - build: - @echo "Building binaries..." @mkdir -p $(BUILD_DIR) - $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_STREAMSTOOL) ./cmd/media-streams/ - $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_CALENDAR) ./cmd/media-calendar/ - $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_REQUESTS) ./cmd/media-requests/ - $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_ARRFEED) ./cmd/arr-feed/ - $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_AIRTIME) ./cmd/media-airtime/ - @echo "Build complete: $(BUILD_DIR)/*" + $(GOCMD) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/calmstoolkit build-all: - @echo "Building for all platforms..." @mkdir -p $(BUILD_DIR) - - @for GOOS in linux darwin windows freebsd; do \ - for GOARCH in amd64 arm64; do \ - for SRC in media-streams media-calendar media-requests arr-feed media-airtime; do \ - EXT=""; \ - if [ "$$GOOS" = "windows" ]; then EXT=".exe"; fi; \ - BIN=$${SRC}-$$GOOS-$$GOARCH$$EXT; \ - echo "Building $$SRC for $$GOOS/$$GOARCH..."; \ - GOOS=$$GOOS GOARCH=$$GOARCH $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$$BIN ./cmd/$$SRC/ || exit 1; \ - done; \ - done; \ + @for arch in amd64 arm64; do \ + echo "Building Linux/$$arch..."; \ + GOOS=linux GOARCH=$$arch CGO_ENABLED=0 $(GOCMD) build $(LDFLAGS) \ + -o $(BUILD_DIR)/$(BINARY)-linux-$$arch ./cmd/calmstoolkit || exit 1; \ done - @echo "All builds complete!" - @ls -lh $(BUILD_DIR) - install: build - @echo "Installing binaries to $(INSTALL_DIR)..." - @install -m 755 $(BUILD_DIR)/$(BINARY_STREAMSTOOL) $(INSTALL_DIR)/$(BINARY_STREAMSTOOL) - @install -m 755 $(BUILD_DIR)/$(BINARY_CALENDAR) $(INSTALL_DIR)/$(BINARY_CALENDAR) - @install -m 755 $(BUILD_DIR)/$(BINARY_REQUESTS) $(INSTALL_DIR)/$(BINARY_REQUESTS) - @install -m 755 $(BUILD_DIR)/$(BINARY_ARRFEED) $(INSTALL_DIR)/$(BINARY_ARRFEED) - @install -m 755 $(BUILD_DIR)/$(BINARY_AIRTIME) $(INSTALL_DIR)/$(BINARY_AIRTIME) - @echo "Make sure $(INSTALL_DIR) is in your PATH." - @echo "Installation complete!" - -clean: - @echo "Cleaning..." - $(GOCLEAN) - rm -rf $(BUILD_DIR) - @echo "Clean complete!" - -clean-all: clean - @echo "Removing stray binaries from repo root..." - @find . -maxdepth 1 -type f \( -name 'media-*' -o -name 'arr-*' \) -not -name '*.go' -not -name '*.md' -exec rm -v {} \; - @echo "Clean-all complete!" + install -m 755 $(BUILD_DIR)/$(BINARY) $(INSTALL_DIR)/$(BINARY) setup: - @echo "=== CalmsToolkit Configuration Setup ===" - @go run ./cmd/calmstoolkit-setup/ - -setup-install: - @mkdir -p $(BUILD_DIR) - $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_SETUP) ./cmd/calmstoolkit-setup/ - @echo "Installed: $(BUILD_DIR)/$(BINARY_SETUP)" + $(GOCMD) run ./cmd/calmstoolkit config setup test: - @echo "Running all tests..." - $(GOTEST) -v ./... - @echo "" - @echo "All tests complete!" + $(GOCMD) test -race ./... + +fmt: + gofmt -w . tidy: - $(GOMOD) tidy + $(GOCMD) mod tidy + +vet: + $(GOCMD) vet ./... + +check: fmt tidy vet test build-all + +clean: + $(GOCMD) clean + rm -rf $(BUILD_DIR) diff --git a/README.md b/README.md index 327f121..deb79bc 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,48 @@ # CalmsToolkit -Various tools for managing media servers and related services. +CalmsToolkit is one Linux-focused, SSH-friendly command for a personal media stack. It keeps the project’s compact Unicode cards, semantic colors, and Catppuccin themes while providing safe plain and machine output for scripts. -## Tools - -### Media Requests (Interactive) - -An interactive CLI tool for managing media requests through Overseerr/Jellyseerr with an intuitive menu-driven interface. - -#### Features - -- Interactive Menu: Easy-to-navigate menu system with hotkeys -- Search Media: Search TMDB database for movies and TV shows -- Smart Season Selection: Request all seasons or specific ones for TV shows -- Request Management: View, approve, and decline pending requests -- Status Indicators: Visual feedback for available/requested/pending media -- Root Folder Override: Select custom root folders when approving requests -- Colored Output: ANSI colors for better readability -- JSON Config: Configuration via ~/.config/calmstoolkit/config.json - -#### Quick Start +## Install ```bash -# Set up configuration -make setup - -# Build the binary make build - -# Run interactive menu -./bin/media-requests -``` - -#### Documentation - -- **[docs/user/README_MEDIA_REQUESTS.md](docs/user/README_MEDIA_REQUESTS.md)** - Complete documentation and usage guide -- **[docs/research/OVERSEERR_API_RESEARCH.md](docs/research/OVERSEERR_API_RESEARCH.md)** - API endpoint reference - -### Media Streams Monitor - -Monitor active streams on both Plex and Jellyfin servers with detailed information about users, transcoding status, bandwidth, and quality. Supports session history tracking in watch mode. - -#### Features - -- Multi-Server: Monitor both Plex and Jellyfin servers simultaneously or individually -- Session History: Track recently ended sessions in watch mode (configurable duration) -- Fast: ~10x faster than bash implementations -- Color Output: ANSI colored terminal output with distinct styling for ended sessions -- JSON Output: Machine-readable format for automation and monitoring -- Watch Mode: Continuous real-time monitoring with auto-refresh and history tracking -- Rich Details: Shows transcoding status, bandwidth, quality, codecs, duration, and more -- Audio Support: Handles both video (movies/TV) and audio (music) streams - -#### Quick Start - -```bash -# Run with both Plex and Jellyfin -./bin/media-streams -server both - -# Plex only -./bin/media-streams -server plex -plex-token "your-token" - -# Jellyfin only -./bin/media-streams -server jellyfin -jellyfin-token "your-token" - -# Watch mode with session history -./bin/media-streams -watch -interval 5 -history-duration 10m - -# JSON output for automation -./bin/media-streams -json | jq '.total_streams' -``` - -### Media Calendar - -Display upcoming TV episodes and movie releases from Sonarr and Radarr in a concise calendar view. - -#### Features - -- Multi-Instance: Monitor multiple Sonarr and Radarr instances simultaneously -- Calendar View: Horizontal and vertical layouts adapt to terminal width -- Filters: Filter by availability, missing, premieres, or monitored status -- Queue Warnings: Alerts for items needing manual intervention -- Watch Mode: Continuous monitoring with auto-refresh -- JSON Output: Machine-readable format for automation - -#### Quick Start - -```bash -./bin/media-calendar -days 7 -./bin/media-calendar -days 7 -days-past 1 -filter missing -./bin/media-calendar -watch -interval 300 -./bin/media-calendar -json +sudo make install +calmstoolkit config setup ``` -### Media Airtime +`make build-all` produces static Linux amd64 and arm64 binaries. Configuration remains at `~/.config/calmstoolkit/config.json` with mode `0600`; override it with `--config` or `CALMSTOOLKIT_CONFIG`. -Look up next upcoming and last aired dates for any show or movie in your Sonarr/Radarr library with fuzzy matching and an interactive selection prompt. +## Commands -#### Features - -- Fuzzy Search: Find shows and movies with partial or approximate name matching -- Interactive Selection: Pick from multiple matches with a numbered menu -- Airtime Display: Shows next upcoming airtime or last aired episode with relative dates ("in 3 days", "yesterday") -- Per-Season Detail: Current season detection with episode on-disk status -- Multi-Instance: Searches across all configured Sonarr and Radarr instances -- JSON Output: Machine-readable format for automation - -#### Quick Start - -```bash -./bin/media-airtime "clarkson" -./bin/media-airtime "clarkson's farm" -./bin/media-airtime -type movie "inception 2010" -./bin/media-airtime -exact "Clarkson's Farm" -./bin/media-airtime -json "dune" +```text +calmstoolkit streams Plex/Jellyfin sessions +calmstoolkit calendar Sonarr/Radarr releases +calmstoolkit requests Interactive Overseerr/Jellyseerr requests +calmstoolkit airtime Library airtime lookup +calmstoolkit feed Sonarr/Radarr activity +calmstoolkit anime AniList search with TVDB mapping +calmstoolkit config setup|validate Configuration management +calmstoolkit doctor Local and service diagnostics +calmstoolkit version Build information ``` -### ARR Feed - -Monitor Sonarr and Radarr history events (grabbed, imported, failed) in real-time. - -#### Features +Run `calmstoolkit --help` for feature flags. Global flags are `--config`, `--output`, `--theme`, `--no-color`, `--timeout`, `--debug`, `--quiet`, and `--strict`. -- Multi-Instance: Monitor multiple Sonarr and Radarr instances simultaneously -- Event Filtering: Show/hide grabbed, imported, failed, deleted, and ignored events -- Watch Mode: Continuous real-time monitoring -- JSON Output: Machine-readable format for automation - -#### Quick Start +Output is `auto`, `terminal`, `plain`, `json`, or `ndjson`. Auto uses the rich terminal view only on a capable UTF-8 TTY and otherwise selects plain output. Watch commands require NDJSON rather than JSON for machine output. Diagnostics always go to stderr. ```bash -./bin/arr-feed -./bin/arr-feed -watch -./bin/arr-feed -show-grabbed -show-failed -./bin/arr-feed -json +calmstoolkit streams --server plex +calmstoolkit calendar --days 7 --filter missing +calmstoolkit feed --watch --output ndjson | jq -c .data +calmstoolkit anime "Frieren" --output json ``` -## Configuration - -All tools use a shared JSON configuration file at `~/.config/calmstoolkit/config.json`. - -Run `make setup` to generate your configuration interactively. +See [migration](docs/user/MIGRATION_UNIFIED_CLI.md), [CLI behavior](docs/user/CLI_SPEC.md), and [architecture](docs/ARCHITECTURE.md). -## Building +## Development ```bash -# Current platform -make build - -# All platforms (Linux, macOS, Windows, FreeBSD) -make build-all - -# Install to /usr/local/bin -make install +make check # format, tidy, vet, race tests, Linux builds ``` -## Testing - -```bash -make test -``` - -## License - -MIT License +New commands must follow [Adding a Tool](docs/ADDING_A_TOOL.md). Never commit the configuration file, tokens, or logs containing credentials. diff --git a/cmd/arr-feed/main.go b/cmd/arr-feed/main.go deleted file mode 100644 index 26e2b84..0000000 --- a/cmd/arr-feed/main.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - - "github.com/calmcacil/CalmsToolkit/internal/cmdutil" - "github.com/calmcacil/CalmsToolkit/internal/feed" -) - -func main() { - tk := cmdutil.LoadAndValidate() - cfg := feed.BuildToolConfig(tk) - - poll := flag.Duration("poll", cfg.PollInterval, "Poll interval for watch mode") - duration := flag.Duration("duration", cfg.HistoryWindow, "History lookback window") - showGrabbed := flag.Bool("show-grabbed", cfg.ShowGrabbed, "Show grabbed events") - showImported := flag.Bool("show-imported", cfg.ShowImported, "Show imported events") - showFailed := flag.Bool("show-failed", cfg.ShowFailed, "Show failed events") - showDeleted := flag.Bool("show-deleted", cfg.ShowDeleted, "Show deleted events") - showIgnored := flag.Bool("show-ignored", cfg.ShowIgnored, "Show ignored events") - showSubtitles := flag.Bool("show-subtitles", cfg.ShowSubtitles, "Show subtitle info for imported events") - maxEvents := flag.Int("events", cfg.MaxEvents, "Maximum number of events to display (1-100)") - watch := flag.Bool("watch", false, "Continuous monitoring mode") - - cu := cmdutil.RegisterCommonFlags(flag.CommandLine, tk, cmdutil.Options{ - IncludeQuiet: true, - }) - flag.Parse() - cu.Apply() - - cfg.PollInterval = *poll - cfg.HistoryWindow = *duration - cfg.Timeout = cu.Timeout - cfg.NoColor = cu.NoColor - cfg.Theme = cu.Theme - cfg.JSONOutput = cu.JSONFlag() - cfg.Watch = *watch - cfg.ShowGrabbed = *showGrabbed - cfg.ShowImported = *showImported - cfg.ShowFailed = *showFailed - cfg.ShowDeleted = *showDeleted - cfg.ShowIgnored = *showIgnored - cfg.ShowSubtitles = *showSubtitles - cfg.MaxEvents = *maxEvents - cfg.Quiet = cu.Quiet - - if cfg.MaxEvents < 0 || cfg.MaxEvents > 100 { - fmt.Fprintf(os.Stderr, "ERROR: -events must be between 0 and 100\n") - os.Exit(1) - } - if cfg.PollInterval <= 0 { - fmt.Fprintf(os.Stderr, "ERROR: -poll must be positive\n") - os.Exit(1) - } - - feed.Run(cfg) -} diff --git a/cmd/arr-feed/main_test.go b/cmd/arr-feed/main_test.go deleted file mode 100644 index 65213f9..0000000 --- a/cmd/arr-feed/main_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "testing" - - "github.com/calmcacil/CalmsToolkit/internal/config" -) - -func TestConfigDefaults(t *testing.T) { - cfg := config.DefaultToolkitConfig() - if cfg == nil { - t.Fatal("DefaultToolkitConfig() returned nil") - } - if cfg.ArrFeed.MaxEvents != 50 { - t.Errorf("MaxEvents = %d, want %d", cfg.ArrFeed.MaxEvents, 50) - } -} diff --git a/cmd/calmstoolkit-setup/main.go b/cmd/calmstoolkit-setup/main.go deleted file mode 100644 index 359d084..0000000 --- a/cmd/calmstoolkit-setup/main.go +++ /dev/null @@ -1,314 +0,0 @@ -package main - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "golang.org/x/term" - - "github.com/calmcacil/CalmsToolkit/internal/config" -) - -func main() { - fmt.Println(strings.Repeat("━", 50)) - fmt.Println(" CalmsToolkit Configuration Setup") - fmt.Println(strings.Repeat("━", 50)) - fmt.Println() - - path := ConfigPath() - if path == "" { - fmt.Fprintln(os.Stderr, "ERROR: Cannot determine home directory") - os.Exit(1) - } - - fmt.Printf("Config file: %s\n", path) - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0700); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Cannot create config directory: %v\n", err) - os.Exit(1) - } - if err := os.Chmod(dir, 0700); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Cannot set config directory permissions: %v\n", err) - os.Exit(1) - } - - cfg := config.DefaultToolkitConfig() - - existing := false - if data, err := os.ReadFile(path); err == nil { - if err := json.Unmarshal(data, cfg); err == nil { - existing = true - fmt.Println("\nExisting configuration loaded. New options (if any) have been added with defaults.") - fmt.Println("Press Enter to keep the current value shown in [brackets].") - } - } - if !existing { - fmt.Println("\nNo existing config found. Defaults shown in [brackets].") - fmt.Println("Press Enter to accept a default.") - } - - fmt.Println() - - fmt.Println(strings.Repeat("─", 50)) - fmt.Println(" Step 1/6: General Settings") - fmt.Println(strings.Repeat("─", 50)) - cfg.General.Timeout = prompt("HTTP timeout (e.g. 10s, 30s)", cfg.General.Timeout) - cfg.General.NoColor = promptYesNo("Disable colors in terminal output", cfg.General.NoColor) - fmt.Println() - - fmt.Println(strings.Repeat("─", 50)) - fmt.Println(" Step 2/6: Sonarr Instances") - fmt.Println(strings.Repeat("─", 50)) - cfg.Sonarr = promptInstances("Sonarr", cfg.Sonarr) - fmt.Println() - - fmt.Println(strings.Repeat("─", 50)) - fmt.Println(" Step 3/6: Radarr Instances") - fmt.Println(strings.Repeat("─", 50)) - cfg.Radarr = promptInstances("Radarr", cfg.Radarr) - fmt.Println() - - fmt.Println(strings.Repeat("─", 50)) - fmt.Println(" Step 4/6: Plex & Jellyfin Streams") - fmt.Println(strings.Repeat("─", 50)) - if promptYesNo("Configure Plex?", cfg.MediaStreams.PlexToken != "") { - cfg.MediaStreams.PlexURL = prompt("Plex URL", cfg.MediaStreams.PlexURL) - cfg.MediaStreams.PlexToken = promptSecret("Plex Token", cfg.MediaStreams.PlexToken) - } else { - cfg.MediaStreams.PlexURL = "" - cfg.MediaStreams.PlexToken = "" - } - if promptYesNo("Configure Jellyfin?", cfg.MediaStreams.JellyfinToken != "") { - cfg.MediaStreams.JellyfinURL = prompt("Jellyfin URL", cfg.MediaStreams.JellyfinURL) - cfg.MediaStreams.JellyfinToken = promptSecret("Jellyfin Token", cfg.MediaStreams.JellyfinToken) - } else { - cfg.MediaStreams.JellyfinURL = "" - cfg.MediaStreams.JellyfinToken = "" - } - cfg.MediaStreams.ServerType = prompt("Server type (plex/jellyfin/both)", cfg.MediaStreams.ServerType) - cfg.MediaStreams.WatchInterval = promptInt("Watch refresh interval (seconds)", cfg.MediaStreams.WatchInterval) - cfg.MediaStreams.HistoryDuration = prompt("Session history duration (e.g. 15m, 1h)", cfg.MediaStreams.HistoryDuration) - fmt.Println() - - fmt.Println(strings.Repeat("─", 50)) - fmt.Println(" Step 5/6: Media Requests (Overseerr / Jellyseerr)") - fmt.Println(strings.Repeat("─", 50)) - if promptYesNo("Configure media request server?", cfg.MediaRequests.APIKey != "") { - cfg.MediaRequests.OverseerrURL = prompt("Server URL", cfg.MediaRequests.OverseerrURL) - cfg.MediaRequests.APIKey = promptSecret("API Key", cfg.MediaRequests.APIKey) - cfg.MediaRequests.Verbose = promptYesNo("Verbose output", cfg.MediaRequests.Verbose) - } else { - cfg.MediaRequests.OverseerrURL = "" - cfg.MediaRequests.APIKey = "" - cfg.MediaRequests.Verbose = false - } - fmt.Println() - - fmt.Println(strings.Repeat("─", 50)) - fmt.Println(" Step 6/6: Airtime, Calendar & Feed Settings") - fmt.Println(strings.Repeat("─", 50)) - - fmt.Println("\n -- Media Airtime --") - cfg.MediaAirtime.Limit = promptInt("Search result limit (1-50)", cfg.MediaAirtime.Limit) - cfg.MediaAirtime.PastDays = promptInt("Past days for last-aired lookup", cfg.MediaAirtime.PastDays) - cfg.MediaAirtime.FutureDays = promptInt("Future days for next-upcoming lookup", cfg.MediaAirtime.FutureDays) - - fmt.Println("\n -- Media Calendar --") - cfg.MediaCalendar.Days = promptInt("Days to show", cfg.MediaCalendar.Days) - cfg.MediaCalendar.DaysPast = promptInt("Past days to include", cfg.MediaCalendar.DaysPast) - cfg.MediaCalendar.WatchInterval = promptInt("Watch refresh interval (seconds)", cfg.MediaCalendar.WatchInterval) - cfg.MediaCalendar.Debug = promptYesNo("Debug mode (show API URLs)", cfg.MediaCalendar.Debug) - - fmt.Println("\n -- ARR Feed --") - cfg.ArrFeed.PollInterval = prompt("Poll interval (e.g. 5s, 30s)", cfg.ArrFeed.PollInterval) - cfg.ArrFeed.HistoryWindow = prompt("History window (e.g. 1h, 24h)", cfg.ArrFeed.HistoryWindow) - cfg.ArrFeed.MaxEvents = promptInt("Max events to display (0-100)", cfg.ArrFeed.MaxEvents) - cfg.ArrFeed.ShowGrabbed = promptYesNo("Show grabbed events", cfg.ArrFeed.ShowGrabbed) - cfg.ArrFeed.ShowImported = promptYesNo("Show imported events", cfg.ArrFeed.ShowImported) - cfg.ArrFeed.ShowFailed = promptYesNo("Show failed events", cfg.ArrFeed.ShowFailed) - cfg.ArrFeed.ShowDeleted = promptYesNo("Show deleted events", cfg.ArrFeed.ShowDeleted) - cfg.ArrFeed.ShowIgnored = promptYesNo("Show ignored events", cfg.ArrFeed.ShowIgnored) - cfg.ArrFeed.ShowSubtitles = promptYesNo("Show subtitle info for imported events", cfg.ArrFeed.ShowSubtitles) - fmt.Println() - - fmt.Println(strings.Repeat("━", 50)) - fmt.Println(" Configuration Summary") - fmt.Println(strings.Repeat("━", 50)) - fmt.Printf(" Sonarr instances: %d\n", len(cfg.Sonarr)) - fmt.Printf(" Radarr instances: %d\n", len(cfg.Radarr)) - fmt.Printf(" Plex configured: %v\n", cfg.MediaStreams.PlexToken != "") - fmt.Printf(" Jellyfin configured: %v\n", cfg.MediaStreams.JellyfinToken != "") - fmt.Printf(" Requests server: %v\n", cfg.MediaRequests.APIKey != "") - fmt.Printf(" Airtime limit: %d\n", cfg.MediaAirtime.Limit) - fmt.Printf(" Calendar days: %d (+%d past)\n", cfg.MediaCalendar.Days, cfg.MediaCalendar.DaysPast) - fmt.Printf(" Feed max events: %d\n", cfg.ArrFeed.MaxEvents) - fmt.Printf(" Feed subtitles: %v\n", cfg.ArrFeed.ShowSubtitles) - fmt.Println() - - cfg.Version = 1 - - if err := cfg.Validate(); err != nil { - fmt.Fprintf(os.Stderr, "WARNING: Configuration has validation errors: %v\n", err) - fmt.Fprintf(os.Stderr, "You can still write it, but some tools may not work correctly.\n") - } - - if !promptYesNo("Write configuration to "+path+"?", true) { - fmt.Println("Setup cancelled.") - return - } - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Cannot encode config: %v\n", err) - os.Exit(1) - } - - if err := os.WriteFile(path, data, 0600); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Cannot write config: %v\n", err) - os.Exit(1) - } - if err := os.Chmod(path, 0600); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Cannot set config permissions: %v\n", err) - os.Exit(1) - } - - fmt.Printf("\n✓ Configuration written to %s\n", path) - fmt.Println(" Run 'make build' to compile all tools.") - fmt.Println(" Run 'make test' to verify everything works.") - fmt.Println() -} - -var scanner = bufio.NewScanner(os.Stdin) - -func prompt(label, defaultVal string) string { - if defaultVal != "" { - fmt.Printf(" %s [%s]: ", label, defaultVal) - } else { - fmt.Printf(" %s: ", label) - } - scanner.Scan() - input := strings.TrimSpace(scanner.Text()) - if input == "" { - return defaultVal - } - return input -} - -func promptSecret(label, defaultVal string) string { - masked := "" - if defaultVal != "" { - masked = "****" - } - if masked != "" { - fmt.Printf(" %s [%s]: ", label, masked) - } else { - fmt.Printf(" %s: ", label) - } - - if term.IsTerminal(int(os.Stdin.Fd())) { - byteInput, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - if err != nil { - return defaultVal - } - input := strings.TrimSpace(string(byteInput)) - if input == "" { - return defaultVal - } - return input - } - - scanner.Scan() - input := strings.TrimSpace(scanner.Text()) - if input == "" { - return defaultVal - } - return input -} - -func promptYesNo(label string, defaultVal bool) bool { - suffix := " [y/N]" - if defaultVal { - suffix = " [Y/n]" - } - fmt.Printf(" %s%s: ", label, suffix) - scanner.Scan() - input := strings.ToLower(strings.TrimSpace(scanner.Text())) - if input == "" { - return defaultVal - } - return input == "y" || input == "yes" -} - -func promptInt(label string, defaultVal int) int { - for { - input := prompt(label, strconv.Itoa(defaultVal)) - val, err := strconv.Atoi(input) - if err == nil { - return val - } - fmt.Printf(" Invalid number. Please enter a number.\n") - } -} - -func promptInstances(kind string, existing []config.ArrInstance) []config.ArrInstance { - instances := make([]config.ArrInstance, len(existing)) - copy(instances, existing) - - if len(instances) > 0 { - fmt.Printf("\n You have %d existing %s instance(s):\n", len(instances), kind) - for i, inst := range instances { - fmt.Printf(" %d. %s (%s)\n", i+1, inst.Name, inst.URL) - } - if !promptYesNo("Review and update instances?", false) { - return instances - } - for i := range instances { - fmt.Printf("\n --- %s Instance %d ---\n", kind, i+1) - instances[i].Name = prompt("Friendly name", instances[i].Name) - instances[i].URL = prompt("URL", instances[i].URL) - token := promptSecret("API Key (enter blank to keep current)", instances[i].APIKey) - if token != "" { - instances[i].APIKey = token - } - instances[i].URL = strings.TrimSuffix(instances[i].URL, "/") - } - } - - if promptYesNo("Add another "+kind+" instance?", false) { - for { - fmt.Printf("\n--- New %s Instance ---\n", kind) - name := prompt("Friendly name", kind+" HD") - url := prompt("URL", "http://localhost:8989") - token := promptSecret("API Key", "") - if token == "" { - fmt.Println(" API Key is required. Skipping this instance.") - } else { - instances = append(instances, config.ArrInstance{ - Name: name, - URL: strings.TrimSuffix(url, "/"), - APIKey: token, - }) - } - if !promptYesNo("Add another "+kind+" instance?", false) { - break - } - } - } - - return instances -} - -func ConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".config", "calmstoolkit", "config.json") -} diff --git a/cmd/calmstoolkit-setup/main_test.go b/cmd/calmstoolkit-setup/main_test.go deleted file mode 100644 index b0c5f7a..0000000 --- a/cmd/calmstoolkit-setup/main_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "os" - "testing" - - "github.com/calmcacil/CalmsToolkit/internal/config" -) - -func TestConfigPath(t *testing.T) { - origHome := os.Getenv("HOME") - defer os.Setenv("HOME", origHome) - - dir := t.TempDir() - os.Setenv("HOME", dir) - - path := config.ConfigPath() - if path == "" { - t.Fatal("ConfigPath() returned empty") - } -} diff --git a/cmd/calmstoolkit/main.go b/cmd/calmstoolkit/main.go new file mode 100644 index 0000000..8621d6f --- /dev/null +++ b/cmd/calmstoolkit/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/calmcacil/CalmsToolkit/internal/cli" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + os.Exit(cli.Execute(ctx, os.Stdin, os.Stdout, os.Stderr, os.Args[1:])) +} diff --git a/cmd/media-airtime/main.go b/cmd/media-airtime/main.go deleted file mode 100644 index 99761ed..0000000 --- a/cmd/media-airtime/main.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - - "github.com/calmcacil/CalmsToolkit/internal/airtime" - "github.com/calmcacil/CalmsToolkit/internal/cmdutil" -) - -func main() { - tk := cmdutil.LoadAndValidate() - cfg := airtime.BuildToolConfig(tk) - - searchType := flag.String("type", "auto", "Search scope: auto, series, movie") - limit := flag.Int("limit", cfg.Limit, "Maximum candidates to list") - exact := flag.Bool("exact", false, "Require exact title match") - season := flag.Int("season", 0, "Override season number (0 = auto-detect)") - pastDays := flag.Int("past", cfg.PastDays, "Past days for last-aired lookup") - futureDays := flag.Int("future", cfg.FutureDays, "Future days for next-upcoming lookup") - noBanner := flag.Bool("no-banner", false, "Suppress decorative banner") - fullSeason := flag.Bool("full-season", false, "Show all episodes of the current season") - - cu := cmdutil.RegisterCommonFlags(flag.CommandLine, tk, cmdutil.Options{ - IncludeDebug: true, - }) - flag.Parse() - cu.Apply() - - query := strings.TrimSpace(strings.Join(flag.Args(), " ")) - if query == "" { - fmt.Fprintf(os.Stderr, "Usage: media-airtime [flags]\n") - fmt.Fprintf(os.Stderr, "\nFlags:\n") - flag.PrintDefaults() - os.Exit(1) - } - - cfg.SearchType = *searchType - cfg.Limit = *limit - cfg.Exact = *exact - cfg.Season = *season - cfg.PastDays = *pastDays - cfg.FutureDays = *futureDays - cfg.Timeout = cu.Timeout - cfg.NoColor = cu.NoColor - cfg.Theme = cu.Theme - cfg.JSONOutput = cu.JSONFlag() - cfg.Debug = cu.Debug - cfg.NoBanner = *noBanner - cfg.FullSeason = *fullSeason - - if searchType := *searchType; searchType != "auto" && searchType != "series" && searchType != "movie" { - fmt.Fprintf(os.Stderr, "ERROR: -type must be auto, series, or movie\n") - os.Exit(1) - } - if cfg.Limit < 1 || cfg.Limit > 50 { - fmt.Fprintf(os.Stderr, "ERROR: -limit must be between 1 and 50\n") - os.Exit(1) - } - if cfg.Timeout <= 0 { - fmt.Fprintf(os.Stderr, "ERROR: -timeout must be positive\n") - os.Exit(1) - } - - airtime.Run(query, cfg) -} diff --git a/cmd/media-airtime/main_test.go b/cmd/media-airtime/main_test.go deleted file mode 100644 index 9de072c..0000000 --- a/cmd/media-airtime/main_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "os" - "os/exec" - "testing" -) - -func TestMainHelpExitsOnNoArgs(t *testing.T) { - if os.Getenv("GO_TEST_SUBPROCESS") == "1" { - os.Args = []string{"media-airtime"} - main() - return - } - cmd := exec.Command(os.Args[0], "-test.run=TestMainHelpExitsOnNoArgs") - cmd.Env = append(os.Environ(), "GO_TEST_SUBPROCESS=1") - err := cmd.Run() - if e, ok := err.(*exec.ExitError); ok && !e.Success() { - return - } - t.Fatal("expected exit 1 for no args") -} diff --git a/cmd/media-calendar/main.go b/cmd/media-calendar/main.go deleted file mode 100644 index b3a7001..0000000 --- a/cmd/media-calendar/main.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - - "github.com/calmcacil/CalmsToolkit/internal/calendar" - "github.com/calmcacil/CalmsToolkit/internal/cmdutil" -) - -func main() { - tk := cmdutil.LoadAndValidate() - cfg := calendar.BuildToolConfig(tk) - - days := flag.Int("days", cfg.Days, "Number of days to display") - daysPast := flag.Int("days-past", cfg.DaysPast, "Number of past days to include") - noBanner := flag.Bool("no-banner", false, "Suppress the banner header") - filter := flag.String("filter", "", "Filter: missing,available,premieres,monitored (comma-separated)") - monitoredOnly := flag.Bool("monitored-only", false, "Only show monitored items") - - cu := cmdutil.RegisterCommonFlags(flag.CommandLine, tk, cmdutil.Options{ - IncludeWatch: true, - IncludeDebug: true, - IncludeQuiet: true, - }) - flag.Parse() - cu.Apply() - - cfg.Days = *days - cfg.DaysPast = *daysPast - cfg.Timeout = cu.Timeout - cfg.NoColor = cu.NoColor - cfg.Theme = cu.Theme - cfg.JSONOutput = cu.JSONFlag() - cfg.WatchMode = cu.Watch - cfg.WatchSeconds = cu.WatchSeconds - cfg.Debug = cu.Debug - cfg.NoBanner = *noBanner - cfg.Quiet = cu.Quiet - cfg.Filter = *filter - cfg.MonitoredOnly = *monitoredOnly - - if cfg.Days < 0 { - fmt.Fprintf(os.Stderr, "ERROR: -days must be >= 0\n") - os.Exit(1) - } - if cfg.DaysPast < 0 { - fmt.Fprintf(os.Stderr, "ERROR: -days-past must be >= 0\n") - os.Exit(1) - } - if cfg.WatchSeconds < 1 { - fmt.Fprintf(os.Stderr, "ERROR: -interval must be >= 1\n") - os.Exit(1) - } - if cfg.Filter != "" { - validFilters := map[string]bool{"missing": true, "available": true, "premieres": true, "monitored": true} - for _, f := range strings.Split(cfg.Filter, ",") { - f = strings.TrimSpace(f) - if f != "" && !validFilters[f] { - fmt.Fprintf(os.Stderr, "Warning: unknown filter value %q (valid: missing, available, premieres, monitored)\n", f) - } - } - } - - calendar.Run(cfg) -} diff --git a/cmd/media-calendar/main_test.go b/cmd/media-calendar/main_test.go deleted file mode 100644 index 9f04a74..0000000 --- a/cmd/media-calendar/main_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "testing" - - "github.com/calmcacil/CalmsToolkit/internal/config" -) - -func TestConfigDefaults(t *testing.T) { - cfg := config.DefaultToolkitConfig() - if cfg == nil { - t.Fatal("DefaultToolkitConfig() returned nil") - } - if cfg.MediaCalendar.Days != 1 { - t.Errorf("Days = %d, want %d", cfg.MediaCalendar.Days, 1) - } -} diff --git a/cmd/media-requests/main.go b/cmd/media-requests/main.go deleted file mode 100644 index 2701781..0000000 --- a/cmd/media-requests/main.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - "time" - - "github.com/calmcacil/CalmsToolkit/internal/cmdutil" - "github.com/calmcacil/CalmsToolkit/internal/config" - "github.com/calmcacil/CalmsToolkit/internal/requests" -) - -func main() { - tk := cmdutil.LoadAndValidate() - cfg := requests.BuildToolConfig(tk) - - url := flag.String("url", cfg.ServerURL, "Overseerr/Jellyseerr server URL") - token := flag.String("token", config.TokenFromEnv("OVERSEERR_API_KEY", cfg.APIKey), "API key/token") - verbose := flag.Bool("verbose", cfg.Verbose, "Enable verbose diagnostic output") - - cu := cmdutil.RegisterCommonFlags(flag.CommandLine, tk, cmdutil.Options{ - IncludeQuiet: true, - }) - flag.Parse() - cu.Apply() - - cfg.ServerURL = strings.TrimSuffix(*url, "/") - cfg.APIKey = *token - cfg.Timeout = cu.Timeout - cfg.NoColor = cu.NoColor - cfg.Theme = cu.Theme - cfg.JSONOutput = cu.JSONFlag() - cfg.Verbose = *verbose - cfg.Quiet = cu.Quiet - - if cfg.ServerURL == "" { - fmt.Fprintf(os.Stderr, "ERROR: server URL is required (use -url flag or set overseerr_url in config)\n") - os.Exit(1) - } - if cfg.Timeout <= 0 { - cfg.Timeout = 10 * time.Second - } - - requests.Run(cfg) -} diff --git a/cmd/media-requests/main_test.go b/cmd/media-requests/main_test.go deleted file mode 100644 index b0c5f7a..0000000 --- a/cmd/media-requests/main_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "os" - "testing" - - "github.com/calmcacil/CalmsToolkit/internal/config" -) - -func TestConfigPath(t *testing.T) { - origHome := os.Getenv("HOME") - defer os.Setenv("HOME", origHome) - - dir := t.TempDir() - os.Setenv("HOME", dir) - - path := config.ConfigPath() - if path == "" { - t.Fatal("ConfigPath() returned empty") - } -} diff --git a/cmd/media-streams/main.go b/cmd/media-streams/main.go deleted file mode 100644 index 47eb8cc..0000000 --- a/cmd/media-streams/main.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - - "github.com/calmcacil/CalmsToolkit/internal/cmdutil" - "github.com/calmcacil/CalmsToolkit/internal/config" - "github.com/calmcacil/CalmsToolkit/internal/streams" -) - -func main() { - tk := cmdutil.LoadAndValidate() - cfg := streams.BuildToolConfig(tk) - - server := flag.String("server", cfg.ServerType, "Server type: plex, jellyfin, or both") - plexURL := flag.String("plex-url", cfg.PlexURL, "Plex server URL") - plexToken := flag.String("plex-token", config.TokenFromEnv("PLEX_TOKEN", cfg.PlexToken), "Plex authentication token") - jellyfinURL := flag.String("jellyfin-url", cfg.JellyfinURL, "Jellyfin server URL") - jellyfinToken := flag.String("jellyfin-token", config.TokenFromEnv("JELLYFIN_TOKEN", cfg.JellyfinToken), "Jellyfin API token") - historyDuration := flag.Duration("history-duration", cfg.HistoryDuration, "How long to keep session history in watch mode") - - cu := cmdutil.RegisterCommonFlags(flag.CommandLine, tk, cmdutil.Options{ - IncludeWatch: true, - IncludeQuiet: true, - }) - flag.Parse() - cu.Apply() - - cfg.ServerType = *server - cfg.PlexURL = strings.TrimSuffix(*plexURL, "/") - cfg.PlexToken = *plexToken - cfg.JellyfinURL = strings.TrimSuffix(*jellyfinURL, "/") - cfg.JellyfinToken = *jellyfinToken - cfg.Timeout = cu.Timeout - cfg.NoColor = cu.NoColor - cfg.Theme = cu.Theme - cfg.JSONOutput = cu.JSONFlag() - cfg.Watch = cu.Watch - cfg.WatchSeconds = cu.WatchSeconds - cfg.HistoryDuration = *historyDuration - cfg.Quiet = cu.Quiet - - validServers := map[string]bool{"plex": true, "jellyfin": true, "both": true} - if cfg.ServerType != "" && !validServers[cfg.ServerType] { - fmt.Fprintf(os.Stderr, "ERROR: invalid -server value %q: must be 'plex', 'jellyfin', or 'both'\n", cfg.ServerType) - os.Exit(1) - } - - if cfg.WatchSeconds < 1 { - fmt.Fprintf(os.Stderr, "ERROR: -interval must be >= 1\n") - os.Exit(1) - } - - if cfg.ServerType == streams.ServerPlex || cfg.ServerType == streams.ServerBoth { - if cfg.PlexToken == "" { - fmt.Fprintf(os.Stderr, "ERROR: PLEX_TOKEN is not set\n") - os.Exit(1) - } - } - if cfg.ServerType == streams.ServerJellyfin || cfg.ServerType == streams.ServerBoth { - if cfg.JellyfinToken == "" { - fmt.Fprintf(os.Stderr, "ERROR: JELLYFIN_TOKEN is not set\n") - os.Exit(1) - } - } - - streams.Run(cfg) -} diff --git a/cmd/media-streams/main_test.go b/cmd/media-streams/main_test.go deleted file mode 100644 index d8557b8..0000000 --- a/cmd/media-streams/main_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "testing" - - "github.com/calmcacil/CalmsToolkit/internal/config" -) - -func TestConfigDefaults(t *testing.T) { - cfg := config.DefaultToolkitConfig() - if cfg == nil { - t.Fatal("DefaultToolkitConfig() returned nil") - } - if cfg.MediaStreams.ServerType != "both" { - t.Errorf("ServerType = %q, want %q", cfg.MediaStreams.ServerType, "both") - } -} diff --git a/docs/ADDING_A_TOOL.md b/docs/ADDING_A_TOOL.md new file mode 100644 index 0000000..4cbf78a --- /dev/null +++ b/docs/ADDING_A_TOOL.md @@ -0,0 +1,12 @@ +# Adding a tool + +- Register a Cobra constructor in `internal/cli`; do not add a binary. +- Derive defaults from `internal/config`, then apply only explicitly changed flags. +- Accept the shared runtime/context and return errors. +- Model fetched data separately from terminal view models. +- Use `internal/console` for terminal, plain, JSON, and NDJSON output. +- Use typed service clients on the shared HTTP foundation; redact credentials. +- Test terminal widths 40, 60, 80, 100, and 120, including Unicode and no-color. +- Test JSON/NDJSON schema, ANSI absence, failures, partial data, and cancellation. +- Add HTTP fixtures for malformed, oversized, retry, and authentication responses. +- Update command docs and migration notes, then run `make check`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..2ba667d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,9 @@ +# Architecture + +`main` constructs the Cobra tree and is the only layer that converts errors into process exits. `internal/cli` derives feature configuration after parsing, honoring changed flags only. `internal/app.Runtime` carries context, streams, logger, clock, HTTP construction, terminal capabilities, and output mode so commands can be tested without a real terminal. + +Feature packages fetch and transform domain data. Typed clients own service wire models; shared HTTP policy belongs in `internal/httputil`. Sonarr/Radarr models should converge in `internal/arr`; Plex, Jellyfin, requests, and AniList remain separate domains. + +Rendering consumes view models. Terminal primitives and screen ownership belong in `internal/console`; feature packages must not create competing palettes, width logic, or cursor lifecycle. Machine rendering always uses the versioned envelope and never shares stdout with logging. + +Dependency direction is `main -> cli -> feature/domain -> foundation`. Foundation packages must not import feature or command packages. No package other than `main` terminates the process. diff --git a/docs/user/CLI_SPEC.md b/docs/user/CLI_SPEC.md new file mode 100644 index 0000000..bb9ca85 --- /dev/null +++ b/docs/user/CLI_SPEC.md @@ -0,0 +1,17 @@ +# CLI behavior specification + +Configuration precedence is `defaults < configuration file < environment < explicitly supplied flags`. An omitted boolean or interval flag never overwrites its configured value. The config path is explicit `--config`, then `CALMSTOOLKIT_CONFIG`, then `~/.config/calmstoolkit/config.json`. + +Global output modes are `auto`, `terminal`, `plain`, `json`, and `ndjson`. Auto selects terminal only for a capable UTF-8 TTY. `NO_COLOR`, `--no-color`, or `TERM=dumb` disables color; limited terminals use ASCII/plain output. JSON is one snapshot. NDJSON is one envelope per line and is mandatory for machine watch output. Interactive requests rejects both machine modes. + +Every machine record has: + +```json +{"schema_version":"1","generated_at":"2026-07-15T12:00:00Z","command":"streams","partial":false,"warnings":[],"data":{}} +``` + +Stdout contains views or machine records only. Prompts, logs, warnings, and redacted debug diagnostics go to stderr. ANSI control sequences are forbidden in plain, JSON, and NDJSON. + +Exit codes are 0 success/best-effort, 1 operational failure, 2 usage or configuration error, 3 partial result with `--strict`, and 130 interruption. Multi-source commands show successful data plus warnings by default; machine envelopes set `partial` and include source warnings. + +Terminal dashboards refresh inline and never use the alternate screen. One screen owner controls clearing, cursor visibility/restoration, resize, and cancellation. diff --git a/docs/user/EXAMPLES_MEDIA_REQUESTS.md b/docs/user/EXAMPLES_MEDIA_REQUESTS.md index ab058c2..ef37057 100644 --- a/docs/user/EXAMPLES_MEDIA_REQUESTS.md +++ b/docs/user/EXAMPLES_MEDIA_REQUESTS.md @@ -1,226 +1,12 @@ -# Media Requests - Usage Examples +# Requests examples -This document provides practical examples for using the `media-requests` interactive CLI tool. +Start the menu with `calmstoolkit requests`. Use the search action, enter a movie or series title, choose a match, and confirm the request. TV results allow all or selected seasons. Administrators can open pending requests to approve, decline, and choose service/root-folder overrides. -## Table of Contents - -- [Initial Setup](#initial-setup) -- [Basic Workflows](#basic-workflows) -- [Advanced Scenarios](#advanced-scenarios) -- [Integration Examples](#integration-examples) -- [Common Issues](#common-issues) - -## Initial Setup - -### Setup Wizard (Recommended) - -```bash -# Generate configuration interactively -make setup - -# Build the tool -make build - -# Run the tool -./bin/media-requests -``` - -### Command-Line Flags +For a temporary server override: ```bash -# Quick connection to local Overseerr -./bin/media-requests \ - -url "http://localhost:5055" \ - -token "your-api-key" +CALMSTOOLKIT_REQUESTS_API_KEY="$TOKEN" \ + calmstoolkit requests --url http://requests.example:5055 ``` -### Getting Your API Key - -1. Log into Overseerr/Jellyseerr web interface -2. Navigate to **Settings** > **General** -3. Find the **API Key** section -4. Copy the generated key - -## Basic Workflows - -### Request a Movie - -``` -$ ./bin/media-requests - -╔══════════════════════════════════════════╗ -║ Media Requests - Interactive Menu ║ -╚══════════════════════════════════════════╝ - -[N] New Request -[W] View Requests -[Q] Quit - -Select an option: n - -=== New Media Request === - -Enter search query (or 'back' to return): inception - -=== Search Results === - -1. 🎬 Inception (2010) - A thief who steals corporate secrets... - Rating: 8.4/10 - -2. 🎬 Inception: The Documentary (2016) - ... - -Select a number (1-10) or 'back' to cancel: 1 - -=== Confirm Request === - -Media: Inception (2010) -Type: Movie - -Submit request? (y/n): y - -✓ Request submitted successfully! -Request ID: 42 -Status: Pending Approval -``` - -### Request a TV Show - -``` -$ ./bin/media-requests - -[N] New Request -Enter search query: breaking bad - -=== Search Results === - -1. 📺 Breaking Bad (2008) -2. 🎬 Breaking Bad: El Camino (2019) - -Select: 1 - -=== Select Seasons === - -TV Show: Breaking Bad -Total Seasons: 5 - -[A] Request all seasons -[S] Select specific seasons -[B] Back - -Select option: s -Enter season numbers: 1,2,3,4,5 - -=== Confirm Request === - -Media: Breaking Bad (2008) -Type: Tv -Seasons: [1 2 3 4 5] - -Submit request? (y/n): y -``` - -## Advanced Scenarios - -### Approving a Request with a Root Folder Override - -When approving a request, you can override the destination root folder: - -``` -=== Pending Requests === - -1. [Movie] Request ID: 42 (TMDB: 27205) - Requested by: jane_smith Created: 2025-01-15 11:45 - -Select a request (1-1): 1 - -=== Request Details === - -Request ID: 42 -TMDB ID: 27205 -Type: Movie -Requested by: jane_smith -Created: 2025-01-15 11:45 -Status: Pending Approval - -Actions: -[A] Approve [D] Decline [B] Back - -Select action: a - -=== Approve Request - Root Folder Override === - -...request details... -Current Root Folder: Not set (will use server default) - -Would you like to override the root folder for this request? -[Y] Yes, select root folder -[N] No, use default (proceed with approval) -[B] Back (cancel approval) - -Select option: y - -=== Select Root Folder === - -Server: Radarr HD - -Root folders: -1. /data/movies/4k -2. /data/movies/hd - -Select a root folder (1-2): 1 - -✓ Request approved! - Root folder set to: /data/movies/4k -``` - -## Integration Examples - -### Non-Interactive Mode - -While the primary interface is interactive, you can pipe input for basic automation: - -```bash -echo -e "n\nThe Matrix\n1\ny\nq" | ./bin/media-requests -url "http://localhost:5055" -token "key" -``` - -### Container/Docker - -```bash -docker run -it --rm \ - -v ~/.config/calmstoolkit:/root/.config/calmstoolkit \ - calmstoolkit/media-requests -``` - -### SSH Tunnel - -```bash -ssh -L 5055:localhost:5055 user@remote-server -./bin/media-requests -``` - -## Common Issues - -### "ERROR: API key is not set" - -Set your API key in the config file (`~/.config/calmstoolkit/config.json`) or use `-token` flag. - -### "ERROR: Failed to connect to server" - -- Verify the server is running -- Check the URL is correct (including http:// or https://) -- Ensure no firewall is blocking the connection - -### "Invalid API key" - -Generate a new API key in Overseerr/Jellyseerr Settings > General. - -### Overseerr API Bug Warning - -If you see a warning about an Overseerr API bug, this is the tool automatically working around a known issue. No action needed. - -### Output Doesn't Look Right - -- Use `-no-color` if ANSI colors don't display correctly -- Use `-json` for machine-readable output +The command is intentionally interactive and has no machine-output mode. Scripts should use the service API directly until a non-interactive requests operation is added. diff --git a/docs/user/MIGRATION_UNIFIED_CLI.md b/docs/user/MIGRATION_UNIFIED_CLI.md new file mode 100644 index 0000000..c18962e --- /dev/null +++ b/docs/user/MIGRATION_UNIFIED_CLI.md @@ -0,0 +1,25 @@ +# Unified CLI migration + +CalmsToolkit now installs only `calmstoolkit`. The JSON configuration remains compatible. + +| Previous command | New command | +|---|---| +| `media-streams` | `calmstoolkit streams` | +| `media-calendar` | `calmstoolkit calendar` | +| `media-requests` | `calmstoolkit requests` | +| `media-airtime` | `calmstoolkit airtime` | +| `arr-feed` | `calmstoolkit feed` | +| `anisearch` | `calmstoolkit anime` | +| `calmstoolkit-setup` | `calmstoolkit config setup` | + +Single-dash long flags are now standard double-dash Cobra flags. Replace `-json` with `--output json`; a watching script must use `--output ndjson`. Feed’s `-poll` is `--interval`. Version is a command: `calmstoolkit version`. + +Optional shell aliases are not installed automatically: + +```bash +alias arr-streams='calmstoolkit streams' +alias arr-calendar='calmstoolkit calendar' +alias arr-feed='calmstoolkit feed' +``` + +Before removing old scripts, walk through `config validate`, `doctor`, one terminal invocation, one piped invocation, and every JSON consumer. JSON now uses the envelope documented in `CLI_SPEC.md`; read feature fields from `.data`. diff --git a/docs/user/README_ARR_FEED.md b/docs/user/README_ARR_FEED.md index a826ebc..a117368 100644 --- a/docs/user/README_ARR_FEED.md +++ b/docs/user/README_ARR_FEED.md @@ -1,305 +1,11 @@ -# arr-feed +# Activity feed -A CLI tool for monitoring Sonarr and Radarr activity feeds in real-time. Display recent downloads, imports, failures, and deletions from your *arr instances. - -## Features - -- **Multi-Instance Support**: Monitor multiple Sonarr and Radarr instances simultaneously -- **Real-Time Monitoring**: Watch mode continuously polls for new activity -- **Flexible Filtering**: Show/hide specific event types (grabbed, imported, failed, deleted, ignored) -- **Color-Coded Output**: Easy-to-read colored table format with action-specific colors -- **JSON Export**: Machine-readable output for automation and integration -- **Relative Timestamps**: Human-friendly time display ("5 minutes ago", "2 hours ago") -- **Custom Format Display**: Shows custom formats applied to each release -- **Episode Information**: Full episode details for TV shows (S01E05 format) - -## Installation - -### Build from Source - -```bash -make build -``` - -This creates `bin/arr-feed` ready for use. - -### Cross-Platform Build - -```bash -make build-all -``` - -Builds binaries for Linux, macOS, Windows, and FreeBSD (amd64 and arm64). - -### Install System-Wide - -```bash -sudo make install -``` - -Installs to `/usr/local/bin` (customize with `prefix=/path/to/install`). - -## Configuration - -arr-feed uses a shared JSON configuration file at `~/.config/calmstoolkit/config.json`. - -Run `make setup` to generate your configuration interactively. - -### JSON Configuration - -```json -{ - "version": 1, - "sonarr_instances": [ - {"name": "Sonarr HD", "url": "http://localhost:8989", "api_key": "your-key"} - ], - "radarr_instances": [ - {"name": "Radarr HD", "url": "http://localhost:7878", "api_key": "your-key"} - ], - "general": { - "timeout": "30s" - }, - "arr_feed": { - "poll_interval": "5s", - "history_window": "1h", - "show_grabbed": true, - "show_imported": true, - "show_failed": true, - "show_deleted": false, - "show_ignored": false, - "max_events": 50 - } -} -``` - -### CLI Flags - -All configuration values can be overridden via flags: - -```bash -arr-feed -watch -duration 24h -poll 2s -``` - -Full flag list: - -- `-poll` - Poll interval for watch mode (default: 5s) -- `-duration` - History lookback window (default: 1h) -- `-timeout` - HTTP request timeout (default: 30s) -- `-no-color` - Disable colored output -- `-json` - Output JSON instead of table -- `-watch` - Continuous monitoring mode -- `-show-grabbed` - Show grabbed events (default: true) -- `-show-imported` - Show imported events (default: true) -- `-show-failed` - Show failed events (default: true) -- `-show-deleted` - Show deleted events (default: false) -- `-show-ignored` - Show ignored events (default: false) -- `-events` - Maximum number of events to display (1-100, default: 50) -- `-quiet` - Suppress error output in watch mode - -## Usage Examples - -### Basic Usage - -Show the last hour of activity: - -```bash -arr-feed -``` - -### Watch Mode - -Continuously monitor for new activity: - -```bash -arr-feed -watch -``` - -### Extended History - -Show the last 24 hours: - -```bash -arr-feed -duration 24h -``` - -### Filter by Event Type - -Show only failed downloads: - -```bash -arr-feed -show-grabbed=false -show-imported=false -show-deleted=false -``` - -### JSON Output - -Get machine-readable output for scripting: - -```bash -arr-feed -json | jq '.[] | select(.action == "Failed")' -``` - -### Custom Poll Interval - -Watch mode with faster updates: +`calmstoolkit feed` combines Sonarr and Radarr history. It shows grabbed, imported, and failed events by default and can include deleted, ignored, or subtitle details. ```bash -arr-feed -watch -poll 2s -``` - -## Output Format - -### Table Mode (Default) - -``` -When | Action | Series/Movie | Episode | Episode Title | Quality | Formats ------------------------------------------------------------------------------------------------------------------------------------- -5 minutes ago | Imported | Breaking Bad | S01E05 | Gray Matter | Bluray-720p | AMZN, DV -12 hours ago | Grabbed | The Matrix (1999) | | | Bluray-1080p | IMAX -1 day ago | Failed | Better Call Saul | S03E02 | Witness | WEB-DL-1080p | -2 days ago | Deleted | Game of Thrones | S08E06 | The Iron Throne | HDTV-720p | - -Total events: 4 -``` - -### Color Legend - -- **Green**: Imported, Bulk Import -- **Cyan**: Grabbed -- **Red**: Failed -- **Yellow**: Deleted -- **Gray**: Ignored -- **Blue**: Renamed - -### JSON Mode - -```json -[ - { - "server": "sonarr", - "when": "2025-10-17T10:30:00Z", - "action": "Imported", - "title": "Breaking Bad", - "episode": "S01E05", - "episodeTitle": "Gray Matter", - "quality": "Bluray-720p", - "formats": ["AMZN", "DV"], - "sourceTitle": "Breaking.Bad.S01E05.720p.BluRay.x264-DEMAND", - "eventType": "downloadFolderImported", - "id": 12345 - } -] +calmstoolkit feed --duration 24h +calmstoolkit feed --watch --interval 2s +calmstoolkit --output ndjson feed --watch | jq -c '.data' ``` -## Event Types - -### Sonarr Events - -- `grabbed` → **Grabbed**: Episode added to download client -- `downloadFolderImported` → **Imported**: Episode successfully imported -- `downloadFailed` → **Failed**: Download failed or was rejected -- `episodeFileDeleted` → **Deleted**: Episode file was deleted -- `episodeFileRenamed` → **Renamed**: Episode file was renamed -- `downloadIgnored` → **Ignored**: Download was ignored (disabled by default) -- `seriesFolderImported` → **Bulk Import**: Entire series folder imported - -### Radarr Events - -- `grabbed` → **Grabbed**: Movie added to download client -- `downloadFolderImported` → **Imported**: Movie successfully imported -- `downloadFailed` → **Failed**: Download failed or was rejected -- `movieFileDeleted` → **Deleted**: Movie file was deleted -- `movieFileRenamed` → **Renamed**: Movie file was renamed -- `downloadIgnored` → **Ignored**: Download was ignored (disabled by default) - -## Error Handling - -- **Partial Failures**: If one instance fails, arr-feed continues with remaining instances -- **Complete Failure**: Exits with error if all instances fail -- **Network Timeouts**: Respects the configured timeout value -- **API Errors**: Displays HTTP status code and error message - -## Performance - -- **Parallel Fetching**: All instances are queried concurrently using goroutines -- **Efficient Polling**: Watch mode only fetches events since last poll -- **Event Caching**: Maintains in-memory cache of last 100 events in watch mode -- **Minimal Dependencies**: Uses only Go stdlib, no external dependencies - -## Troubleshooting - -### No events showing - -1. Check your API keys are correct -2. Verify URLs are accessible (no trailing slashes) -3. Ensure events exist in the configured time window -4. Try increasing `-duration` (e.g., `-duration 24h`) - -### Connection errors - -1. Verify Sonarr/Radarr is running and accessible -2. Check firewall rules -3. Confirm API endpoint: `http://your-server:8989/api/v3/history/since` -4. Test with: `curl -H "X-Api-Key: YOUR_KEY" "http://localhost:8989/api/v3/system/status"` - -### Watch mode not updating - -1. Verify poll interval is reasonable (not too fast) -2. Check for errors in the output -3. Confirm new activity is actually happening in Sonarr/Radarr - -## Integration Examples - -### Send Failed Downloads to Discord - -```bash -#!/bin/bash -while true; do - arr-feed -duration 5m -show-grabbed=false -show-imported=false -show-deleted=false -json | \ - jq -r '.[] | "Failed: \(.title) - \(.sourceTitle)"' | \ - while read line; do - curl -X POST "https://discord.com/api/webhooks/YOUR_WEBHOOK" \ - -H "Content-Type: application/json" \ - -d "{\"content\": \"$line\"}" - done - sleep 300 -done -``` - -### Log All Activity to File - -```bash -arr-feed -watch -json >> /var/log/arr-feed.log -``` - -### Count Events by Type - -```bash -arr-feed -duration 24h -json | jq -r '.[].action' | sort | uniq -c -``` - -## Development - -### Run Tests - -```bash -make test -``` - -Runs the full test suite including arr-feed tests. - -## Technical Details - -- **Dependencies**: stdlib only -- **API Version**: Sonarr/Radarr v3 API (`/api/v3/history/since`) -- **Architecture**: Concurrent fetching with goroutines, event deduplication, time-based sorting - -## See Also - -- [ARR_FEED_SPEC.md](ARR_FEED_SPEC.md) - Complete technical specification -- [media-calendar](../README.md) - Related tool for calendar view -- [Sonarr API Docs](https://github.com/Sonarr/Sonarr/wiki/API) -- [Radarr API Docs](https://github.com/Radarr/Radarr/wiki/API) - -## License - -Part of CalmsToolkit - See repository root for license information. +Configure instances under `sonarr_instances` and `radarr_instances`. A failed source does not discard successful sources; use global `--strict` when partial output must fail automation. diff --git a/docs/user/README_MEDIA_REQUESTS.md b/docs/user/README_MEDIA_REQUESTS.md index 479ae38..ba67c79 100644 --- a/docs/user/README_MEDIA_REQUESTS.md +++ b/docs/user/README_MEDIA_REQUESTS.md @@ -1,424 +1,13 @@ -# media-requests +# Media requests -Interactive CLI tool for managing media requests through Overseerr/Jellyseerr. +`calmstoolkit requests` is the interactive Overseerr/Jellyseerr request manager. It supports search, season selection, viewing and moderating pending requests, and root-folder choice during approval. -## Overview - -`media-requests` provides an interactive menu-driven interface to: -- Search for movies and TV shows -- Create new media requests -- View pending requests -- Approve or decline requests - -This tool communicates directly with the Overseerr/Jellyseerr API, providing a streamlined command-line experience for media request management. - -## Features - -- **Interactive Menu System**: Easy-to-navigate menu with hotkeys (N/W/Q) -- **Search Media**: Search TMDB database through Overseerr/Jellyseerr -- **Smart Season Selection**: For TV shows, select all seasons or specific ones -- **Request Management**: View, approve, and decline pending requests -- **Status Indicators**: Visual feedback for available/requested/pending media -- **Root Folder Override**: Select custom root folders when approving requests -- **Colored Output**: ANSI colors for better readability (can be disabled) -- **JSON Config**: Configuration via `~/.config/calmstoolkit/config.json` - -## Installation - -### Build from Source - -```bash -# Build for current platform -make build - -# Install to ~/.local/bin (or /usr/local/bin) -make install - -# Build for all platforms -make build-all -``` - -The binary will be available as `bin/media-requests`. - -### Pre-built Binaries - -Download pre-built binaries from the [Releases](https://github.com/calmcacil/CalmsToolkit/releases) page. - -## Configuration - -### Setup Wizard (Recommended) - -Run the interactive setup wizard to generate your configuration: - -```bash -make setup -``` - -This creates `~/.config/calmstoolkit/config.json`. - -### JSON Configuration File - -The tool reads configuration from `~/.config/calmstoolkit/config.json`: - -```json -{ - "version": 1, - "general": { - "timeout": "10s", - "no_color": false - }, - "media_requests": { - "overseerr_url": "http://localhost:5055", - "api_key": "your-api-key-here", - "verbose": false - } -} -``` - -### Command-Line Flags - -Flags override config file values: - -```bash -./bin/media-requests \ - -url "http://overseerr:5055" \ - -token "your-api-key-here" \ - -verbose -``` - -### Getting Your API Key - -1. Log into Overseerr/Jellyseerr web interface -2. Navigate to **Settings** > **General** -3. Find the **API Key** section -4. Copy the generated key - -## Usage - -### Basic Usage - -```bash -# Run with config file -./bin/media-requests - -# Run with command-line flags -./bin/media-requests -url "http://overseerr:5055" -token "your-token" - -# Enable verbose diagnostics -./bin/media-requests -verbose - -# Disable colored output -./bin/media-requests -no-color -``` - -### Interactive Menu - -When you launch the tool, you'll see the main menu: - -``` -╔══════════════════════════════════════════╗ -║ Media Requests - Interactive Menu ║ -╚══════════════════════════════════════════╝ - -[N] New Request -[W] View Requests -[Q] Quit - -Select an option: -``` - -#### Creating a New Request (N) - -1. **Search**: Enter a movie or TV show name - ``` - Enter search query (or 'back' to return): breaking bad - ``` - -2. **Select Media**: Choose from search results - ``` - 1. 📺 Breaking Bad (2008) [AVAILABLE] - A high school chemistry teacher turned meth cook... - Rating: 9.2/10 - - 2. 🎬 Breaking Bad: The Movie (2017) - ... - - Select a number (1-10) or 'back' to cancel: 1 - ``` - -3. **Choose Seasons** (TV shows only): - ``` - [A] Request all seasons - [S] Select specific seasons - [B] Back - - Select option: s - Enter season numbers (comma-separated, e.g., 1,2,3): 1,2,3 - ``` - -4. **Confirm Request**: - ``` - Media: Breaking Bad (2008) - Type: Tv - Seasons: [1 2 3] - - Submit request? (y/n): y - - ✓ Request submitted successfully! - Request ID: 42 - Status: Pending Approval - ``` - -#### Viewing Requests (W) - -1. **List Pending Requests**: - ``` - === Pending Requests === - - 1. [TV Show] Request ID: 42 (TMDB: 1396) - Requested by: john_doe Created: 2025-01-15 10:30 - - 2. [Movie] Request ID: 43 (TMDB: 550) - Requested by: jane_smith Created: 2025-01-15 11:45 - - Select a request (1-2), or 'back' to return: - ``` - -2. **View Request Details**: - ``` - === Request Details === - - Request ID: 42 - TMDB ID: 1396 - Type: TV Show - Requested by: john_doe - Created: 2025-01-15 10:30 - Status: Pending Approval - Seasons requested: 3 - ``` - -3. **Take Action**: - ``` - Actions: - [A] Approve [D] Decline [B] Back - - Select action: a - - Approving request... - ✓ Request approved! - ``` - -## Command-Line Options - -```bash -Usage of media-requests: - -url string - Overseerr/Jellyseerr server URL - -token string - API key/token - -timeout duration - Connection timeout (default 10s) - -no-color - Disable colored output - -verbose - Enable verbose diagnostic output - -json - Output in JSON format - -quiet - Suppress warnings -``` - -## Configuration Priority - -Configuration is loaded in the following order (highest priority last): - -1. `~/.config/calmstoolkit/config.json` (via `make setup`) -2. Command-line flags (`-url`, `-token`) - -## API Endpoints Used - -The tool interacts with the following Overseerr/Jellyseerr API endpoints: - -- `GET /api/v1/auth/me` - Verify authentication -- `GET /api/v1/search` - Search for media -- `GET /api/v1/tv/{id}` - Get TV show details (season count) -- `POST /api/v1/request` - Create new request -- `GET /api/v1/request` - Get pending requests (with fallback support) -- `GET /api/v1/request/count` - Count pending requests -- `POST /api/v1/request/{id}/approve` - Approve request -- `POST /api/v1/request/{id}/decline` - Decline request -- `GET /api/v1/service/sonarr` - List Sonarr service instances -- `GET /api/v1/service/radarr` - List Radarr service instances -- `GET /api/v1/service/{type}/{id}` - Get service details (profiles, root folders) -- `PUT /api/v1/request/{id}` - Update request (root folder override) - -### Overseerr API Bug Workaround - -This tool includes automatic fallback logic for a known Overseerr API bug where the `filter=pending` query parameter sometimes returns zero results even when pending requests exist. When this occurs: - -1. The tool first attempts to fetch requests using `GET /api/v1/request?filter=pending` -2. It checks the actual count via `GET /api/v1/request/count` endpoint -3. If there's a mismatch (e.g., count shows 5 pending but filter returns 0): - - A warning message is displayed - - The tool automatically falls back to fetching ALL requests - - Client-side filtering extracts only pending requests (status === 1) - - Success message confirms the correct number of pending requests found - -This happens transparently with no user intervention required. - -## Permissions - -Different actions require different permission levels in Overseerr/Jellyseerr: - -- **Creating Requests**: Requires `REQUEST` permission -- **Viewing All Requests**: Requires `ADMIN` or `MANAGE_REQUESTS` permission -- **Approving/Declining**: Requires `ADMIN` or `MANAGE_REQUESTS` permission - -Regular users can only view and manage their own requests. - -## Examples - -### Search and Request a Movie +Configure `media_requests.overseerr_url` and `media_requests.api_key`, or set `CALMSTOOLKIT_REQUESTS_API_KEY` (`OVERSEERR_API_KEY` remains a legacy fallback). ```bash -$ ./bin/media-requests - -# Select [N] for New Request -# Enter: "Fight Club" -# Select: 1 (the 1999 movie) -# Confirm: y -``` - -### Request All Seasons of a TV Show - -```bash -$ ./bin/media-requests - -# Select [N] for New Request -# Enter: "Breaking Bad" -# Select: 1 -# Choose: [A] Request all seasons -# Confirm: y +calmstoolkit requests +calmstoolkit requests --url http://localhost:5055 --token "$TOKEN" +calmstoolkit --no-color requests ``` -### Approve Pending Requests - -```bash -$ ./bin/media-requests - -# Select [W] for View Requests -# Select: 1 (first pending request) -# Action: [A] Approve -``` - -### Request Specific TV Seasons - -```bash -$ ./bin/media-requests - -# Select [N] for New Request -# Enter: "Game of Thrones" -# Select: 1 -# Choose: [S] Select specific seasons -# Enter: 1,2,3,4 -# Confirm: y -``` - -## Troubleshooting - -### "ERROR: API key is not set" - -Make sure you've either: -- Set `api_key` in `~/.config/calmstoolkit/config.json` -- Provided `-token` flag - -### "ERROR: Failed to connect to server" - -Verify that: -- The server URL is correct -- Overseerr/Jellyseerr is running and accessible -- The API key is valid -- There are no firewall/network issues - -### "invalid API key" - -Your API key may be incorrect or expired. Generate a new one from Settings > General in the web UI. - -### Seeing "WARNING: Overseerr API bug detected!" messages - -This is normal and indicates the tool is working around a known Overseerr API issue. The warning appears when: -- The API reports X pending requests exist -- But the filtered endpoint returns 0 results -- The tool automatically fetches all requests and filters them client-side - -No action is needed — this is handled automatically. - -### Colorized Output Not Working - -Colors work on: -- Linux/macOS terminals -- Windows 10+ (with ANSI support) - -If colors aren't displaying correctly, use the `-no-color` flag. - -## Technical Details - -### Architecture - -- **Single Binary**: No external dependencies, uses only Go stdlib -- **Interactive CLI**: Menu-driven interface with stdin/stdout -- **ANSI Colors**: Supports color-coded output for clarity -- **HTTP Client**: Configurable timeout for API calls -- **Error Handling**: Comprehensive error messages with context - -### Media Status Codes - -- `1` - Unknown -- `2` - Pending -- `3` - Processing -- `4` - Partially Available -- `5` - Available -- `6` - Deleted - -### Request Status Codes - -- `1` - Pending Approval -- `2` - Approved -- `3` - Declined - -## Compatibility - -- **Overseerr**: All versions with API v1 -- **Jellyseerr**: All versions (uses same API as Overseerr) -- **Platforms**: Linux, macOS, Windows, FreeBSD -- **Architectures**: amd64, arm64 - -## Performance - -- **Startup Time**: < 100ms -- **Search Latency**: Depends on Overseerr/TMDB response time -- **Memory Usage**: ~10MB -- **Binary Size**: ~6MB (compressed) - -## Security Considerations - -- **API Keys**: Never commit API keys to version control -- **HTTPS**: Use HTTPS URLs in production for encrypted communication -- **Permissions**: Ensure users have appropriate Overseerr permissions -- **Tokens**: API tokens should be treated as passwords - -## Related Tools - -- **media-streams**: Monitor active Plex/Jellyfin streams -- **media-calendar**: Display upcoming media releases -- **arr-feed**: Monitor Sonarr/Radarr activity feed - -## Support - -For issues, feature requests, or questions: -- GitHub Issues: [CalmsToolkit Issues](https://github.com/calmcacil/CalmsToolkit/issues) -- See also: [OVERSEERR_API_RESEARCH.md](OVERSEERR_API_RESEARCH.md) - -## License - -Part of CalmsToolkit - see main repository for license information. +Because the workflow prompts for choices, JSON and NDJSON are rejected. Tokens supplied on a command line may be visible in process listings; the config file or environment is preferable. diff --git a/docs/user/UX_DESIGN.md b/docs/user/UX_DESIGN.md index f580b3b..224fc62 100644 --- a/docs/user/UX_DESIGN.md +++ b/docs/user/UX_DESIGN.md @@ -1,221 +1,5 @@ -# CalmsToolkit Terminal UX Design +# Console design -## Principles +The normative console and machine-output contract is [CLI_SPEC.md](CLI_SPEC.md). Preserve the current box-drawn, information-dense visual language and semantic status colors. Below 60 columns use stacked cards; 60–99 use compact tables/cards; 100+ may use full layouts. Limited terminals degrade to plain ASCII. -- **Box-drawn containers** — all tools use Unicode box-drawing characters for structure -- **Terminal-aware sizing** — content scales to terminal width via `golang.org/x/term` -- **Proportional columns** — column widths are fractions of available space, not hardcoded -- **ANSI-safe rendering** — all padding and truncation uses `visibleLen()` to account for ANSI escape codes -- **Consistent color vocabulary** — same status colors across all tools -- **Buffered output** — all rendering writes to `bytes.Buffer` + `bufio.Writer`, flushed to `os.Stdout` - -## Box-Drawing Characters - -| Char | Use | -|------|-----| -| `─` | Horizontal rule | -| `│` | Vertical rule (column separator, box sides) | -| `┌` | Top-left corner | -| `┐` | Top-right corner | -| `└` | Bottom-left corner | -| `┘` | Bottom-right corner | -| `├` | Left tee (separator meets left edge) | -| `┤` | Right tee (separator meets right edge) | -| `┬` | Top tee (column junction in header) | -| `┴` | Bottom tee (column junction in footer) | -| `┼` | Cross (column junction in separator) | -| `…` | Ellipsis (truncated text) | -| `█` / `░` | Filled/empty progress bar chars | - -## Box Construction - -### Table-style layout (arr-feed, calendar) - -``` -┌── Title ──────────────────────────────────────┐ -│ Header 1 │ Header 2 │ Head… │ -├───────────────────┼───────────────────┼───────┤ -│ Data cell │ Data cell │ Data… │ -│ Data cell │ Data cell │ Data… │ -└───────────────────┴───────────────────┴───────┘ -``` - -Functions: -- `boxTop(bw, colWidths)` — draws `┌──┬──┐` -- `boxSep(bw, colWidths)` — draws `├──┼──┤` -- `boxBottom(bw, colWidths)` — draws `└──┴──┘` -- Each column's width is computed proportionally; integer remainder is added to the last column to ensure the total exactly matches terminal width. - -### Card-style layout (media-streams) - -``` -┌───────────────────────────────────────┐ -│ [PLEX] User: name │ -│ Show: Title │ -│ Status: Direct Play │ -│ Playback: ████████░░░░ 45% │ -├───────────────────────────────────────┤ -│ Total Streams: 3 Transcoding: 1 │ -└───────────────────────────────────────┘ -``` - -Functions: -- `boxStreamTop(bw, termW)` — draws `┌──┐` -- `boxStreamSep(bw, termW)` — draws `├──┤` -- `boxStreamBottom(bw, termW)` — draws `└──┘` -- Box inner width cap: 120 columns (`maxBoxInnerWidth`) -- Content lines: `"│" + padRight(" " + content + " ", boxW) + "│"` - -## Terminal Width Detection - -All tools use `golang.org/x/term`: - -```go -w, _, err := term.GetSize(int(os.Stdout.Fd())) -if err != nil || w <= 0 { - w = defaultWidth // 120 for feed, 80 for streams, 80 for calendar -} -``` - -Width is then clamped per-tool: -- Calendar: minimum column width 20 -- Feed: minimum column width 5 -- Streams: inner width clamped to [40, 120] - -## Column Width Calculation - -```go -availWidth := termWidth - totalCols - 1 // minus borders -sumProp := sum of all column proportions -colWidths[i] = availWidth * prop[i] / sumProp -// Minimum enforced per column -// Integer remainder absorbed by distribution across columns -``` - -### arr-feed column proportions - -| Column | Proportion | Notes | -|--------|-----------|-------| -| When | 12 | Relative time | -| Action | 8 | Colored by type | -| Series/Movie | 28 | Truncated with `…` | -| Episode | 8 | `S02E09` format | -| Episode Title | 20 | Truncated with `…` | -| Quality | 12 | | -| Formats | 15 | Comma-joined | -| Subtitles | 10 | Optional, only when `ShowSubtitles` is on | - -When the subtitles column is hidden, the space is redistributed proportionally. - -## Color Convention - -### Action/Event colors (arr-feed) - -| Event | Color | -|-------|-------| -| Imported / Bulk Import | `colors.Green` | -| Grabbed | `colors.Cyan` | -| Failed | `colors.Red` | -| Deleted | `colors.Yellow` | -| Ignored | `colors.Gray` | -| Renamed | `colors.Blue` | - -### Status colors (streams) - -| State | Color | -|-------|-------| -| Direct Play | `colors.Green` | -| Transcoding | `colors.Red` | -| Paused | Appended `(Paused)` to status | -| Ended session | `colors.Gray` for entire block | - -### Server label colors (streams) - -| Server | Color | -|--------|-------| -| Plex | `colors.Yellow` | -| Jellyfin | `colors.Magenta` | - -### Summary/header colors - -| Element | Color | -|---------|-------| -| Total events/streams | `colors.Bold` + `colors.Cyan` | -| Available count | `colors.Green` | -| Missing count | `colors.Red` | -| Bandwidth value | `colors.Magenta` | -| Progress percentage | `colors.Cyan` | -| No items / no streams | `colors.Green` | - -## ANSI-Safe Helpers - -These are shared across rendering code (exact functions duplicated per package for simplicity): - -```go -var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) - -// visibleLen returns the visible character count, stripping ANSI codes. -func visibleLen(s string) int { - return utf8.RuneCountInString(ansiRe.ReplaceAllString(s, "")) -} - -// padRight pads a string to the given visible width with spaces on the right. -func padRight(s string, width int) string { - v := visibleLen(s) - if v >= width { - return s - } - return s + strings.Repeat(" ", width-v) -} - -// truncateWithEllipsis truncates a string at maxLen runes, appending … if truncated. -func truncateWithEllipsis(s string, maxLen int) string { - if maxLen <= 0 { - return "" - } - runes := []rune(s) - if len(runes) <= maxLen { - return s - } - if maxLen <= 1 { - return string(runes[:maxLen]) - } - return string(runes[:maxLen-1]) + "…" -} -``` - -## Watch Mode Behavior - -All tools with watch mode follow the same pattern: - -1. Hide cursor on start: `fmt.Print(colors.HideCursor)` -2. On each refresh: `fmt.Print(colors.ClearScreen + colors.HomeCursor)` -3. Re-render full output -4. Show cursor on exit: `defer fmt.Print(colors.ShowCursor)` -5. SIGWINCH handling for terminal resize (calendar only currently) - -## Summary Footer Convention - -When displaying aggregates, the summary is styled as: - -``` -Total items: N (N episodes, N movies) — N available, N missing, N upcoming -``` - -Or for streams: - -``` -Total Streams: N Transcoding: N Bandwidth: N.NN Mbps -``` - -Each numeric value is wrapped in `clr(colors.Bold)` for emphasis. - -## Key Implementation Locations - -| Component | File | Key Functions | -|-----------|------|---------------| -| Calendar rendering | `internal/calendar/render.go` | `renderCalendar`, `renderBox`, `boxTop/Sep/Bottom`, `headerRow`, `buildDayLines` | -| Feed rendering | `internal/feed/feed.go` | `renderTable`, `boxTop/Sep/Bottom`, helpers | -| Streams rendering | `internal/streams/streams.go` | `displayTerminalOutput`, `displayStreamToBox`, `renderProgressBar`, `getBoxWidth` | -| Colors | `internal/colors/` | ANSI escape constants | -| Config | `internal/config/config.go` | Shared config types | +Dashboards refresh inline, never enter the alternate screen, and have one owner for clear/resize/cursor/cancellation lifecycle. Feature renderers consume view models and use `internal/console`; they do not introduce new terminal-control or palette frameworks. diff --git a/go.mod b/go.mod index c9344ba..ef09573 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,17 @@ module github.com/calmcacil/CalmsToolkit go 1.25.0 require ( + github.com/klauspost/compress v1.18.6 github.com/mattn/go-runewidth v0.0.23 + github.com/spf13/cobra v1.10.2 golang.org/x/sync v0.20.0 golang.org/x/term v0.36.0 + golang.org/x/time v0.15.0 ) require ( github.com/clipperhouse/uax29/v2 v2.2.0 // indirect - golang.org/x/sys v0.37.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 5913c72..25bc454 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,24 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/airtime/airtime.go b/internal/airtime/airtime.go index 1bb6397..6fef7c4 100644 --- a/internal/airtime/airtime.go +++ b/internal/airtime/airtime.go @@ -2,18 +2,16 @@ package airtime import ( "context" - "encoding/json" "fmt" "os" - "os/signal" "slices" "sort" "strings" "sync" - "syscall" "time" "github.com/calmcacil/CalmsToolkit/internal/config" + "github.com/calmcacil/CalmsToolkit/internal/console" "github.com/calmcacil/CalmsToolkit/internal/core" "github.com/calmcacil/CalmsToolkit/internal/httputil" ) @@ -182,34 +180,25 @@ func BuildToolConfig(tk *config.ToolkitConfig) ToolConfig { } // Run executes the media-airtime tool. -func Run(query string, cfg ToolConfig) { +func Run(ctx context.Context, query string, cfg ToolConfig) error { if len(cfg.SonarrInstances) == 0 && len(cfg.RadarrInstances) == 0 { - fmt.Fprintf(os.Stderr, "ERROR: No Sonarr or Radarr instances configured\n") - fmt.Fprintf(os.Stderr, "Run 'make setup' or edit ~/.config/calmstoolkit/config.json\n") - os.Exit(1) + return fmt.Errorf("no Sonarr or Radarr instances configured") } - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - client := httputil.NewTransportClient(cfg.Timeout) candidates := searchLibrary(ctx, client, query, cfg) if len(candidates) == 0 { if cfg.JSONOutput { - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - encoder.Encode(map[string]interface{}{ + return console.WriteEnvelope(os.Stdout, "airtime", map[string]interface{}{ "query": query, "candidates": []map[string]interface{}{}, "selected": nil, "airtime": nil, "error": "no matches found", - }) - return + }, false, nil, time.Now()) } - fmt.Fprintf(os.Stderr, "No matches found for %q\n", query) - os.Exit(2) + return fmt.Errorf("no matches found for %q", query) } if cfg.Debug { @@ -224,29 +213,27 @@ func Run(query string, cfg ToolConfig) { selected := pickCandidate(ctx, candidates, query, cfg) if selected == nil { if ctx.Err() != nil { - os.Exit(130) + return ctx.Err() } - os.Exit(3) + return fmt.Errorf("no candidate selected") } info := resolveAirtime(ctx, client, *selected, cfg) if cfg.JSONOutput { - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") candOut := make([]map[string]interface{}, len(candidates)) for i, sm := range candidates { candOut[i] = candidateToMap(sm.Candidate, sm.Score) } - encoder.Encode(map[string]interface{}{ + return console.WriteEnvelope(os.Stdout, "airtime", map[string]interface{}{ "query": query, "candidates": candOut, "selected": candidateToMap(selected.Candidate, selected.Score), "airtime": infoToMap(info), - }) - return + }, false, nil, time.Now()) } renderCard(info, cfg) + return nil } func searchLibrary(ctx context.Context, client *httputil.Client, query string, cfg ToolConfig) []scoredMatch { diff --git a/internal/airtime/render.go b/internal/airtime/render.go index 38e8dae..88a7070 100644 --- a/internal/airtime/render.go +++ b/internal/airtime/render.go @@ -120,7 +120,7 @@ func interactiveSelect(ctx context.Context, candidates []scoredMatch, query stri return nil } if line == "s" { - fmt.Fprintf(os.Stderr, "Start a new search with: media-airtime \n") + fmt.Fprintf(os.Stderr, "Start a new search with: calmstoolkit airtime \n") return nil } diff --git a/internal/anisearch/anisearch_test.go b/internal/anisearch/anisearch_test.go new file mode 100644 index 0000000..c20fa47 --- /dev/null +++ b/internal/anisearch/anisearch_test.go @@ -0,0 +1,1255 @@ +package anisearch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/calmcacil/CalmsToolkit/internal/config" + "github.com/calmcacil/CalmsToolkit/internal/core" + "github.com/klauspost/compress/zstd" + "golang.org/x/time/rate" +) + +// --- Model Tests --- + +func TestDisplayTitle(t *testing.T) { + romaji := "Sword Art Online" + english := "Sword Art Online" + native := "ソードアート・オンライン" + + tests := []struct { + name string + t Title + want string + }{ + { + name: "english preferred over romaji", + t: Title{Romaji: &romaji, English: &english, Native: &native}, + want: "Sword Art Online", + }, + { + name: "romaji fallback when english empty", + t: Title{Romaji: &romaji, English: strPtr("")}, + want: "Sword Art Online", + }, + { + name: "romaji fallback when english nil", + t: Title{Romaji: &romaji}, + want: "Sword Art Online", + }, + { + name: "empty when all nil", + t: Title{}, + want: "", + }, + { + name: "empty when romaji nil and english empty", + t: Title{English: strPtr("")}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.t.DisplayTitle(); got != tt.want { + t.Errorf("DisplayTitle() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestStudioNames(t *testing.T) { + tests := []struct { + name string + s Show + want []string + }{ + { + name: "with studios", + s: Show{Studios: &Studios{Nodes: []StudioNode{{Name: "A-1 Pictures"}, {Name: "CloverWorks"}}}}, + want: []string{"A-1 Pictures", "CloverWorks"}, + }, + { + name: "nil studios", + s: Show{Studios: nil}, + want: nil, + }, + { + name: "empty studios", + s: Show{Studios: &Studios{Nodes: []StudioNode{}}}, + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.s.StudioNames() + if len(got) != len(tt.want) { + t.Errorf("StudioNames() = %v, want %v", got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("StudioNames()[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestLookupByAniList(t *testing.T) { + m := &AnibridgeMapping{ + byAniList: map[int]int{101: 1001, 102: 1002}, + } + + tvdb, ok := m.LookupByAniList(101) + if !ok || tvdb != 1001 { + t.Errorf("LookupByAniList(101) = (%d, %v), want (1001, true)", tvdb, ok) + } + + tvdb, ok = m.LookupByAniList(999) + if ok || tvdb != 0 { + t.Errorf("LookupByAniList(999) = (%d, %v), want (0, false)", tvdb, ok) + } + + // Nil mapping + var nilMap *AnibridgeMapping + tvdb, ok = nilMap.LookupByAniList(101) + if ok || tvdb != 0 { + t.Errorf("nil LookupByAniList = (%d, %v), want (0, false)", tvdb, ok) + } +} + +// --- Client Tests --- + +func TestNewAniListClient(t *testing.T) { + c := NewAniListClient(0) + if c.http.Timeout != 30*time.Second { + t.Errorf("default timeout = %v, want 30s", c.http.Timeout) + } + + c = NewAniListClient(10 * time.Second) + if c.http.Timeout != 10*time.Second { + t.Errorf("custom timeout = %v, want 10s", c.http.Timeout) + } +} + +func TestJitter(t *testing.T) { + tests := []struct { + name string + d time.Duration + }{ + {"zero", 0}, + {"1s", time.Second}, + {"100ms", 100 * time.Millisecond}, + {"10s", 10 * time.Second}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for range 100 { + got := jitter(tt.d) + if tt.d > 0 { + if got < tt.d-tt.d/4 || got > tt.d+tt.d/4 { + t.Errorf("jitter(%v) = %v, want within ±25%%", tt.d, got) + } + } else { + if got != 0 { + t.Errorf("jitter(0) = %v, want 0", got) + } + } + } + }) + } +} + +func TestSearchPage(t *testing.T) { + // Build a valid GraphQL-like response for AniList. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request basics. + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %s, want application/json", r.Header.Get("Content-Type")) + } + if r.Header.Get("User-Agent") != anilistUserAgent { + t.Errorf("User-Agent = %s, want %s", r.Header.Get("User-Agent"), anilistUserAgent) + } + + resp := graphqlResponse{} + resp.Data.Page.PageInfo = PageInfo{ + HasNextPage: false, + CurrentPage: 1, + Total: 2, + } + resp.Data.Page.Media = []Show{ + { + ID: 1, + Format: "TV", + Title: Title{Romaji: strPtr("Test Anime"), English: strPtr("Test Anime")}, + Status: "FINISHED", + }, + { + ID: 2, + Format: "MOVIE", + Title: Title{Romaji: strPtr("Test Movie")}, + Status: "RELEASING", + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Override the endpoint. + origEndpoint := aniListEndpoint + aniListEndpoint = server.URL + defer func() { aniListEndpoint = origEndpoint }() + + ctx := context.Background() + client := NewAniListClient(5 * time.Second) + + result, err := client.SearchPage(ctx, "test", 1, 10) + if err != nil { + t.Fatalf("SearchPage error: %v", err) + } + + if len(result.Media) != 2 { + t.Fatalf("got %d results, want 2", len(result.Media)) + } + if result.Media[0].Title.DisplayTitle() != "Test Anime" { + t.Errorf("result[0].Title = %q, want %q", result.Media[0].Title.DisplayTitle(), "Test Anime") + } + if result.PageInfo.Total != 2 { + t.Errorf("Total = %d, want 2", result.PageInfo.Total) + } +} + +func TestSearchPageErrors(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantErr string + }{ + { + name: "server error", + statusCode: http.StatusInternalServerError, + body: `{"error":"internal"}`, + wantErr: "giving up after", + }, + { + name: "bad request", + statusCode: http.StatusBadRequest, + body: `{"error":"bad query"}`, + wantErr: "400", + }, + { + name: "graphql error", + statusCode: http.StatusOK, + body: `{"errors":[{"message":"not found"}]}`, + wantErr: "not found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + // Speed up retries for tests. + origEndpoint := aniListEndpoint + aniListEndpoint = server.URL + defer func() { aniListEndpoint = origEndpoint }() + + origRetry := maxRetry + maxRetry = 1 + defer func() { maxRetry = origRetry }() + + origDelay := rateLimitDelay + rateLimitDelay = 1 * time.Millisecond + defer func() { rateLimitDelay = origDelay }() + + ctx := context.Background() + client := NewAniListClient(5 * time.Second) + + _, err := client.SearchPage(ctx, "test", 1, 10) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestSearchPagination(t *testing.T) { + callCount := 0 + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + callCount++ + page := callCount + mu.Unlock() + + resp := graphqlResponse{} + resp.Data.Page.PageInfo = PageInfo{ + HasNextPage: page < 3, + CurrentPage: page, + Total: 3, + } + resp.Data.Page.Media = []Show{ + { + ID: page, + Title: Title{Romaji: strPtr(fmt.Sprintf("Page %d Anime", page))}, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + origEndpoint := aniListEndpoint + aniListEndpoint = server.URL + defer func() { aniListEndpoint = origEndpoint }() + + ctx := context.Background() + client := NewAniListClient(5 * time.Second) + + // Search paginates through all pages. + result, err := client.Search(ctx, "test", 50) + if err != nil { + t.Fatalf("Search error: %v", err) + } + + if len(result.Media) != 3 { + t.Errorf("got %d results across pages, want 3", len(result.Media)) + } +} + +// --- Mapping Tests --- + +func TestCountSourceEpisodes(t *testing.T) { + tests := []struct { + name string + raw string + want int + }{ + { + name: "single range", + raw: `{"1-12":"1-12"}`, + want: 12, + }, + { + name: "multiple ranges", + raw: `{"1-12":"1-12","13-24":"13-24"}`, + want: 24, + }, + { + name: "single episode", + raw: `{"5":"5"}`, + want: 1, + }, + { + name: "open-ended range", + raw: `{"5-":"5-"}`, + want: 1, + }, + { + name: "empty", + raw: `{}`, + want: 0, + }, + { + name: "null", + raw: `null`, + want: 0, + }, + { + name: "empty string", + raw: ``, + want: 0, + }, + { + name: "negative eps ignored", + raw: `{"-5":"-5"}`, + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := countSourceEpisodes(json.RawMessage(tt.raw)) + if got != tt.want { + t.Errorf("countSourceEpisodes(%q) = %d, want %d", tt.raw, got, tt.want) + } + }) + } +} + +func TestExtractTVDB(t *testing.T) { + tests := []struct { + name string + json string + wantID int + wantOK bool + }{ + { + name: "single s1 entry", + json: `{"tvdb_show:12345:s1": {"1-12":"1-12"}}`, + wantID: 12345, + wantOK: true, + }, + { + name: "multiple scopes picks s1", + json: `{"tvdb_show:12345:s0": {"1-3":"4-6"}, "tvdb_show:12345:s1": {"1-12":"1-12"}}`, + wantID: 12345, + wantOK: true, + }, + { + name: "no s1 falls to highest ep count", + json: `{"tvdb_show:54321:s0": {"1-3":"4-6"}, "tvdb_show:54321:s2": {"1-24":"1-24"}}`, + wantID: 54321, + wantOK: true, + }, + { + name: "prefers s1 even if fewer episodes", + json: `{"tvdb_show:33333:s1": {"1-2":"1-2"}, "tvdb_show:44444:s2": {"1-24":"1-24"}}`, + wantID: 33333, + wantOK: true, + }, + { + name: "no matching prefix", + json: `{"other:123:s1": {"1-12":"1-12"}}`, + wantID: 0, + wantOK: false, + }, + { + name: "malformed tvdb id", + json: `{"tvdb_show:bad:s1": {"1-12":"1-12"}}`, + wantID: 0, + wantOK: false, + }, + { + name: "empty object", + json: `{}`, + wantID: 0, + wantOK: false, + }, + { + name: "multiple seasons of same show s1 highest ep", + json: `{"tvdb_show:99999:s1": {"1-12":"1-12"}, "tvdb_show:99999:s2": {"1-24":"1-24"}}`, + wantID: 99999, + wantOK: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec := json.NewDecoder(strings.NewReader(tt.json)) + id, ok := extractTVDB(dec) + if id != tt.wantID || ok != tt.wantOK { + t.Errorf("extractTVDB(%q) = (%d, %v), want (%d, %v)", + tt.json, id, ok, tt.wantID, tt.wantOK) + } + }) + } +} + +func TestParseAnibridgeJSON(t *testing.T) { + // Build a sample mapping JSON. + input := `{ + "mal:1": {"tvdb_show:100:s1": {"1-12":"1-12"}}, + "anilist:101": {"tvdb_show:200:s1": {"1-24":"1-24"}}, + "mal:2": {"tvdb_show:300:s2": {"1-12":"1-12"}}, + "anilist:102": {"tvdb_show:400:s1": {"1-1":"1-1"}}, + "$meta": {"schema_version": "3"}, + "mal:invalid": {"tvdb_show:500:s1": {"1-12":"1-12"}}, + "anilist:invalid": {"tvdb_show:600:s1": {"1-12":"1-12"}} + }` + + m, err := parseAnibridgeJSON(strings.NewReader(input)) + if err != nil { + t.Fatalf("parseAnibridgeJSON error: %v", err) + } + + if m == nil { + t.Fatal("parseAnibridgeJSON returned nil") + } + + // Check MAL entries. + if tvdb, ok := m.byMAL[1]; !ok || tvdb != 100 { + t.Errorf("byMAL[1] = (%d, %v), want (100, true)", tvdb, ok) + } + + // Check AniList entries (s1 preference over s2). + if tvdb, ok := m.byAniList[101]; !ok || tvdb != 200 { + t.Errorf("byAniList[101] = (%d, %v), want (200, true)", tvdb, ok) + } + + // Invalid IDs should be skipped. + if _, ok := m.byMAL[0]; ok { + t.Error("byMAL[0] should not exist") + } + if _, ok := m.byAniList[0]; ok { + t.Error("byAniList[0] should not exist") + } +} + +func TestParseAnibridgeBytes(t *testing.T) { + // Build zstd-compressed test data. + input := `{"anilist:101": {"tvdb_show:200:s1": {"1-24":"1-24"}}}` + + var compressed bytes.Buffer + zw, err := zstd.NewWriter(&compressed) + if err != nil { + t.Fatalf("zstd writer: %v", err) + } + zw.Write([]byte(input)) + zw.Close() + + m, err := parseAnibridgeBytes(compressed.Bytes()) + if err != nil { + t.Fatalf("parseAnibridgeBytes error: %v", err) + } + + if tvdb, ok := m.LookupByAniList(101); !ok || tvdb != 200 { + t.Errorf("LookupByAniList(101) = (%d, %v), want (200, true)", tvdb, ok) + } +} + +func TestParseAnibridgeBytesCorrupt(t *testing.T) { + _, err := parseAnibridgeBytes([]byte("not-zstd-data")) + if err == nil { + t.Fatal("expected error for corrupt data, got nil") + } +} + +func TestWriteFileAtomic(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdir", "test.txt") + + err := writeFileAtomic(path, []byte("hello world")) + if err != nil { + t.Fatalf("writeFileAtomic error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + if string(data) != "hello world" { + t.Errorf("content = %q, want %q", string(data), "hello world") + } +} + +func TestMappingMetaRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.meta.json") + + meta := mappingMetadata{ + ETag: `"abc123"`, + LastModified: "Mon, 01 Jan 2024 00:00:00 GMT", + FetchedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + URL: "https://example.com/mapping.json.zst", + } + + err := writeMappingMeta(path, meta) + if err != nil { + t.Fatalf("writeMappingMeta error: %v", err) + } + + loaded, err := readMappingMeta(path) + if err != nil { + t.Fatalf("readMappingMeta error: %v", err) + } + + if loaded.ETag != meta.ETag { + t.Errorf("ETag = %q, want %q", loaded.ETag, meta.ETag) + } + if loaded.URL != meta.URL { + t.Errorf("URL = %q, want %q", loaded.URL, meta.URL) + } + if !loaded.FetchedAt.Equal(meta.FetchedAt) { + t.Errorf("FetchedAt = %v, want %v", loaded.FetchedAt, meta.FetchedAt) + } +} + +func TestReadMappingMetaNotFound(t *testing.T) { + meta, err := readMappingMeta("/nonexistent/path.meta.json") + if err != nil { + t.Fatalf("readMappingMeta error for missing file: %v", err) + } + if meta.ETag != "" || !meta.FetchedAt.IsZero() { + t.Errorf("expected zero metadata for missing file, got %+v", meta) + } +} + +// --- Render Functions Tests --- + +func TestStripHTML(t *testing.T) { + tests := []struct { + name string + s string + want string + }{ + {"no tags", "hello world", "hello world"}, + {"simple tags", "

hello

", "hello"}, + {"nested tags", "

hello

", "hello"}, + {"with entities", "hello & goodbye", "hello & goodbye"}, + {"lt entity", "a < b", "a < b"}, + {"gt entity", "a > b", "a > b"}, + {"nbsp entity", "hello world", "hello world"}, + {"ampersand not entity", "rock & roll", "rock & roll"}, + {"empty string", "", ""}, + {"only tags", "
", ""}, + {"mixed content", "bold and italic", "bold and italic"}, + {"entity apostrophe", "'tis", "'tis"}, + {"entity quote", ""hello"", "\"hello\""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stripHTML(tt.s); got != tt.want { + t.Errorf("stripHTML(%q) = %q, want %q", tt.s, got, tt.want) + } + }) + } +} + +func TestWordWrap(t *testing.T) { + tests := []struct { + name string + text string + width int + want []string + }{ + { + name: "short line no wrap", + text: "hello world", + width: 80, + want: []string{"hello world"}, + }, + { + name: "wraps at width", + text: "hello world foo bar baz", + width: 10, + want: []string{"hello", "world foo", "bar baz"}, + }, + { + name: "empty text", + text: "", + width: 80, + want: nil, + }, + { + name: "zero width", + text: "hello world", + width: 0, + want: []string{"hello world"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wordWrap(tt.text, tt.width) + if len(got) != len(tt.want) { + t.Errorf("wordWrap(%q, %d) = %v (len=%d), want %v (len=%d)", + tt.text, tt.width, got, len(got), tt.want, len(tt.want)) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("wordWrap(%q, %d)[%d] = %q, want %q", + tt.text, tt.width, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestTruncateVis(t *testing.T) { + tests := []struct { + name string + s string + maxLen int + want string + }{ + {"short string", "hello", 10, "hello"}, + {"needs truncation", "hello world how are you", 15, "hello world ..."}, + {"max len 3", "hello", 3, "hel"}, + {"max len 0", "hello", 0, ""}, + {"empty string", "", 10, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateVis(tt.s, tt.maxLen) + if got != tt.want { + t.Errorf("truncateVis(%q, %d) = %q, want %q", tt.s, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestFormatEpisodes(t *testing.T) { + tests := []struct { + name string + ep *int + want string + }{ + {"nil", nil, "?"}, + {"zero", intPtr(0), "0"}, + {"positive", intPtr(24), "24"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatEpisodes(tt.ep); got != tt.want { + t.Errorf("formatEpisodes(%v) = %q, want %q", tt.ep, got, tt.want) + } + }) + } +} + +func TestFormatScore(t *testing.T) { + tests := []struct { + name string + score *int + want string + }{ + {"nil", nil, "—"}, + {"zero", intPtr(0), "0%"}, + {"positive", intPtr(85), "85%"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatScore(tt.score); got != tt.want { + t.Errorf("formatScore(%v) = %q, want %q", tt.score, got, tt.want) + } + }) + } +} + +func TestFormatStatus(t *testing.T) { + tests := []struct { + name string + status string + want string + }{ + {"finished", "FINISHED", "Finished"}, + {"releasing", "RELEASING", "Airing"}, + {"not released", "NOT_YET_RELEASED", "Not Yet Released"}, + {"cancelled", "CANCELLED", "Cancelled"}, + {"hiatus", "HIATUS", "Hiatus"}, + {"unknown", "UNKNOWN", "UNKNOWN"}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatStatus(tt.status); got != tt.want { + t.Errorf("formatStatus(%q) = %q, want %q", tt.status, got, tt.want) + } + }) + } +} + +func TestFormatSeason(t *testing.T) { + tests := []struct { + name string + season string + year *int + want string + }{ + {"winter with year", "WINTER", intPtr(2024), "Winter 2024"}, + {"spring no year", "SPRING", nil, "Spring"}, + {"summer with year", "SUMMER", intPtr(2023), "Summer 2023"}, + {"fall with year", "FALL", intPtr(2022), "Fall 2022"}, + {"unknown", "UNKNOWN", nil, "UNKNOWN"}, + {"empty", "", nil, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatSeason(tt.season, tt.year); got != tt.want { + t.Errorf("formatSeason(%q, %v) = %q, want %q", tt.season, tt.year, got, tt.want) + } + }) + } +} + +func TestFormatPopularity(t *testing.T) { + tests := []struct { + name string + p *int + want string + }{ + {"nil", nil, "—"}, + {"zero", intPtr(0), "#0"}, + {"positive", intPtr(12345), "#12345"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatPopularity(tt.p); got != tt.want { + t.Errorf("formatPopularity(%v) = %q, want %q", tt.p, got, tt.want) + } + }) + } +} + +func TestFormatTVDB(t *testing.T) { + tests := []struct { + name string + tvdb int + want string + }{ + {"zero", 0, "—"}, + {"negative", -1, "—"}, + {"positive", 12345, "12345"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatTVDB(tt.tvdb); got != tt.want { + t.Errorf("formatTVDB(%d) = %q, want %q", tt.tvdb, got, tt.want) + } + }) + } +} + +func TestFormatRelativeTime(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + t time.Time + want string + }{ + {"just now", now, "just now"}, + {"1 minute ago", now.Add(-1 * time.Minute), "1 minute ago"}, + {"5 minutes ago", now.Add(-5 * time.Minute), "5 minutes ago"}, + {"1 hour ago", now.Add(-1 * time.Hour), "1 hour ago"}, + {"3 hours ago", now.Add(-3 * time.Hour), "3 hours ago"}, + {"yesterday", now.Add(-24 * time.Hour), "yesterday"}, + {"5 days ago", now.Add(-5 * 24 * time.Hour), "5 days ago"}, + {"in the future", now.Add(-2 * time.Hour), "2 hours ago"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatRelativeTime(tt.t) + if got != tt.want { + t.Errorf("formatRelativeTime(%v) = %q, want %q", tt.t, got, tt.want) + } + }) + } +} + +// --- Input Tests --- + +func TestReadKey(t *testing.T) { + tests := []struct { + name string + input []byte + want KeyEvent + }{ + {"up arrow", []byte{0x1b, '[', 'A'}, KeyUp}, + {"down arrow", []byte{0x1b, '[', 'B'}, KeyDown}, + {"enter (CR)", []byte{0x0d}, KeyEnter}, + {"enter (LF)", []byte{0x0a}, KeyEnter}, + {"ctrl+c", []byte{0x03}, KeyCtrlC}, + {"q", []byte{'q'}, KeyQuit}, + {"Q", []byte{'Q'}, KeyQuit}, + {"n", []byte{'n'}, KeyNext}, + {"N", []byte{'N'}, KeyNext}, + {"p", []byte{'p'}, KeyPrev}, + {"P", []byte{'P'}, KeyPrev}, + {"b", []byte{'b'}, KeyBack}, + {"B", []byte{'B'}, KeyBack}, + {"0", []byte{'0'}, KeyDigit}, + {"9", []byte{'9'}, KeyDigit}, + {"escape", []byte{0x1b}, KeyEsc}, + {"unknown", []byte{'x'}, KeyUnknown}, + {"empty read", nil, KeyUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // We test the mapping logic directly by simulating stdin. + // Since readKey reads from os.Stdin, we test through a helper + // that validates the key mapping is correct. + got := simulateReadKey(tt.input) + if got != tt.want { + t.Errorf("readKey(%v) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +// simulateReadKey replicates readKey logic for testing. +func simulateReadKey(input []byte) KeyEvent { + if len(input) == 0 { + return KeyUnknown + } + + switch input[0] { + case 0x1b: + if len(input) >= 3 && input[1] == '[' { + switch input[2] { + case 'A': + return KeyUp + case 'B': + return KeyDown + } + } + return KeyEsc + case 0x0d, 0x0a: + return KeyEnter + case 0x03: + return KeyCtrlC + case 'q', 'Q': + return KeyQuit + case 'n', 'N': + return KeyNext + case 'p', 'P': + return KeyPrev + case 'b', 'B': + return KeyBack + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + return KeyDigit + default: + return KeyUnknown + } +} + +func TestPromptForQuery(t *testing.T) { + origStdin := os.Stdin + defer func() { os.Stdin = origStdin }() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + + // Write input then close write end before reading. + w.Write([]byte("Attack on Titan\n")) + w.Close() + + os.Stdin = r + + q, err := PromptForQuery() + if err != nil { + t.Fatalf("PromptForQuery error: %v", err) + } + if q != "Attack on Titan" { + t.Errorf("got %q, want %q", q, "Attack on Titan") + } +} + +// --- ITOA test --- + +func TestItoa(t *testing.T) { + if got := itoa(42); got != "42" { + t.Errorf("itoa(42) = %q, want %q", got, "42") + } + if got := itoa(0); got != "0" { + t.Errorf("itoa(0) = %q, want %q", got, "0") + } + if got := itoa(-5); got != "-5" { + t.Errorf("itoa(-5) = %q, want %q", got, "-5") + } + if got := itoa(math.MaxInt); got != fmt.Sprintf("%d", math.MaxInt) { + t.Errorf("itoa(MaxInt) = %q, want %q", got, fmt.Sprintf("%d", math.MaxInt)) + } +} + +// --- BuildToolConfig Tests --- + +func TestBuildToolConfig(t *testing.T) { + tk := &config.ToolkitConfig{ + General: config.GeneralConfig{ + Timeout: "15s", + NoColor: true, + Theme: "catppuccin-mocha", + }, + AniSearch: config.AniSearchConfig{ + Limit: 10, + MappingURL: "https://example.com/mapping.json.zst", + MappingPath: "/tmp/mappings.json.zst", + }, + } + + cfg := BuildToolConfig(tk) + + if cfg.Timeout != 15*time.Second { + t.Errorf("Timeout = %v, want 15s", cfg.Timeout) + } + if !cfg.NoColor { + t.Error("NoColor = false, want true") + } + if cfg.Theme != "catppuccin-mocha" { + t.Errorf("Theme = %q, want %q", cfg.Theme, "catppuccin-mocha") + } + if cfg.Limit != 10 { + t.Errorf("Limit = %d, want 10", cfg.Limit) + } + if cfg.MappingURL != "https://example.com/mapping.json.zst" { + t.Errorf("MappingURL = %q, want %q", cfg.MappingURL, "https://example.com/mapping.json.zst") + } + if cfg.MappingPath != "/tmp/mappings.json.zst" { + t.Errorf("MappingPath = %q, want %q", cfg.MappingPath, "/tmp/mappings.json.zst") + } +} + +func TestBuildToolConfigDefaults(t *testing.T) { + cfg := BuildToolConfig(nil) + + if cfg.Timeout != 10*time.Second { + t.Errorf("default Timeout = %v, want 10s", cfg.Timeout) + } + if cfg.Limit != 5 { + t.Errorf("default Limit = %d, want 5", cfg.Limit) + } + if cfg.MappingURL != "https://github.com/anibridge/anibridge-mappings/releases/download/v3/mappings.json.zst" { + t.Errorf("default MappingURL = %q, want default", cfg.MappingURL) + } + if cfg.Page != 1 { + t.Errorf("default Page = %d, want 1", cfg.Page) + } +} + +func TestBuildToolConfigPartial(t *testing.T) { + tk := &config.ToolkitConfig{ + General: config.GeneralConfig{ + NoColor: true, + Theme: "catppuccin-latte", + }, + AniSearch: config.AniSearchConfig{ + Limit: 3, + }, + } + + cfg := BuildToolConfig(tk) + + // These should come from the config. + if cfg.Limit != 3 { + t.Errorf("Limit = %d, want 3", cfg.Limit) + } + if !cfg.NoColor { + t.Error("NoColor = false, want true") + } + if cfg.Theme != "catppuccin-latte" { + t.Errorf("Theme = %q, want %q", cfg.Theme, "catppuccin-latte") + } + + // These should be defaults. + if cfg.MappingURL != "https://github.com/anibridge/anibridge-mappings/releases/download/v3/mappings.json.zst" { + t.Errorf("default MappingURL = %q, want default", cfg.MappingURL) + } + if cfg.Page != 1 { + t.Errorf("default Page = %d, want 1", cfg.Page) + } +} + +// --- Helpers --- + +func strPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} + +// TestRandSeed ensures jitter is deterministic enough for tests. +func TestJitterRange(t *testing.T) { + // Test that jitter stays within ±25%. + for range 1000 { + d := 2 * time.Second + result := jitter(d) + min := d - d/4 + max := d + d/4 + if result < min || result > max { + t.Errorf("jitter(%v) = %v, outside range [%v, %v]", d, result, min, max) + } + } +} + +// TestJitterDeterminism checks that jitter with zero duration stays zero. +func TestJitterZeroDuration(t *testing.T) { + zeroDur := time.Duration(0) + if res := jitter(zeroDur); res != zeroDur { + t.Errorf("jitter(0) = %v, want 0", res) + } +} + +// TestSearchResultMarshaling tests that SearchResult can be JSON marshaled. +func TestSearchResultMarshaling(t *testing.T) { + result := SearchResult{ + PageInfo: PageInfo{ + HasNextPage: true, + CurrentPage: 1, + Total: 42, + }, + Media: []Show{ + {ID: 1, Title: Title{Romaji: strPtr("Test")}}, + }, + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var decoded SearchResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + if decoded.PageInfo.Total != 42 { + t.Errorf("Total = %d, want 42", decoded.PageInfo.Total) + } + if len(decoded.Media) != 1 { + t.Errorf("Media len = %d, want 1", len(decoded.Media)) + } +} + +// TestAniListClientConcurrency tests that multiple concurrent searches don't race. +func TestAniListClientConcurrency(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := graphqlResponse{} + resp.Data.Page.PageInfo = PageInfo{HasNextPage: false, CurrentPage: 1} + resp.Data.Page.Media = []Show{{ID: 1, Title: Title{Romaji: strPtr("Concurrent Test")}}} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + origEndpoint := aniListEndpoint + aniListEndpoint = server.URL + defer func() { aniListEndpoint = origEndpoint }() + + ctx := context.Background() + client := NewAniListClient(5 * time.Second) + + var wg sync.WaitGroup + for range 5 { + wg.Add(1) + go func() { + defer wg.Done() + _, err := client.SearchPage(ctx, "test", 1, 10) + if err != nil { + t.Errorf("concurrent SearchPage error: %v", err) + } + }() + } + wg.Wait() +} + +// TestRetryAfterCredit ensures that lock contention is released properly. +func TestClientRateLimitMutex(t *testing.T) { + c := &AniListClient{ + http: &http.Client{Timeout: time.Second}, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + // Should not block. + ctx := context.Background() + err := c.throttle(ctx) + if err != nil { + t.Errorf("throttle error: %v", err) + } +} + +// TestCommandLineOptions tests that runDetailView doesn't crash with nil mapping. +func TestRunDetailViewNilMapping(t *testing.T) { + // Just ensure the rendering functions don't panic. + defer func() { + if r := recover(); r != nil { + t.Errorf("runDetailView with nil mapping panicked: %v", r) + } + }() + + // Simulate the rendering without terminal interaction. + show := Show{ + ID: 1, + Format: "TV", + Title: Title{Romaji: strPtr("Test")}, + Status: "FINISHED", + } + + cfg := ToolConfig{ + CommonConfig: core.CommonConfig{ + NoColor: true, + }, + } + + // Just test renderDetail since runDetailView needs actual stdin. + output := renderDetail(show, 0, cfg) + if output == "" { + t.Error("renderDetail returned empty output") + } + if !strings.Contains(output, "Test") { + t.Errorf("renderDetail output should contain title, got: %q", output) + } +} + +// TestSearchResultIsEmpty checks that empty searches return an empty result. +func TestSearchPageEmptyResult(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := graphqlResponse{} + resp.Data.Page.PageInfo = PageInfo{HasNextPage: false, CurrentPage: 1} + resp.Data.Page.Media = []Show{} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + origEndpoint := aniListEndpoint + aniListEndpoint = server.URL + defer func() { aniListEndpoint = origEndpoint }() + + ctx := context.Background() + client := NewAniListClient(5 * time.Second) + + result, err := client.SearchPage(ctx, "nonexistent", 1, 10) + if err != nil { + t.Fatalf("SearchPage error: %v", err) + } + + if len(result.Media) != 0 { + t.Errorf("expected 0 results, got %d", len(result.Media)) + } +} + +// TestGraphQLErrorResponse ensures GraphQL-level errors are surfaced. +func TestGraphQLErrorResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"errors":[{"message":"not found"},{"message":"invalid search"}]}`)) + })) + defer server.Close() + + origEndpoint := aniListEndpoint + aniListEndpoint = server.URL + defer func() { aniListEndpoint = origEndpoint }() + + ctx := context.Background() + client := NewAniListClient(5 * time.Second) + + _, err := client.SearchPage(ctx, "test", 1, 10) + if err == nil { + t.Fatal("expected GraphQL error, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error should contain 'not found', got: %v", err) + } + if !strings.Contains(err.Error(), "invalid search") { + t.Errorf("error should contain 'invalid search', got: %v", err) + } +} diff --git a/internal/anisearch/client.go b/internal/anisearch/client.go new file mode 100644 index 0000000..f63fffb --- /dev/null +++ b/internal/anisearch/client.go @@ -0,0 +1,312 @@ +package anisearch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "math/rand/v2" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// --- Constants --- + +const ( + anilistUserAgent = "calmstoolkit-anisearch/1.0" + maxPerPage = 50 +) + +var ( + aniListEndpoint = "https://graphql.anilist.co" + maxRetry = 5 + rateLimitDelay = 700 * time.Millisecond + rateLimitBackoff = 5 * time.Second +) + +// searchQuery fetches anime matching a text search, sorted by relevance. +// AniList's search parameter provides fuzzy/partial matching out of the box. +const searchQuery = `query($search: String, $page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + currentPage + total + } + media(search: $search, type: ANIME, sort: SEARCH_MATCH) { + id + idMal + title { romaji english native } + format + episodes + status + season + seasonYear + genres + tags { name } + averageScore + popularity + description(asHtml: false) + studios { nodes { name } } + } + } +}` + +// --- AniList HTTP Client --- + +// AniListClient fetches data from the AniList GraphQL API with built-in +// rate limiting and retries. +type AniListClient struct { + http *http.Client + limiter *rate.Limiter + + rateLimitMu sync.Mutex + lastRateLimit time.Time +} + +// NewAniListClient creates a new AniList client with the given HTTP timeout. +func NewAniListClient(timeout time.Duration) *AniListClient { + if timeout <= 0 { + timeout = 30 * time.Second + } + return &AniListClient{ + http: &http.Client{Timeout: timeout}, + limiter: rate.NewLimiter(rate.Every(rateLimitDelay), 1), + } +} + +// jitter returns d randomly varied by ±25% to prevent synchronized retry storms. +func jitter(d time.Duration) time.Duration { + if d <= 0 { + return d + } + quarter := d / 4 + offset := time.Duration(rand.Int64N(int64(2*quarter+1))) - quarter + return d + offset +} + +// throttle ensures we don't exceed AniList rate limits. After a 429 response, +// the limit is tightened to 5s between requests for 30 seconds. +func (c *AniListClient) throttle(ctx context.Context) error { + c.rateLimitMu.Lock() + limit := rate.Every(rateLimitDelay) + if time.Since(c.lastRateLimit) < 30*time.Second { + limit = rate.Every(rateLimitBackoff) + } + c.limiter.SetLimit(limit) + c.rateLimitMu.Unlock() + + return c.limiter.Wait(ctx) +} + +// Search performs a text search on AniList, paginating through all pages and +// returning all matching results. Use perPage to control the page size (max 50). +func (c *AniListClient) Search(ctx context.Context, q string, perPage int) (*SearchResult, error) { + if perPage <= 0 || perPage > maxPerPage { + perPage = maxPerPage + } + + var allMedia []Show + page := 1 + var firstPageInfo *PageInfo + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + payload := map[string]any{ + "query": searchQuery, + "variables": map[string]any{ + "search": q, + "page": page, + "perPage": perPage, + }, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + var resp graphqlResponse + if err := c.doRequest(ctx, body, &resp); err != nil { + return nil, fmt.Errorf("search %q (page %d): %w", q, page, err) + } + + if len(resp.Errors) > 0 { + msgs := make([]string, len(resp.Errors)) + for i, e := range resp.Errors { + msgs[i] = e.Message + } + return nil, fmt.Errorf("AniList errors: %s", strings.Join(msgs, "; ")) + } + + shows := resp.Data.Page.Media + if shows == nil { + shows = []Show{} + } + + if firstPageInfo == nil { + pi := resp.Data.Page.PageInfo + firstPageInfo = &pi + } + + allMedia = append(allMedia, shows...) + + if !resp.Data.Page.PageInfo.HasNextPage { + break + } + page++ + } + + result := &SearchResult{ + Media: allMedia, + } + if firstPageInfo != nil { + result.PageInfo = *firstPageInfo + } + result.PageInfo.CurrentPage = 1 + result.PageInfo.HasNextPage = page > 1 && page < result.PageInfo.Total + return result, nil +} + +// SearchPage fetches a single page of AniList search results, useful for +// paginated interactive browsing. +func (c *AniListClient) SearchPage(ctx context.Context, q string, page, perPage int) (*SearchResult, error) { + if perPage <= 0 || perPage > maxPerPage { + perPage = maxPerPage + } + if page < 1 { + page = 1 + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + payload := map[string]any{ + "query": searchQuery, + "variables": map[string]any{ + "search": q, + "page": page, + "perPage": perPage, + }, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + var resp graphqlResponse + if err := c.doRequest(ctx, body, &resp); err != nil { + return nil, fmt.Errorf("search %q (page %d): %w", q, page, err) + } + + if len(resp.Errors) > 0 { + msgs := make([]string, len(resp.Errors)) + for i, e := range resp.Errors { + msgs[i] = e.Message + } + return nil, fmt.Errorf("AniList errors: %s", strings.Join(msgs, "; ")) + } + + result := &SearchResult{ + PageInfo: resp.Data.Page.PageInfo, + Media: resp.Data.Page.Media, + } + if result.Media == nil { + result.Media = []Show{} + } + return result, nil +} + +// doRequest sends a POST request with retries and exponential backoff. +func (c *AniListClient) doRequest(ctx context.Context, payload []byte, dst any) error { + var lastErr error + for attempt := range maxRetry { + if attempt > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(jitter(time.Duration(1< 0 { + slog.Warn("rate limited, waiting retry-after", "seconds", sec, "attempt", attempt+1) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(sec) * time.Second): + } + } + } + lastErr = fmt.Errorf("rate limited (attempt %d)", attempt+1) + continue + } + + if resp.StatusCode != http.StatusOK { + respBody, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + lastErr = fmt.Errorf("API error (HTTP %d): failed to read body: %w", resp.StatusCode, readErr) + } else { + lastErr = fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + // Client errors (4xx except 429) won't self-heal; break. + if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 { + break + } + continue + } + + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + resp.Body.Close() + lastErr = fmt.Errorf("decode response: %w", err) + break + } + resp.Body.Close() + return nil + } + + return fmt.Errorf("giving up after %d retries: %w", maxRetry, lastErr) +} diff --git a/internal/anisearch/input.go b/internal/anisearch/input.go new file mode 100644 index 0000000..d6affde --- /dev/null +++ b/internal/anisearch/input.go @@ -0,0 +1,105 @@ +package anisearch + +import ( + "bufio" + "fmt" + "os" + "strings" + + "golang.org/x/term" +) + +// KeyEvent represents a single key press from the terminal. +type KeyEvent int + +const ( + KeyUnknown KeyEvent = iota + KeyUp + KeyDown + KeyEnter + KeyEsc + KeyQuit // q or Q + KeyNext // n or N (next page) + KeyPrev // p or P (previous page) + KeyBack // b or B (back to search results) + KeyCtrlC // Ctrl+C + KeyDigit // 0-9 (use DigitValue()) +) + +// readKey reads a single key press from stdin in raw mode. +// The caller MUST have placed stdin into raw mode via term.MakeRaw before +// calling this function. +func readKey() KeyEvent { + buf := make([]byte, 3) + n, err := os.Stdin.Read(buf) + if err != nil || n == 0 { + return KeyUnknown + } + + switch buf[0] { + case 0x1b: // ESC or arrow key + if n >= 3 && buf[1] == '[' { + switch buf[2] { + case 'A': + return KeyUp + case 'B': + return KeyDown + } + } + return KeyEsc + + case 0x0d, 0x0a: // CR or LF + return KeyEnter + + case 0x03: // Ctrl+C + return KeyCtrlC + + case 'q', 'Q': + return KeyQuit + case 'n', 'N': + return KeyNext + case 'p', 'P': + return KeyPrev + case 'b', 'B': + return KeyBack + + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + return KeyDigit + + default: + return KeyUnknown + } +} + +// runInRawTerminal executes f while stdin is in raw terminal mode. +// It restores the terminal state before returning. +func runInRawTerminal(f func()) { + fd := int(os.Stdin.Fd()) + + if !term.IsTerminal(fd) { + // Not a terminal; just run f directly. + f() + return + } + + oldState, err := term.MakeRaw(fd) + if err != nil { + // Cannot set raw mode; run f directly anyway. + f() + return + } + defer term.Restore(fd, oldState) //nolint:errcheck + + f() +} + +// PromptForQuery reads a search query from stdin. +func PromptForQuery() (string, error) { + fmt.Fprint(os.Stderr, "Search: ") + reader := bufio.NewReader(os.Stdin) + q, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimRight(q, "\r\n"), nil +} diff --git a/internal/anisearch/mapping.go b/internal/anisearch/mapping.go new file mode 100644 index 0000000..98a0416 --- /dev/null +++ b/internal/anisearch/mapping.go @@ -0,0 +1,417 @@ +package anisearch + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/klauspost/compress/zstd" +) + +// --- Anibridge mapping constants --- + +const ( + defaultAnibridgeHTTPTimeout = 60 * time.Second + mappingCacheDuration = 24 * time.Hour + defaultMappingURL = "https://github.com/anibridge/anibridge-mappings/releases/download/v3/mappings.json.zst" +) + +// mappingMetadata is persisted as a sidecar file next to the cached mapping. +type mappingMetadata struct { + ETag string `json:"etag,omitempty"` + LastModified string `json:"last_modified,omitempty"` + FetchedAt time.Time `json:"fetched_at"` + URL string `json:"url"` +} + +// loadMapping loads the anibridge mapping from cache or downloads it from the +// upstream URL. Returns nil if the mapping cannot be loaded (the caller can +// proceed without TVDB IDs). +func loadMapping(ctx context.Context, path, url string, forceRefresh bool) *AnibridgeMapping { + if path == "" { + home, err := os.UserHomeDir() + if err != nil { + slog.Warn("cannot determine home dir for mapping cache", "error", err) + return nil + } + path = home + "/.config/calmstoolkit/anibridge_mappings.json.zst" + } + if url == "" { + url = defaultMappingURL + } + + metaPath := path + ".meta.json" + + // Try to load from cache first (unless force refresh). + if !forceRefresh { + m, err := parseAnibridgeFile(path) + if err == nil { + meta, _ := readMappingMeta(metaPath) + + // If cache is fresh enough, use it directly. + if !meta.FetchedAt.IsZero() && time.Since(meta.FetchedAt) < mappingCacheDuration { + slog.Debug("using cached anibridge mapping", "path", path) + return m + } + + // Cache is stale; try a conditional HEAD. + if meta.ETag != "" { + upstreamMeta, headErr := headMapping(ctx, url) + if headErr == nil && strings.EqualFold( + strings.TrimSpace(upstreamMeta.ETag), + strings.TrimSpace(meta.ETag), + ) { + slog.Debug("anibridge mapping unchanged (ETag match)") + // Update fetched_at so we don't HEAD again too soon. + meta.FetchedAt = time.Now() + _ = writeMappingMeta(metaPath, meta) + return m + } + if headErr != nil { + slog.Debug("anibridge HEAD failed, using stale cache", "error", headErr) + return m + } + // ETag changed; fall through to download. + } + } else if !os.IsNotExist(err) { + slog.Debug("cached anibridge mapping unreadable, will re-download", "error", err) + } + } + + // Download fresh mapping. + data, newMeta, err := fetchMapping(ctx, url) + if err != nil { + // Try fallback to stale cache. + m, fallbackErr := parseAnibridgeFile(path) + if fallbackErr == nil { + slog.Warn("anibridge fetch failed, using cached mapping", "error", err) + return m + } + slog.Warn("anibridge mapping not available", "error", err) + return nil + } + + // Write to disk atomically. + if writeErr := writeFileAtomic(path, data); writeErr != nil { + slog.Warn("failed to cache anibridge mapping", "error", writeErr) + } + + // Write metadata sidecar. + _ = writeMappingMeta(metaPath, newMeta) + + // Parse. + m, err := parseAnibridgeBytes(data) + if err != nil { + slog.Warn("failed to parse anibridge mapping", "error", err) + return nil + } + + slog.Debug("loaded anibridge mapping", + "mal_entries", len(m.byMAL), + "anilist_entries", len(m.byAniList)) + return m +} + +// headMapping performs a HEAD request to check the remote ETag. +func headMapping(ctx context.Context, url string) (mappingMetadata, error) { + client := &http.Client{Timeout: defaultAnibridgeHTTPTimeout} + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return mappingMetadata{}, fmt.Errorf("create HEAD request: %w", err) + } + req.Header.Set("User-Agent", anilistUserAgent) + + resp, err := client.Do(req) + if err != nil { + return mappingMetadata{}, fmt.Errorf("HEAD failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return mappingMetadata{}, fmt.Errorf("HEAD returned HTTP %d", resp.StatusCode) + } + + return mappingMetadata{ + ETag: resp.Header.Get("ETag"), + LastModified: resp.Header.Get("Last-Modified"), + FetchedAt: time.Now(), + URL: url, + }, nil +} + +// fetchMapping downloads the full mapping file. +func fetchMapping(ctx context.Context, url string) ([]byte, mappingMetadata, error) { + client := &http.Client{Timeout: defaultAnibridgeHTTPTimeout} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, mappingMetadata{}, fmt.Errorf("create GET request: %w", err) + } + req.Header.Set("User-Agent", anilistUserAgent) + + resp, err := client.Do(req) + if err != nil { + return nil, mappingMetadata{}, fmt.Errorf("GET failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, mappingMetadata{}, fmt.Errorf("GET returned HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, mappingMetadata{}, fmt.Errorf("read body: %w", err) + } + + meta := mappingMetadata{ + ETag: resp.Header.Get("ETag"), + LastModified: resp.Header.Get("Last-Modified"), + FetchedAt: time.Now(), + URL: url, + } + + return data, meta, nil +} + +// parseAnibridgeFile opens and parses a cached mapping file. +func parseAnibridgeFile(path string) (*AnibridgeMapping, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() //nolint:errcheck + + zr, err := zstd.NewReader(f) + if err != nil { + return nil, fmt.Errorf("create zstd reader: %w", err) + } + defer zr.Close() + + return parseAnibridgeJSON(zr) +} + +// parseAnibridgeBytes parses mapping data from memory. +func parseAnibridgeBytes(data []byte) (*AnibridgeMapping, error) { + zr, err := zstd.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("create zstd reader: %w", err) + } + defer zr.Close() + return parseAnibridgeJSON(zr) +} + +// parseAnibridgeJSON reads the JSON mapping and extracts MAL and AniList → TVDB mappings. +func parseAnibridgeJSON(r io.Reader) (*AnibridgeMapping, error) { + dec := json.NewDecoder(r) + + t, err := dec.Token() + if err != nil { + return nil, fmt.Errorf("expected opening brace: %w", err) + } + if t != json.Delim('{') { + return nil, fmt.Errorf("expected '{', got %T(%v)", t, t) + } + + byMAL := map[int]int{} + byAniList := map[int]int{} + + for dec.More() { + t, err := dec.Token() + if err != nil { + return nil, fmt.Errorf("key token: %w", err) + } + key, ok := t.(string) + if !ok { + return nil, fmt.Errorf("expected string key, got %T", t) + } + + switch { + case strings.HasPrefix(key, "mal:"): + id, convErr := strconv.Atoi(key[4:]) + if convErr != nil || id <= 0 { + skipJSONValue(dec) + continue + } + if tvdbID, ok := extractTVDB(dec); ok { + byMAL[id] = tvdbID + } + + case strings.HasPrefix(key, "anilist:"): + id, convErr := strconv.Atoi(key[8:]) + if convErr != nil || id <= 0 { + skipJSONValue(dec) + continue + } + if tvdbID, ok := extractTVDB(dec); ok { + byAniList[id] = tvdbID + } + + default: + skipJSONValue(dec) + } + } + + if _, err := dec.Token(); err != nil { + return nil, fmt.Errorf("expected closing brace: %w", err) + } + + return &AnibridgeMapping{byMAL: byMAL, byAniList: byAniList}, nil +} + +// extractTVDB parses the `tvdb_show::s` entries from a mapping value, +// preferring s1 season scope and falling back to the scope with the most episodes. +func extractTVDB(dec *json.Decoder) (int, bool) { + var targets map[string]json.RawMessage + if err := dec.Decode(&targets); err != nil { + return 0, false + } + + bestTVDB := 0 + bestEpCount := -1 + bestIsS1 := false + + for descriptor, rawValue := range targets { + if !strings.HasPrefix(descriptor, "tvdb_show:") { + continue + } + + parts := strings.SplitN(descriptor, ":", 3) + if len(parts) < 3 { + continue + } + tvdbID, convErr := strconv.Atoi(parts[1]) + if convErr != nil || tvdbID <= 0 { + continue + } + scope := parts[2] + + epCount := countSourceEpisodes(rawValue) + + isS1 := scope == "s1" + // Season 1 is an absolute preference. Compare episode counts only + // within the same preference class so map iteration order cannot let a + // larger non-s1 mapping displace an s1 mapping. + if (isS1 && !bestIsS1) || (isS1 == bestIsS1 && (epCount > bestEpCount || (epCount == bestEpCount && (bestTVDB == 0 || tvdbID < bestTVDB)))) { + bestTVDB = tvdbID + bestEpCount = epCount + bestIsS1 = isS1 + } + } + + if bestTVDB > 0 { + return bestTVDB, true + } + return 0, false +} + +// countSourceEpisodes counts the total number of episode ranges in a mapping value. +func countSourceEpisodes(raw json.RawMessage) int { + if len(raw) == 0 || string(raw) == "null" { + return 0 + } + + var ranges map[string]string + if err := json.Unmarshal(raw, &ranges); err != nil { + return 0 + } + + var total int + for srcRange := range ranges { + if srcRange == "" { + continue + } + parts := strings.SplitN(srcRange, "-", 2) + if len(parts) == 1 { + if ep, err := strconv.Atoi(parts[0]); err == nil && ep > 0 { + total++ + } + continue + } + if parts[1] == "" { + if start, err := strconv.Atoi(parts[0]); err == nil && start > 0 { + total++ + } + continue + } + start, startErr := strconv.Atoi(parts[0]) + end, endErr := strconv.Atoi(parts[1]) + if startErr == nil && endErr == nil && start > 0 && end >= start { + total += end - start + 1 + } + } + return total +} + +// skipJSONValue reads and discards one JSON value from the decoder stream. +func skipJSONValue(dec *json.Decoder) { + var raw json.RawMessage + _ = dec.Decode(&raw) // best-effort skip +} + +// --- File I/O helpers --- + +func readMappingMeta(path string) (mappingMetadata, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return mappingMetadata{}, nil + } + return mappingMetadata{}, err + } + var m mappingMetadata + if err := json.Unmarshal(data, &m); err != nil { + return mappingMetadata{}, err + } + return m, nil +} + +func writeMappingMeta(path string, m mappingMetadata) error { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return writeFileAtomic(path, data) +} + +func writeFileAtomic(path string, data []byte) error { + dir := path[:strings.LastIndex(path, "/")+1] + if dir != "" && dir != "/" { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + } + + tmp, err := os.CreateTemp(dir, "anisearch-mapping-*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() //nolint:errcheck + return fmt.Errorf("write file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("rename temp file: %w", err) + } + cleanup = false + return nil +} diff --git a/internal/anisearch/model.go b/internal/anisearch/model.go new file mode 100644 index 0000000..f7cc59d --- /dev/null +++ b/internal/anisearch/model.go @@ -0,0 +1,126 @@ +// Package anisearch searches AniList for anime by name and returns +// information about the show plus TVDB mapping via the anibridge dataset. +package anisearch + +import ( + "github.com/calmcacil/CalmsToolkit/internal/core" +) + +// ToolConfig holds configuration for the anisearch tool. +type ToolConfig struct { + core.CommonConfig + MappingURL string + MappingPath string + Limit int + ForceRefresh bool + NoTVDB bool + Page int +} + +// --- AniList GraphQL types --- + +// Title holds the romaji, english, and native titles from AniList. +type Title struct { + Romaji *string `json:"romaji"` + English *string `json:"english"` + Native *string `json:"native"` +} + +// DisplayTitle returns the English title if available, falling back to romaji. +func (t Title) DisplayTitle() string { + if t.English != nil && *t.English != "" { + return *t.English + } + if t.Romaji != nil { + return *t.Romaji + } + return "" +} + +// Tag represents an AniList content tag. +type Tag struct { + Name string `json:"name"` +} + +// StudioNode holds a studio name. +type StudioNode struct { + Name string `json:"name"` +} + +// Studios holds the list of studios for a show. +type Studios struct { + Nodes []StudioNode `json:"nodes"` +} + +// Show represents an anime entry from AniList search results. +type Show struct { + ID int `json:"id"` + IDMal *int `json:"idMal"` + Title Title `json:"title"` + Format string `json:"format"` + Episodes *int `json:"episodes"` + Status string `json:"status"` + Season string `json:"season"` + SeasonYear *int `json:"seasonYear"` + Genres []string `json:"genres"` + Tags []Tag `json:"tags"` + AverageRank *int `json:"averageScore"` + Popularity *int `json:"popularity"` + Description string `json:"description"` + Studios *Studios `json:"studios"` +} + +// StudioNames returns a slice of studio names for display. +func (s Show) StudioNames() []string { + if s.Studios == nil { + return nil + } + names := make([]string, len(s.Studios.Nodes)) + for i, n := range s.Studios.Nodes { + names[i] = n.Name + } + return names +} + +// PageInfo holds pagination metadata from AniList. +type PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + CurrentPage int `json:"currentPage"` + Total int `json:"total"` +} + +// SearchResult wraps a page of search results from AniList. +type SearchResult struct { + PageInfo PageInfo `json:"pageInfo"` + Media []Show `json:"media"` +} + +// graphqlResponse is the top-level response from AniList. +type graphqlResponse struct { + Data struct { + Page SearchResult `json:"Page"` + } `json:"data"` + Errors []graphqlError `json:"errors,omitempty"` +} + +type graphqlError struct { + Message string `json:"message"` +} + +// --- Anibridge mapping types --- + +// AnibridgeMapping holds the TVDB ID lookup tables parsed from an anibridge +// mapping file. Keys are MAL IDs or AniList IDs; values are TVDB show IDs. +type AnibridgeMapping struct { + byMAL map[int]int + byAniList map[int]int +} + +// LookupByAniList looks up the TVDB ID for an AniList show ID. +func (m *AnibridgeMapping) LookupByAniList(anilistID int) (int, bool) { + if m == nil { + return 0, false + } + tvdbID, ok := m.byAniList[anilistID] + return tvdbID, ok +} diff --git a/internal/anisearch/render.go b/internal/anisearch/render.go new file mode 100644 index 0000000..0edc5a2 --- /dev/null +++ b/internal/anisearch/render.go @@ -0,0 +1,495 @@ +package anisearch + +import ( + "bytes" + "fmt" + "os" + "strings" + "time" + + "github.com/calmcacil/CalmsToolkit/internal/colors" + "github.com/mattn/go-runewidth" + "golang.org/x/term" +) + +// terminalWidth returns the current terminal width, defaulting to 80. +func terminalWidth() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w <= 0 { + return 80 + } + return w +} + +// innerWidth returns the usable inner width for boxed content. +func innerWidth() int { + w := terminalWidth() + if w < 40 { + w = 40 + } + if w > 100 { + w = 100 + } + return w - 2 // subtract box borders +} + +// --- Search Results View --- + +// renderSearchResults renders the search results interactive list. +// It returns the string to print to stdout. +func renderSearchResults(query string, result *SearchResult, selected int, mapping *AnibridgeMapping, cfg ToolConfig) string { + p := colors.GetPalette(cfg.Theme) + clr := colors.ClrFunc(cfg.NoColor) + iW := innerWidth() + + var buf bytes.Buffer + bw := bufioWriter(&buf) + + // Top border + title + title := fmt.Sprintf(" Searching: %q ", query) + title = truncateVis(title, iW) + topLine := "┌" + title + strings.Repeat("─", iW-colors.VisibleLen(title)) + "┐\n" + bw.WriteString(topLine) + + // Content area + for i, show := range result.Media { + highlighted := i == selected + line := formatSearchLine(i+1, show, highlighted, mapping, p, clr, iW) + if highlighted { + // Wrap line in inverse video for the highlight + line = "\033[7m" + line + "\033[27m" + } + bw.WriteString("│" + line + "│\n") + } + + // Spacer if few results + for i := len(result.Media); i < 5; i++ { + bw.WriteString("│" + strings.Repeat(" ", iW) + "│\n") + } + + // Separator + bw.WriteString("├" + strings.Repeat("─", iW) + "┤\n") + + // Footer with nav hints + footer := " Page " + itoa(result.PageInfo.CurrentPage) + if result.PageInfo.HasNextPage { + footer += " · N next" + } + footer += " · " + arrowUp + arrowDown + " select · Enter detail · Q quit" + footer = colors.PadRight(footer, iW) + bw.WriteString("│" + footer + "│\n") + + // Bottom border + bw.WriteString("└" + strings.Repeat("─", iW) + "┘\n") + + bw.Flush() + return buf.String() +} + +// formatSearchLine formats a single search result line. +func formatSearchLine(num int, show Show, highlighted bool, mapping *AnibridgeMapping, + p *colors.Palette, clr func(string) string, iW int) string { + + // Prefix: number + title + prefix := fmt.Sprintf("%s%2d.%s %s", clr(p.Subdued), num, clr(p.Reset), show.Title.DisplayTitle()) + + // Format badge + fmtBadge := "" + switch show.Format { + case "TV": + fmtBadge = "[TV]" + case "TV_SHORT": + fmtBadge = "[Short]" + case "MOVIE": + fmtBadge = "[Movie]" + case "OVA": + fmtBadge = "[OVA]" + case "ONA": + fmtBadge = "[ONA]" + case "SPECIAL": + fmtBadge = "[Spcl]" + default: + fmtBadge = "[" + show.Format + "]" + } + + // Meta info + epLabel := "? eps" + if show.Episodes != nil { + epLabel = itoa(*show.Episodes) + " eps" + } + + scoreLabel := "" + if show.AverageRank != nil { + scoreLabel = fmt.Sprintf(" · %s%d%%%s", clr(p.Accent), *show.AverageRank, clr(p.Reset)) + } + + meta := fmt.Sprintf(" %s% s%s%s", clr(p.Subdued), fmtBadge, epLabel, scoreLabel) + + // Status indicator + statusLabel := "" + switch show.Status { + case "FINISHED": + statusLabel = clr(p.Subdued) + " [Finished]" + clr(p.Reset) + case "RELEASING": + statusLabel = clr(p.Success) + " [Airing]" + clr(p.Reset) + case "NOT_YET_RELEASED": + statusLabel = clr(p.Info) + " [Upcoming]" + clr(p.Reset) + case "CANCELLED": + statusLabel = clr(p.Error) + " [Cancelled]" + clr(p.Reset) + } + + visible := colors.VisibleLen(prefix) + colors.VisibleLen(meta) + colors.VisibleLen(statusLabel) + padding := iW - visible - 1 // -1 for space + if padding < 1 { + maxLabel := iW - colors.VisibleLen(meta) - colors.VisibleLen(statusLabel) - 8 + if maxLabel < 4 { + maxLabel = 4 + } + prefix = truncateVis(prefix, maxLabel) + padding = iW - colors.VisibleLen(prefix) - colors.VisibleLen(meta) - colors.VisibleLen(statusLabel) - 1 + if padding < 1 { + padding = 1 + } + } + + return fmt.Sprintf(" %s%s%s%s", + prefix, + strings.Repeat(" ", padding), + meta, + statusLabel) +} + +// --- Detail View --- + +// renderDetail renders the full detail view for a single show. +func renderDetail(show Show, tvdbID int, cfg ToolConfig) string { + p := colors.GetPalette(cfg.Theme) + clr := colors.ClrFunc(cfg.NoColor) + iW := innerWidth() + + var buf bytes.Buffer + bw := bufioWriter(&buf) + + // Top border + title + title := " " + show.Title.DisplayTitle() + " " + topLine := "┌" + title + strings.Repeat("─", iW-colors.VisibleLen(title)) + "┐\n" + bw.WriteString(topLine) + + // Native title (if different from display title) + if show.Title.Native != nil && *show.Title.Native != "" && + (show.Title.Romaji == nil || *show.Title.Romaji != *show.Title.Native) && + (show.Title.English == nil || *show.Title.English != *show.Title.Native) { + nativeLine := " " + clr(p.Subdued) + *show.Title.Native + clr(p.Reset) + nativeLine = colors.PadRight(nativeLine, iW) + bw.WriteString("│" + nativeLine + "│\n") + } + + bw.WriteString("│" + strings.Repeat(" ", iW) + "│\n") + + // Two-column info: (Format/Status, Episodes/Season, Score/Popularity, Studios/TVDB) + infoRows := [][2]string{ + {"Format", show.Format}, + {"Episodes", formatEpisodes(show.Episodes)}, + {"Score", formatScore(show.AverageRank)}, + {"Studios", strings.Join(show.StudioNames(), ", ")}, + {"Status", formatStatus(show.Status)}, + {"Season", formatSeason(show.Season, show.SeasonYear)}, + {"Popularity", formatPopularity(show.Popularity)}, + {"TVDB ID", formatTVDB(tvdbID)}, + } + + // Render info in a 2-column grid (4 rows × 2 cols each) + for row := 0; row < 4; row++ { + left := infoRows[row*2] + right := infoRows[row*2+1] + + leftStr := fmt.Sprintf(" %s%s:%s %s", clr(p.Bold), left[0], clr(p.Reset), left[1]) + rightStr := fmt.Sprintf("%s%s:%s %s", clr(p.Bold), right[0], clr(p.Reset), right[1]) + + // Layout: leftStr | padding | rightStr, totaling iW + leftVis := colors.VisibleLen(leftStr) + colWidth := iW / 2 + if leftVis > colWidth { + leftStr = truncateVis(leftStr, colWidth) + leftVis = colors.VisibleLen(leftStr) + } + padding := iW - leftVis - colors.VisibleLen(rightStr) - 1 // -1 for space + if padding < 1 { + padding = 1 + } + line := leftStr + strings.Repeat(" ", padding) + rightStr + line = colors.PadRight(line, iW) + bw.WriteString("│" + line + "│\n") + } + + // Genres + if len(show.Genres) > 0 { + genreLine := " " + clr(p.Subdued) + strings.Join(show.Genres, ", ") + clr(p.Reset) + genreLine = colors.PadRight(genreLine, iW) + bw.WriteString("│" + genreLine + "│\n") + } + + // Tags (only show first 8) + if len(show.Tags) > 0 { + tagNames := make([]string, 0, 8) + for _, t := range show.Tags { + tagNames = append(tagNames, t.Name) + if len(tagNames) >= 8 { + break + } + } + if len(tagNames) > 0 { + tagLine := " " + clr(p.Subdued) + "Tags: " + strings.Join(tagNames, ", ") + clr(p.Reset) + tagLine = colors.PadRight(tagLine, iW) + bw.WriteString("│" + tagLine + "│\n") + } + } + + // Description (word-wrapped) + desc := show.Description + if desc != "" { + bw.WriteString("│" + strings.Repeat(" ", iW) + "│\n") + + // Strip HTML tags from description + desc = stripHTML(desc) + + // Word wrap + lines := wordWrap(desc, iW-2) + maxDescLines := 8 + if len(lines) > maxDescLines { + lines = lines[:maxDescLines] + lines = append(lines, clr(p.Subdued)+"... (truncated)"+clr(p.Reset)) + } + for _, line := range lines { + line = " " + line + line = colors.PadRight(line, iW) + bw.WriteString("│" + line + "│\n") + } + } + + // Separator + bw.WriteString("├" + strings.Repeat("─", iW) + "┤\n") + + // Footer + footer := " B back · Q quit" + footer = colors.PadRight(footer, iW) + bw.WriteString("│" + footer + "│\n") + + // Bottom border + bw.WriteString("└" + strings.Repeat("─", iW) + "┘\n") + + bw.Flush() + return buf.String() +} + +// --- Formatting helpers --- + +func formatEpisodes(ep *int) string { + if ep == nil { + return "?" + } + return itoa(*ep) +} + +func formatScore(score *int) string { + if score == nil { + return "—" + } + return itoa(*score) + "%" +} + +func formatStatus(s string) string { + switch s { + case "FINISHED": + return "Finished" + case "RELEASING": + return "Airing" + case "NOT_YET_RELEASED": + return "Not Yet Released" + case "CANCELLED": + return "Cancelled" + case "HIATUS": + return "Hiatus" + default: + return s + } +} + +func formatSeason(season string, year *int) string { + s := "" + switch season { + case "WINTER": + s = "Winter" + case "SPRING": + s = "Spring" + case "SUMMER": + s = "Summer" + case "FALL": + s = "Fall" + default: + s = season + } + if year != nil { + s += " " + itoa(*year) + } + return s +} + +func formatPopularity(p *int) string { + if p == nil { + return "—" + } + return "#" + itoa(*p) +} + +func formatTVDB(tvdbID int) string { + if tvdbID <= 0 { + return "—" + } + return itoa(tvdbID) +} + +// --- Utilities --- + +func itoa(n int) string { + return fmt.Sprintf("%d", n) +} + +// arrowUp and arrowDown are Unicode arrows for the footer hints. +const ( + arrowUp = "\u2191" + arrowDown = "\u2193" +) + +// bufioWriter creates a convenience writer that matches the bufio.Writer +// interface but wraps a bytes.Buffer. +func bufioWriter(buf *bytes.Buffer) *simpleWriter { + return &simpleWriter{buf: buf} +} + +type simpleWriter struct { + buf *bytes.Buffer +} + +func (w *simpleWriter) WriteString(s string) (int, error) { + return w.buf.WriteString(s) +} + +func (w *simpleWriter) Flush() error { + return nil +} + +// truncateVis truncates a string to a visible width, appending "..." if truncated. +func truncateVis(s string, maxLen int) string { + visLen := colors.VisibleLen(s) + if visLen <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + // Remove runes from end until we fit with ellipsis + runes := []rune(s) + for colors.VisibleLen(string(runes)+"...") > maxLen && len(runes) > 0 { + runes = runes[:len(runes)-1] + } + return string(runes) + "..." +} + +// stripHTML removes HTML tags from a string. +func stripHTML(s string) string { + var result strings.Builder + inTag := false + for _, r := range s { + if r == '<' { + inTag = true + continue + } + if r == '>' { + inTag = false + continue + } + if !inTag { + result.WriteRune(r) + } + } + // Decode common HTML entities + resultStr := result.String() + resultStr = strings.ReplaceAll(resultStr, "&", "&") + resultStr = strings.ReplaceAll(resultStr, "<", "<") + resultStr = strings.ReplaceAll(resultStr, ">", ">") + resultStr = strings.ReplaceAll(resultStr, """, "\"") + resultStr = strings.ReplaceAll(resultStr, "'", "'") + resultStr = strings.ReplaceAll(resultStr, "'", "'") + resultStr = strings.ReplaceAll(resultStr, " ", " ") + return resultStr +} + +// wordWrap wraps text to a maximum width, respecting word boundaries. +func wordWrap(text string, width int) []string { + if width <= 0 { + return []string{text} + } + + words := strings.Fields(text) + if len(words) == 0 { + return nil + } + + var lines []string + var current strings.Builder + currentWriteable := 0 + + for _, word := range words { + wordLen := runewidth.StringWidth(word) + if currentWriteable == 0 { + current.WriteString(word) + currentWriteable = wordLen + } else if currentWriteable+1+wordLen <= width { + current.WriteString(" ") + current.WriteString(word) + currentWriteable += 1 + wordLen + } else { + lines = append(lines, current.String()) + current.Reset() + current.WriteString(word) + currentWriteable = wordLen + } + } + + if currentWriteable > 0 { + lines = append(lines, current.String()) + } + + return lines +} + +// formatRelativeTime returns a human-friendly relative time string. +func formatRelativeTime(t time.Time) string { + diff := time.Since(t) + if diff < 0 { + diff = -diff + } + if diff < time.Minute { + return "just now" + } + if diff < time.Hour { + m := int(diff.Minutes()) + if m == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", m) + } + if diff < 24*time.Hour { + h := int(diff.Hours()) + if h == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", h) + } + d := int(diff.Hours() / 24) + if d == 1 { + return "yesterday" + } + return fmt.Sprintf("%d days ago", d) +} diff --git a/internal/anisearch/search.go b/internal/anisearch/search.go new file mode 100644 index 0000000..d31e114 --- /dev/null +++ b/internal/anisearch/search.go @@ -0,0 +1,209 @@ +package anisearch + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/calmcacil/CalmsToolkit/internal/config" + "github.com/calmcacil/CalmsToolkit/internal/console" + "github.com/calmcacil/CalmsToolkit/internal/core" +) + +// BuildToolConfig constructs ToolConfig from the global toolkit config. +func BuildToolConfig(tk *config.ToolkitConfig) ToolConfig { + cfg := ToolConfig{ + CommonConfig: core.FromToolkit(tk), + Limit: 5, + MappingURL: "https://github.com/anibridge/anibridge-mappings/releases/download/v3/mappings.json.zst", + Page: 1, + } + if tk == nil { + return cfg + } + if tk.AniSearch.Limit > 0 { + cfg.Limit = tk.AniSearch.Limit + } + if tk.AniSearch.MappingURL != "" { + cfg.MappingURL = tk.AniSearch.MappingURL + } + if tk.AniSearch.MappingPath != "" { + cfg.MappingPath = tk.AniSearch.MappingPath + } + return cfg +} + +// Run executes the anisearch tool with the given query and configuration. +func Run(ctx context.Context, query string, cfg ToolConfig) error { + // Load the anibridge mapping for TVDB cross-references. + var mapping *AnibridgeMapping + if !cfg.NoTVDB { + mapping = loadMapping(ctx, cfg.MappingPath, cfg.MappingURL, cfg.ForceRefresh) + if mapping == nil && !cfg.JSONOutput { + fmt.Fprintln(os.Stderr, "WARN: no TVDB mapping available (TVDB IDs will not be shown)") + } + } + + // Create the AniList client. + client := NewAniListClient(cfg.Timeout) + + // Perform the initial search. + result, err := client.SearchPage(ctx, query, 1, cfg.Limit) + if err != nil { + return fmt.Errorf("search failed: %w", err) + } + + if len(result.Media) == 0 { + if cfg.JSONOutput { + return console.WriteEnvelope(os.Stdout, "anime", map[string]interface{}{ + "query": query, + "results": []Show{}, + "error": "no results found", + }, false, nil, time.Now()) + } + return fmt.Errorf("no results found for %q", query) + } + + // JSON mode: output and exit. + if cfg.JSONOutput { + type showOut struct { + AniListID int `json:"anilist_id"` + MALID *int `json:"mal_id"` + Title Title `json:"title"` + Format string `json:"format"` + Episodes *int `json:"episodes"` + Status string `json:"status"` + Season string `json:"season"` + SeasonYear *int `json:"season_year"` + Genres []string `json:"genres"` + Tags []Tag `json:"tags"` + AverageRank *int `json:"average_score"` + Popularity *int `json:"popularity"` + Description string `json:"description"` + Studios []string `json:"studios"` + TVDBID int `json:"tvdb_id"` + } + + results := make([]showOut, len(result.Media)) + for i, show := range result.Media { + tvdbID := 0 + if mapping != nil { + tvdbID, _ = mapping.LookupByAniList(show.ID) + } + results[i] = showOut{ + AniListID: show.ID, + MALID: show.IDMal, + Title: show.Title, + Format: show.Format, + Episodes: show.Episodes, + Status: show.Status, + Season: show.Season, + SeasonYear: show.SeasonYear, + Genres: show.Genres, + Tags: show.Tags, + AverageRank: show.AverageRank, + Popularity: show.Popularity, + Description: show.Description, + Studios: show.StudioNames(), + TVDBID: tvdbID, + } + } + + return console.WriteEnvelope(os.Stdout, "anime", map[string]interface{}{ + "query": query, + "results": results, + "page": result.PageInfo.CurrentPage, + "has_next": result.PageInfo.HasNextPage, + "total": result.PageInfo.Total, + }, false, nil, time.Now()) + } + + // Interactive mode. + runInteractive(ctx, client, query, result, cfg, mapping) + return nil +} + +// runInteractive manages the interactive TUI loop. +func runInteractive(ctx context.Context, client *AniListClient, query string, currentResult *SearchResult, cfg ToolConfig, mapping *AnibridgeMapping) { + // Clear screen once at the start. + fmt.Fprint(os.Stdout, "\033[H\033[J") + + selected := 0 + currentPage := 1 + + runInRawTerminal(func() { + for { + // Render search results. + output := renderSearchResults(query, currentResult, selected, mapping, cfg) + fmt.Fprint(os.Stdout, "\033[H\033[J"+output) + + // Read key. + key := readKey() + + switch key { + case KeyUp: + if selected > 0 { + selected-- + } + case KeyDown: + if selected < len(currentResult.Media)-1 { + selected++ + } + case KeyEnter: + if selected >= 0 && selected < len(currentResult.Media) { + runDetailView(currentResult.Media[selected], mapping, cfg) + } + case KeyNext: + if currentResult.PageInfo.HasNextPage { + next, err := client.SearchPage(ctx, query, currentPage+1, cfg.Limit) + if err == nil && len(next.Media) > 0 { + currentResult = next + currentPage++ + selected = 0 + } + } + case KeyPrev: + if currentPage > 1 { + prev, err := client.SearchPage(ctx, query, currentPage-1, cfg.Limit) + if err == nil && len(prev.Media) > 0 { + currentResult = prev + currentPage-- + selected = 0 + } + } + case KeyQuit, KeyCtrlC: + return + case KeyEsc: + return + } + } + }) + + // Clean up: clear screen and move cursor to bottom. + fmt.Fprint(os.Stdout, "\033[H\033[J") + fmt.Fprint(os.Stdout, "\n") +} + +// runDetailView shows the detail for a single show until the user presses B or Q. +func runDetailView(show Show, mapping *AnibridgeMapping, cfg ToolConfig) { + tvdbID := 0 + if mapping != nil { + tvdbID, _ = mapping.LookupByAniList(show.ID) + } + + output := renderDetail(show, tvdbID, cfg) + fmt.Fprint(os.Stdout, "\033[H\033[J"+output) + + for { + key := readKey() + + switch key { + case KeyBack, KeyEsc: + return + case KeyQuit, KeyCtrlC: + fmt.Fprint(os.Stdout, "\033[H\033[J") + return + } + } +} diff --git a/internal/app/runtime.go b/internal/app/runtime.go new file mode 100644 index 0000000..933e9b1 --- /dev/null +++ b/internal/app/runtime.go @@ -0,0 +1,82 @@ +// Package app provides the shared command runtime and exit semantics. +package app + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "time" + + "github.com/calmcacil/CalmsToolkit/internal/config" + "github.com/calmcacil/CalmsToolkit/internal/console" +) + +const ( + ExitOK = 0 + ExitOperational = 1 + ExitUsage = 2 + ExitPartial = 3 + ExitInterrupted = 130 +) + +// Clock permits deterministic timestamps and polling tests. +type Clock interface{ Now() time.Time } +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } + +// Runtime is the only command-level dependency container. +type Runtime struct { + Context context.Context + Stdin io.Reader + Stdout, Stderr io.Writer + Logger *slog.Logger + Clock Clock + HTTPClient func(time.Duration) *http.Client + Capabilities console.Capabilities + Output console.OutputMode + Config *config.ToolkitConfig + ConfigPath string + Theme string + NoColor, Debug, Quiet, Strict bool + Timeout time.Duration +} + +// NewRuntime constructs a runtime with production defaults and injectable I/O. +func NewRuntime(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) *Runtime { + return &Runtime{Context: ctx, Stdin: stdin, Stdout: stdout, Stderr: stderr, Clock: realClock{}, HTTPClient: func(timeout time.Duration) *http.Client { return &http.Client{Timeout: timeout} }, Output: console.OutputAuto, Timeout: 10 * time.Second} +} + +// ExitError carries an intentional process status without terminating a package. +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { + if e.Err == nil { + return "command failed" + } + return e.Err.Error() +} +func (e *ExitError) Unwrap() error { return e.Err } +func Error(code int, err error) error { return &ExitError{Code: code, Err: err} } +func ExitCode(err error) int { + if err == nil { + return ExitOK + } + if errors.Is(err, context.Canceled) { + return ExitInterrupted + } + var exit *ExitError + if errors.As(err, &exit) { + return exit.Code + } + var partial interface{ PartialResult() bool } + if errors.As(err, &partial) && partial.PartialResult() { + return ExitPartial + } + return ExitOperational +} diff --git a/internal/app/runtime_test.go b/internal/app/runtime_test.go new file mode 100644 index 0000000..35e65a5 --- /dev/null +++ b/internal/app/runtime_test.go @@ -0,0 +1,29 @@ +package app + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/calmcacil/CalmsToolkit/internal/core" +) + +func TestExitCode(t *testing.T) { + tests := []struct { + err error + want int + }{{nil, 0}, {errors.New("failure"), 1}, {Error(ExitUsage, errors.New("bad flags")), 2}, {&core.PartialError{Warnings: []string{"source"}}, 3}, {context.Canceled, 130}} + for _, tt := range tests { + if got := ExitCode(tt.err); got != tt.want { + t.Errorf("ExitCode(%v)=%d want %d", tt.err, got, tt.want) + } + } +} + +func TestNewRuntimeDefaults(t *testing.T) { + rt := NewRuntime(context.Background(), nil, io.Discard, io.Discard) + if rt.Context == nil || rt.Clock == nil || rt.HTTPClient == nil || rt.Timeout <= 0 { + t.Fatalf("incomplete runtime: %+v", rt) + } +} diff --git a/internal/arr/client.go b/internal/arr/client.go new file mode 100644 index 0000000..d311a53 --- /dev/null +++ b/internal/arr/client.go @@ -0,0 +1,51 @@ +// Package arr contains shared Sonarr/Radarr client foundations. +package arr + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/calmcacil/CalmsToolkit/internal/httputil" +) + +type Service string + +const ( + Sonarr Service = "sonarr" + Radarr Service = "radarr" +) + +type Instance struct { + Service Service + Name, URL, APIKey string +} +type Client struct{ HTTP *httputil.Client } + +// Get decodes one authenticated v3 API resource. +func (c Client) Get(ctx context.Context, instance Instance, resource string, query url.Values, result any) error { + if c.HTTP == nil { + return fmt.Errorf("ARR HTTP client is nil") + } + base := strings.TrimSuffix(instance.URL, "/") + endpoint := base + "/api/v3/" + strings.TrimPrefix(resource, "/") + if len(query) > 0 { + endpoint += "?" + query.Encode() + } + if err := c.HTTP.DoJSON(ctx, "GET", endpoint, map[string]string{"X-Api-Key": instance.APIKey}, nil, result); err != nil { + return fmt.Errorf("%s %s: %w", instance.Service, instance.Name, err) + } + return nil +} + +type SystemStatus struct { + AppName string `json:"appName"` + Version string `json:"version"` +} + +func (c Client) Status(ctx context.Context, instance Instance) (SystemStatus, error) { + var status SystemStatus + err := c.Get(ctx, instance, "system/status", nil, &status) + return status, err +} diff --git a/internal/arr/client_test.go b/internal/arr/client_test.go new file mode 100644 index 0000000..51928ae --- /dev/null +++ b/internal/arr/client_test.go @@ -0,0 +1,24 @@ +package arr + +import ( + "context" + "github.com/calmcacil/CalmsToolkit/internal/httputil" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/system/status" || r.Header.Get("X-Api-Key") != "secret" { + t.Errorf("request=%s key=%q", r.URL.Path, r.Header.Get("X-Api-Key")) + } + w.Write([]byte(`{"appName":"Sonarr","version":"4"}`)) + })) + defer server.Close() + status, err := (Client{HTTP: httputil.NewClient(time.Second)}).Status(context.Background(), Instance{Service: Sonarr, Name: "HD", URL: server.URL, APIKey: "secret"}) + if err != nil || status.AppName != "Sonarr" { + t.Fatalf("status=%+v err=%v", status, err) + } +} diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index d8f8d4f..50c1a76 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -2,6 +2,7 @@ package calendar import ( "context" + "crypto/sha256" "encoding/json" "fmt" "os" @@ -15,6 +16,7 @@ import ( "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" + "github.com/calmcacil/CalmsToolkit/internal/console" "github.com/calmcacil/CalmsToolkit/internal/core" "github.com/calmcacil/CalmsToolkit/internal/httputil" ) @@ -197,36 +199,67 @@ func calculateDateRange(days, daysPast int) (start, end time.Time) { } // Run executes the media calendar tool. -func Run(cfg ToolConfig) { +func Run(ctx context.Context, cfg ToolConfig) error { if len(cfg.SonarrInstances) == 0 && len(cfg.RadarrInstances) == 0 { - fmt.Fprintf(os.Stderr, "ERROR: No Sonarr or Radarr instances configured\n") - fmt.Fprintf(os.Stderr, "Run 'make setup' or edit ~/.config/calmstoolkit/config.json\n") - os.Exit(1) + return fmt.Errorf("no Sonarr or Radarr instances configured") } if cfg.JSONOutput { - ctx := context.Background() - items, issues, err := aggregateCalendar(ctx, cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) - os.Exit(1) - } - if err := displayJSON(items, issues, cfg); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) - os.Exit(1) - } - return + return runJSON(ctx, cfg) } p := colors.GetPalette(cfg.Theme) - ctx := context.Background() if err := runWithSubagents(ctx, cfg, p); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) - os.Exit(1) + return err + } + return nil +} + +func runJSON(ctx context.Context, cfg ToolConfig) error { + var lastHash [sha256.Size]byte + for { + items, issues, warnings, err := aggregateCalendarDetailed(ctx, cfg) + if err != nil { + return err + } + snapshot, _ := json.Marshal(struct { + Items []CalendarItem + Issues []QueueIssue + Warnings []string + }{items, issues, warnings}) + hash := sha256.Sum256(snapshot) + if hash != lastHash { + if err := displayJSON(items, issues, cfg, len(warnings) > 0, warnings); err != nil { + return err + } + lastHash = hash + } + if len(warnings) > 0 && cfg.Strict { + return &core.PartialError{Warnings: warnings} + } + if !cfg.WatchMode { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(cfg.WatchSeconds) * time.Second): + } } } func aggregateCalendar(ctx context.Context, cfg ToolConfig) ([]CalendarItem, []QueueIssue, error) { + items, issues, warnings, err := aggregateCalendarDetailed(ctx, cfg) + for _, warning := range warnings { + fmt.Fprintf(os.Stderr, "WARNING: %s\n", warning) + } + if err == nil && len(warnings) > 0 && cfg.Strict { + err = &core.PartialError{Warnings: warnings} + } + return items, issues, err +} + +func aggregateCalendarDetailed(ctx context.Context, cfg ToolConfig) ([]CalendarItem, []QueueIssue, []string, error) { start, end := calculateDateRange(cfg.Days, cfg.DaysPast) client := httputil.NewTransportClient(cfg.Timeout) @@ -270,19 +303,20 @@ func aggregateCalendar(ctx context.Context, cfg ToolConfig) ([]CalendarItem, []Q }) } + warnings := []string{} if err := g.Wait(); err != nil { - fmt.Fprintf(os.Stderr, "WARNING: %v\n", err) + warnings = append(warnings, err.Error()) } if successes == 0 && totalSources > 0 { - return items, queueIssues, fmt.Errorf("all %d source(s) failed to return data", totalSources) + return items, queueIssues, warnings, fmt.Errorf("all %d source(s) failed to return data", totalSources) } sort.Slice(items, func(i, j int) bool { return items[i].AirTime.Before(items[j].AirTime) }) - return items, queueIssues, nil + return items, queueIssues, warnings, nil } func fetchSonarrInstance(ctx context.Context, client *httputil.Client, inst config.ArrInstance, start, end time.Time, debug bool, mu, qMu *sync.Mutex, items *[]CalendarItem, queueIssues *[]QueueIssue, seen map[string]bool) error { @@ -465,7 +499,7 @@ func fetchQueue(ctx context.Context, client *httputil.Client, inst config.ArrIns return &queue, nil } -func displayJSON(items []CalendarItem, queueIssues []QueueIssue, cfg ToolConfig) error { +func displayJSON(items []CalendarItem, queueIssues []QueueIssue, cfg ToolConfig, partial bool, warnings []string) error { start, end := calculateDateRange(cfg.Days, cfg.DaysPast) now := time.Now() @@ -502,9 +536,7 @@ func displayJSON(items []CalendarItem, queueIssues []QueueIssue, cfg ToolConfig) Items: items, } - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - return encoder.Encode(summary) + return console.WriteEnvelope(os.Stdout, "calendar", summary, partial, warnings, now) } func applyFilters(items []CalendarItem, cfg ToolConfig) []CalendarItem { diff --git a/internal/calendar/render.go b/internal/calendar/render.go index f1f1f35..d266955 100644 --- a/internal/calendar/render.go +++ b/internal/calendar/render.go @@ -123,6 +123,9 @@ func runWithSubagents(ctx context.Context, cfg ToolConfig, p *colors.Palette) er termWidth = re.width case de := <-dataCh: if de.err != nil { + if cfg.Strict { + return de.err + } if !cfg.WatchMode { return de.err } @@ -134,7 +137,9 @@ func runWithSubagents(ctx context.Context, cfg ToolConfig, p *colors.Palette) er } } - fmt.Print(colors.ClearScreen + colors.HomeCursor) + if !cfg.PlainOutput { + fmt.Print(colors.ClearScreen + colors.HomeCursor) + } renderCalendar(cfg, items, issues, termWidth, p) if !cfg.WatchMode { @@ -187,7 +192,9 @@ func renderCalendar(cfg ToolConfig, items []CalendarItem, queueIssues []QueueIss var buf bytes.Buffer bw := bufio.NewWriter(&buf) - fmt.Fprint(bw, colors.ClearScreen+colors.HomeCursor) + if !cfg.PlainOutput { + fmt.Fprint(bw, colors.ClearScreen+colors.HomeCursor) + } if !cfg.Quiet && len(queueIssues) > 0 { totalIssues := 0 @@ -206,7 +213,9 @@ func renderCalendar(cfg ToolConfig, items []CalendarItem, queueIssues []QueueIss if len(items) == 0 { fmt.Fprintf(bw, "%sNo items match the current filters.%s\n", clr(p.NoReleases), clr(p.Reset)) - fmt.Fprint(bw, colors.EraseDown) + if !cfg.PlainOutput { + fmt.Fprint(bw, colors.EraseDown) + } bw.Flush() os.Stdout.Write(buf.Bytes()) return @@ -277,7 +286,9 @@ func renderCalendar(cfg ToolConfig, items []CalendarItem, queueIssues []QueueIss renderSummary(bw, items, now, clr, p) - fmt.Fprint(bw, colors.EraseDown) + if !cfg.PlainOutput { + fmt.Fprint(bw, colors.EraseDown) + } bw.Flush() os.Stdout.Write(buf.Bytes()) } diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..4e1826c --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,628 @@ +// Package cli defines the single CalmsToolkit command tree. +package cli + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/calmcacil/CalmsToolkit/internal/airtime" + "github.com/calmcacil/CalmsToolkit/internal/anisearch" + "github.com/calmcacil/CalmsToolkit/internal/app" + "github.com/calmcacil/CalmsToolkit/internal/buildinfo" + "github.com/calmcacil/CalmsToolkit/internal/calendar" + "github.com/calmcacil/CalmsToolkit/internal/colors" + "github.com/calmcacil/CalmsToolkit/internal/config" + "github.com/calmcacil/CalmsToolkit/internal/console" + "github.com/calmcacil/CalmsToolkit/internal/feed" + "github.com/calmcacil/CalmsToolkit/internal/requests" + "github.com/calmcacil/CalmsToolkit/internal/streams" + "github.com/spf13/cobra" +) + +type globalOptions struct { + configPath, output, theme string + noColor, debug, quiet, strict bool + timeout time.Duration +} + +// NewRootCommand returns the complete command tree with no process termination. +func NewRootCommand(rt *app.Runtime) *cobra.Command { + var global globalOptions + root := &cobra.Command{ + Use: "calmstoolkit", + Short: "A unified SSH-friendly media toolkit", + SilenceErrors: true, + SilenceUsage: true, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { return configureRuntime(cmd, rt, global) }, + } + root.SetIn(rt.Stdin) + root.SetOut(rt.Stdout) + root.SetErr(rt.Stderr) + f := root.PersistentFlags() + f.StringVar(&global.configPath, "config", "", "configuration file (or CALMSTOOLKIT_CONFIG)") + f.StringVar(&global.output, "output", "auto", "output mode: auto, terminal, plain, json, ndjson") + f.StringVar(&global.theme, "theme", "", "color theme (default, catppuccin-mocha, catppuccin-latte)") + f.BoolVar(&global.noColor, "no-color", false, "disable colored output") + f.DurationVar(&global.timeout, "timeout", 0, "HTTP timeout") + f.BoolVar(&global.debug, "debug", false, "enable redacted diagnostics") + f.BoolVar(&global.quiet, "quiet", false, "suppress informational diagnostics") + f.BoolVar(&global.strict, "strict", false, "fail with status 3 on partial results") + root.AddCommand(newStreamsCommand(rt), newCalendarCommand(rt), newRequestsCommand(rt), newAirtimeCommand(rt), newFeedCommand(rt), newAnimeCommand(rt), newConfigCommand(rt), newDoctorCommand(rt), newVersionCommand(rt)) + return root +} + +func configureRuntime(cmd *cobra.Command, rt *app.Runtime, opt globalOptions) error { + path := config.ResolvePath(opt.configPath) + cfg, err := config.LoadToolkitConfigAt(path) + if err != nil { + if !os.IsNotExist(rootCause(err)) && !strings.Contains(err.Error(), "config not found") { + return app.Error(app.ExitUsage, err) + } + cfg = config.DefaultToolkitConfig() + config.ApplyEnvironment(cfg) + } + rt.Config, rt.ConfigPath = cfg, path + timeout, parseErr := time.ParseDuration(cfg.General.Timeout) + if parseErr != nil || timeout <= 0 { + timeout = 10 * time.Second + } + rt.Timeout, rt.Theme, rt.NoColor = timeout, cfg.General.Theme, cfg.General.NoColor + flags := cmd.Flags() + if flags.Changed("timeout") { + if opt.timeout <= 0 { + return app.Error(app.ExitUsage, errors.New("--timeout must be positive")) + } + rt.Timeout = opt.timeout + } + if flags.Changed("theme") { + rt.Theme = opt.theme + } + if rt.Theme == "" { + rt.Theme = "default" + } + if !colors.ValidateTheme(rt.Theme) { + return app.Error(app.ExitUsage, fmt.Errorf("unknown theme %q", rt.Theme)) + } + if flags.Changed("no-color") { + rt.NoColor = opt.noColor + } + mode := console.OutputMode(opt.output) + switch mode { + case console.OutputAuto, console.OutputTerminal, console.OutputPlain, console.OutputJSON, console.OutputNDJSON: + default: + return app.Error(app.ExitUsage, fmt.Errorf("invalid --output %q", opt.output)) + } + var outputFile *os.File + if file, ok := rt.Stdout.(*os.File); ok { + outputFile = file + } + rt.Capabilities = console.Detect(outputFile, rt.NoColor) + rt.Output = console.ResolveOutput(mode, rt.Capabilities) + if rt.Output == console.OutputJSON || rt.Output == console.OutputNDJSON || rt.Output == console.OutputPlain { + rt.NoColor = true + } + rt.Debug, rt.Quiet, rt.Strict = opt.debug, opt.quiet, opt.strict + level := slog.LevelInfo + if rt.Debug { + level = slog.LevelDebug + } + if rt.Quiet { + level = slog.LevelWarn + } + rt.Logger = slog.New(slog.NewTextHandler(rt.Stderr, &slog.HandlerOptions{Level: level})) + return nil +} + +func rootCause(err error) error { + for errors.Unwrap(err) != nil { + err = errors.Unwrap(err) + } + return err +} +func applyCommon(rt *app.Runtime, cfg *appConfig) { + cfg.Timeout = rt.Timeout + cfg.NoColor = rt.NoColor + cfg.Theme = rt.Theme + cfg.JSONOutput = rt.Output == console.OutputJSON || rt.Output == console.OutputNDJSON + cfg.PlainOutput = rt.Output == console.OutputPlain + cfg.Debug = rt.Debug + cfg.Quiet = rt.Quiet + cfg.Strict = rt.Strict +} + +type appConfig struct { + Timeout time.Duration + NoColor bool + Theme string + JSONOutput, PlainOutput, Debug, Quiet, Strict bool +} + +func newStreamsCommand(rt *app.Runtime) *cobra.Command { + var server, plexURL, plexToken, jellyURL, jellyToken string + var watch bool + var interval int + var history time.Duration + cmd := &cobra.Command{Use: "streams", Short: "Show Plex and Jellyfin sessions", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + cfg := streams.BuildToolConfig(rt.Config) + common := appConfig{} + applyCommon(rt, &common) + cfg.Timeout, cfg.NoColor, cfg.Theme, cfg.JSONOutput, cfg.PlainOutput, cfg.Quiet, cfg.Strict = common.Timeout, common.NoColor, common.Theme, common.JSONOutput, common.PlainOutput, common.Quiet, common.Strict + if cmd.Flags().Changed("server") { + cfg.ServerType = server + } + if cmd.Flags().Changed("plex-url") { + cfg.PlexURL = strings.TrimSuffix(plexURL, "/") + } + if cmd.Flags().Changed("plex-token") { + cfg.PlexToken = plexToken + } + if cmd.Flags().Changed("jellyfin-url") { + cfg.JellyfinURL = strings.TrimSuffix(jellyURL, "/") + } + if cmd.Flags().Changed("jellyfin-token") { + cfg.JellyfinToken = jellyToken + } + if cmd.Flags().Changed("history-duration") { + cfg.HistoryDuration = history + } + cfg.Watch = watch + if cmd.Flags().Changed("interval") { + cfg.WatchSeconds = interval + } + if cfg.WatchSeconds < 1 { + return app.Error(app.ExitUsage, errors.New("--interval must be at least 1")) + } + if watch && rt.Output == console.OutputJSON { + return app.Error(app.ExitUsage, errors.New("watch mode requires --output=ndjson for machine output")) + } + if cfg.ServerType != "plex" && cfg.ServerType != "jellyfin" && cfg.ServerType != "both" { + return app.Error(app.ExitUsage, fmt.Errorf("invalid --server %q", cfg.ServerType)) + } + if (cfg.ServerType == "plex" || cfg.ServerType == "both") && cfg.PlexToken == "" { + return app.Error(app.ExitUsage, errors.New("Plex token is required")) + } + if (cfg.ServerType == "jellyfin" || cfg.ServerType == "both") && cfg.JellyfinToken == "" { + return app.Error(app.ExitUsage, errors.New("Jellyfin token is required")) + } + return streams.Run(rt.Context, cfg) + }} + f := cmd.Flags() + f.StringVar(&server, "server", "", "server: plex, jellyfin, or both") + f.StringVar(&plexURL, "plex-url", "", "Plex URL") + f.StringVar(&plexToken, "plex-token", "", "Plex token") + f.StringVar(&jellyURL, "jellyfin-url", "", "Jellyfin URL") + f.StringVar(&jellyToken, "jellyfin-token", "", "Jellyfin token") + f.BoolVar(&watch, "watch", false, "continuously monitor") + f.IntVar(&interval, "interval", 0, "watch interval in seconds") + f.DurationVar(&history, "history-duration", 0, "session history duration") + return cmd +} + +func newCalendarCommand(rt *app.Runtime) *cobra.Command { + var days, past, interval int + var watch, noBanner, monitored bool + var filter string + cmd := &cobra.Command{Use: "calendar", Short: "Show the Sonarr and Radarr calendar", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + cfg := calendar.BuildToolConfig(rt.Config) + common := appConfig{} + applyCommon(rt, &common) + cfg.Timeout, cfg.NoColor, cfg.Theme, cfg.JSONOutput, cfg.PlainOutput, cfg.Debug, cfg.Quiet, cfg.Strict = common.Timeout, common.NoColor, common.Theme, common.JSONOutput, common.PlainOutput, common.Debug, common.Quiet, common.Strict + if cmd.Flags().Changed("days") { + cfg.Days = days + } + if cmd.Flags().Changed("days-past") { + cfg.DaysPast = past + } + if cmd.Flags().Changed("interval") { + cfg.WatchSeconds = interval + } + cfg.WatchMode = watch + cfg.NoBanner = noBanner + cfg.MonitoredOnly = monitored + cfg.Filter = filter + if cfg.Days < 0 || cfg.DaysPast < 0 || cfg.WatchSeconds < 1 { + return app.Error(app.ExitUsage, errors.New("days must be non-negative and interval must be at least 1")) + } + if watch && rt.Output == console.OutputJSON { + return app.Error(app.ExitUsage, errors.New("watch mode requires --output=ndjson for machine output")) + } + return calendar.Run(rt.Context, cfg) + }} + f := cmd.Flags() + f.IntVar(&days, "days", 0, "future days") + f.IntVar(&past, "days-past", 0, "past days") + f.BoolVar(&watch, "watch", false, "continuously monitor") + f.IntVar(&interval, "interval", 0, "watch interval in seconds") + f.BoolVar(&noBanner, "no-banner", false, "suppress banner") + f.StringVar(&filter, "filter", "", "comma-separated filters") + f.BoolVar(&monitored, "monitored-only", false, "only monitored items") + return cmd +} + +func newRequestsCommand(rt *app.Runtime) *cobra.Command { + var serverURL, token string + var verbose bool + cmd := &cobra.Command{Use: "requests", Short: "Open the interactive Overseerr/Jellyseerr requester", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + if rt.Output == console.OutputJSON || rt.Output == console.OutputNDJSON { + return app.Error(app.ExitUsage, errors.New("requests is interactive and does not support JSON or NDJSON")) + } + cfg := requests.BuildToolConfig(rt.Config) + common := appConfig{} + applyCommon(rt, &common) + cfg.Timeout, cfg.NoColor, cfg.Theme, cfg.PlainOutput, cfg.Quiet, cfg.Strict = common.Timeout, common.NoColor, common.Theme, common.PlainOutput, common.Quiet, common.Strict + if cmd.Flags().Changed("url") { + cfg.ServerURL = strings.TrimSuffix(serverURL, "/") + } + if cmd.Flags().Changed("token") { + cfg.APIKey = token + } + cfg.Verbose = cfg.Verbose || verbose + if cfg.ServerURL == "" || cfg.APIKey == "" { + return app.Error(app.ExitUsage, errors.New("requests URL and API key are required")) + } + return requests.Run(rt.Context, cfg) + }} + f := cmd.Flags() + f.StringVar(&serverURL, "url", "", "Overseerr/Jellyseerr URL") + f.StringVar(&token, "token", "", "API key") + f.BoolVar(&verbose, "verbose", false, "verbose diagnostics") + return cmd +} + +func newAirtimeCommand(rt *app.Runtime) *cobra.Command { + var typ string + var limit, season, past, future int + var exact, noBanner, full bool + cmd := &cobra.Command{Use: "airtime ", Short: "Find previous and upcoming airtimes", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + cfg := airtime.BuildToolConfig(rt.Config) + common := appConfig{} + applyCommon(rt, &common) + cfg.Timeout, cfg.NoColor, cfg.Theme, cfg.JSONOutput, cfg.PlainOutput, cfg.Debug, cfg.Strict = common.Timeout, common.NoColor, common.Theme, common.JSONOutput, common.PlainOutput, common.Debug, common.Strict + if cmd.Flags().Changed("type") { + cfg.SearchType = typ + } + if cmd.Flags().Changed("limit") { + cfg.Limit = limit + } + if cmd.Flags().Changed("season") { + cfg.Season = season + } + if cmd.Flags().Changed("past") { + cfg.PastDays = past + } + if cmd.Flags().Changed("future") { + cfg.FutureDays = future + } + cfg.Exact = exact + cfg.NoBanner = noBanner + cfg.FullSeason = full + if cfg.SearchType != "auto" && cfg.SearchType != "series" && cfg.SearchType != "movie" { + return app.Error(app.ExitUsage, errors.New("--type must be auto, series, or movie")) + } + if cfg.Limit < 1 || cfg.Limit > 50 { + return app.Error(app.ExitUsage, errors.New("--limit must be between 1 and 50")) + } + return airtime.Run(rt.Context, strings.Join(args, " "), cfg) + }} + f := cmd.Flags() + f.StringVar(&typ, "type", "", "auto, series, or movie") + f.IntVar(&limit, "limit", 0, "maximum matches") + f.BoolVar(&exact, "exact", false, "require exact title") + f.IntVar(&season, "season", 0, "season override") + f.IntVar(&past, "past", 0, "past days") + f.IntVar(&future, "future", 0, "future days") + f.BoolVar(&noBanner, "no-banner", false, "suppress banner") + f.BoolVar(&full, "full-season", false, "show full season") + return cmd +} + +func newFeedCommand(rt *app.Runtime) *cobra.Command { + var poll, window time.Duration + var watch, grabbed, imported, failed, deleted, ignored, subtitles bool + var events int + cmd := &cobra.Command{Use: "feed", Short: "Show Sonarr and Radarr activity", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + cfg := feed.BuildToolConfig(rt.Config) + common := appConfig{} + applyCommon(rt, &common) + cfg.Timeout, cfg.NoColor, cfg.Theme, cfg.JSONOutput, cfg.PlainOutput, cfg.Quiet, cfg.Strict = common.Timeout, common.NoColor, common.Theme, common.JSONOutput, common.PlainOutput, common.Quiet, common.Strict + if cmd.Flags().Changed("interval") { + cfg.PollInterval = poll + } + if cmd.Flags().Changed("duration") { + cfg.HistoryWindow = window + } + if cmd.Flags().Changed("events") { + cfg.MaxEvents = events + } + cfg.Watch = watch + values := map[string]bool{"show-grabbed": grabbed, "show-imported": imported, "show-failed": failed, "show-deleted": deleted, "show-ignored": ignored, "show-subtitles": subtitles} + for name, dst := range map[string]*bool{"show-grabbed": &cfg.ShowGrabbed, "show-imported": &cfg.ShowImported, "show-failed": &cfg.ShowFailed, "show-deleted": &cfg.ShowDeleted, "show-ignored": &cfg.ShowIgnored, "show-subtitles": &cfg.ShowSubtitles} { + if cmd.Flags().Changed(name) { + *dst = values[name] + } + } + if cfg.PollInterval <= 0 || cfg.MaxEvents < 0 || cfg.MaxEvents > 100 { + return app.Error(app.ExitUsage, errors.New("interval must be positive and events between 0 and 100")) + } + if watch && rt.Output == console.OutputJSON { + return app.Error(app.ExitUsage, errors.New("watch mode requires --output=ndjson for machine output")) + } + return feed.Run(rt.Context, cfg) + }} + f := cmd.Flags() + f.DurationVar(&poll, "interval", 0, "poll interval") + f.DurationVar(&window, "duration", 0, "history window") + f.IntVar(&events, "events", 0, "maximum events") + f.BoolVar(&watch, "watch", false, "continuously monitor") + f.BoolVar(&grabbed, "show-grabbed", false, "show grabbed") + f.BoolVar(&imported, "show-imported", false, "show imported") + f.BoolVar(&failed, "show-failed", false, "show failed") + f.BoolVar(&deleted, "show-deleted", false, "show deleted") + f.BoolVar(&ignored, "show-ignored", false, "show ignored") + f.BoolVar(&subtitles, "show-subtitles", false, "show subtitles") + return cmd +} + +func newAnimeCommand(rt *app.Runtime) *cobra.Command { + var limit int + var mappingURL, mappingPath string + var force, noTVDB bool + cmd := &cobra.Command{Use: "anime ", Aliases: []string{"anisearch"}, Short: "Search AniList with TVDB mappings", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + cfg := anisearch.BuildToolConfig(rt.Config) + common := appConfig{} + applyCommon(rt, &common) + cfg.Timeout, cfg.NoColor, cfg.Theme, cfg.JSONOutput, cfg.PlainOutput, cfg.Debug, cfg.Quiet, cfg.Strict = common.Timeout, common.NoColor, common.Theme, common.JSONOutput, common.PlainOutput, common.Debug, common.Quiet, common.Strict + if cmd.Flags().Changed("limit") { + cfg.Limit = limit + } + if cmd.Flags().Changed("mapping-url") { + cfg.MappingURL = mappingURL + } + if cmd.Flags().Changed("mapping-path") { + cfg.MappingPath = mappingPath + } + cfg.ForceRefresh = force + cfg.NoTVDB = noTVDB + if cfg.Limit < 1 || cfg.Limit > 50 { + return app.Error(app.ExitUsage, errors.New("--limit must be between 1 and 50")) + } + return anisearch.Run(rt.Context, strings.Join(args, " "), cfg) + }} + f := cmd.Flags() + f.IntVar(&limit, "limit", 0, "results per page") + f.StringVar(&mappingURL, "mapping-url", "", "mapping URL") + f.StringVar(&mappingPath, "mapping-path", "", "mapping cache path") + f.BoolVar(&force, "force-refresh", false, "refresh mapping") + f.BoolVar(&noTVDB, "no-tvdb", false, "skip TVDB mapping") + return cmd +} + +func newConfigCommand(rt *app.Runtime) *cobra.Command { + parent := &cobra.Command{Use: "config", Short: "Manage configuration"} + validate := &cobra.Command{Use: "validate", Args: cobra.NoArgs, Short: "Validate all configuration fields", RunE: func(*cobra.Command, []string) error { + if err := rt.Config.Validate(); err != nil { + return app.Error(app.ExitUsage, err) + } + if rt.Output == console.OutputJSON || rt.Output == console.OutputNDJSON { + return console.WriteEnvelope(rt.Stdout, "config validate", map[string]any{"path": rt.ConfigPath, "valid": true}, false, nil, rt.Clock.Now()) + } + fmt.Fprintf(rt.Stdout, "Configuration is valid: %s\n", rt.ConfigPath) + return nil + }} + var force, defaults bool + setup := &cobra.Command{Use: "setup", Args: cobra.NoArgs, Short: "Create a secure default configuration", RunE: func(*cobra.Command, []string) error { + _, statErr := os.Stat(rt.ConfigPath) + if statErr == nil && defaults && !force { + return app.Error(app.ExitUsage, fmt.Errorf("configuration already exists at %s (use --force to replace)", rt.ConfigPath)) + } + cfg := rt.Config + if force || statErr != nil { + cfg = config.DefaultToolkitConfig() + } + if !defaults { + if err := promptSetup(rt, cfg); err != nil { + return app.Error(app.ExitUsage, err) + } + } + if err := cfg.Validate(); err != nil { + return app.Error(app.ExitUsage, err) + } + if err := cfg.SaveAt(rt.ConfigPath); err != nil { + return err + } + fmt.Fprintf(rt.Stdout, "Created %s with mode 0600. Add service credentials, then run 'calmstoolkit config validate'.\n", rt.ConfigPath) + return nil + }} + setup.Flags().BoolVar(&force, "force", false, "replace existing configuration") + setup.Flags().BoolVar(&defaults, "defaults", false, "write defaults without prompting") + parent.AddCommand(setup, validate) + return parent +} + +func promptSetup(rt *app.Runtime, cfg *config.ToolkitConfig) error { + scanner := bufio.NewScanner(rt.Stdin) + prompt := func(label, current string) (string, error) { + if current == "" { + fmt.Fprintf(rt.Stdout, "%s: ", label) + } else { + display := current + lower := strings.ToLower(label) + if strings.Contains(lower, "token") || strings.Contains(lower, "key") { + display = "configured" + } + fmt.Fprintf(rt.Stdout, "%s [%s]: ", label, display) + } + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("input ended while reading %s", label) + } + value := strings.TrimSpace(scanner.Text()) + if value == "" { + return current, nil + } + return value, nil + } + var err error + if cfg.General.Timeout, err = prompt("HTTP timeout", cfg.General.Timeout); err != nil { + return err + } + if cfg.General.Theme, err = prompt("Theme", cfg.General.Theme); err != nil { + return err + } + if cfg.MediaStreams.PlexURL, err = prompt("Plex URL", cfg.MediaStreams.PlexURL); err != nil { + return err + } + if cfg.MediaStreams.PlexToken, err = prompt("Plex token", cfg.MediaStreams.PlexToken); err != nil { + return err + } + if cfg.MediaStreams.JellyfinURL, err = prompt("Jellyfin URL", cfg.MediaStreams.JellyfinURL); err != nil { + return err + } + if cfg.MediaStreams.JellyfinToken, err = prompt("Jellyfin token", cfg.MediaStreams.JellyfinToken); err != nil { + return err + } + if cfg.MediaRequests.OverseerrURL, err = prompt("Overseerr/Jellyseerr URL", cfg.MediaRequests.OverseerrURL); err != nil { + return err + } + if cfg.MediaRequests.APIKey, err = prompt("Requests API key", cfg.MediaRequests.APIKey); err != nil { + return err + } + if cfg.AniSearch.MappingURL, err = prompt("AniSearch mapping URL", cfg.AniSearch.MappingURL); err != nil { + return err + } + if cfg.AniSearch.MappingPath, err = prompt("AniSearch mapping cache path", cfg.AniSearch.MappingPath); err != nil { + return err + } + limit, err := prompt("AniSearch result limit", strconv.Itoa(cfg.AniSearch.Limit)) + if err != nil { + return err + } + cfg.AniSearch.Limit, err = strconv.Atoi(limit) + if err != nil { + return fmt.Errorf("AniSearch result limit: %w", err) + } + return nil +} + +type doctorCheck struct { + Name string `json:"name"` + OK bool `json:"ok"` + Detail string `json:"detail"` +} + +func newDoctorCommand(rt *app.Runtime) *cobra.Command { + return &cobra.Command{Use: "doctor", Args: cobra.NoArgs, Short: "Check config, permissions, terminal, and services", RunE: func(*cobra.Command, []string) error { + checks := runDoctor(rt) + failed := false + for _, c := range checks { + failed = failed || !c.OK + } + if rt.Output == console.OutputJSON || rt.Output == console.OutputNDJSON { + if err := console.WriteEnvelope(rt.Stdout, "doctor", checks, failed, nil, rt.Clock.Now()); err != nil { + return err + } + } else { + for _, c := range checks { + status := "OK" + if !c.OK { + status = "FAIL" + } + fmt.Fprintf(rt.Stdout, "%-4s %-18s %s\n", status, c.Name, c.Detail) + } + } + if failed { + return app.Error(app.ExitOperational, errors.New("one or more doctor checks failed")) + } + return nil + }} +} +func runDoctor(rt *app.Runtime) []doctorCheck { + checks := []doctorCheck{{Name: "configuration", OK: rt.Config.Validate() == nil, Detail: rt.ConfigPath}, {Name: "terminal", OK: true, Detail: fmt.Sprintf("tty=%t utf8=%t color=%t width=%d", rt.Capabilities.TTY, rt.Capabilities.UTF8, rt.Capabilities.Color, rt.Capabilities.Width)}} + if info, err := os.Stat(rt.ConfigPath); err == nil { + checks = append(checks, doctorCheck{Name: "permissions", OK: info.Mode().Perm() == 0600, Detail: info.Mode().Perm().String()}) + } else { + checks = append(checks, doctorCheck{Name: "permissions", OK: false, Detail: "configuration file unavailable"}) + } + type endpoint struct{ name, url, token, header string } + eps := []endpoint{} + for _, i := range rt.Config.Sonarr { + eps = append(eps, endpoint{"sonarr " + i.Name, i.URL + "/api/v3/system/status", i.APIKey, "X-Api-Key"}) + } + for _, i := range rt.Config.Radarr { + eps = append(eps, endpoint{"radarr " + i.Name, i.URL + "/api/v3/system/status", i.APIKey, "X-Api-Key"}) + } + if rt.Config.MediaStreams.PlexToken != "" { + eps = append(eps, endpoint{"plex", strings.TrimSuffix(rt.Config.MediaStreams.PlexURL, "/") + "/identity", rt.Config.MediaStreams.PlexToken, "X-Plex-Token"}) + } + if rt.Config.MediaStreams.JellyfinToken != "" { + eps = append(eps, endpoint{"jellyfin", strings.TrimSuffix(rt.Config.MediaStreams.JellyfinURL, "/") + "/System/Info", rt.Config.MediaStreams.JellyfinToken, "X-Emby-Token"}) + } + if rt.Config.MediaRequests.APIKey != "" { + eps = append(eps, endpoint{"requests", strings.TrimSuffix(rt.Config.MediaRequests.OverseerrURL, "/") + "/api/v1/status", rt.Config.MediaRequests.APIKey, "X-Api-Key"}) + } + for _, e := range eps { + ctx, cancel := context.WithTimeout(rt.Context, rt.Timeout) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, e.url, nil) + req.Header.Set(e.header, e.token) + resp, err := rt.HTTPClient(rt.Timeout).Do(req) + cancel() + ok := err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 + detail := "reachable" + if err != nil { + detail = err.Error() + } else { + detail = resp.Status + } + if resp != nil { + resp.Body.Close() + } + checks = append(checks, doctorCheck{Name: e.name, OK: ok, Detail: detail}) + } + return checks +} + +func newVersionCommand(rt *app.Runtime) *cobra.Command { + return &cobra.Command{Use: "version", Args: cobra.NoArgs, Short: "Print build version", RunE: func(*cobra.Command, []string) error { + if rt.Output == console.OutputJSON || rt.Output == console.OutputNDJSON { + return console.WriteEnvelope(rt.Stdout, "version", map[string]string{"version": buildinfo.Version, "commit": buildinfo.Commit, "built_at": buildinfo.Date}, false, nil, rt.Clock.Now()) + } + buildinfo.Print(rt.Stdout) + return nil + }} +} + +// Execute runs root and maps its returned error to a process status. +func Execute(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) int { + rt := app.NewRuntime(ctx, stdin, stdout, stderr) + root := NewRootCommand(rt) + root.SetArgs(args) + err := root.Execute() + if err != nil && app.ExitCode(err) == app.ExitOperational && cobraUsageError(err) { + err = app.Error(app.ExitUsage, err) + } + if err != nil { + fmt.Fprintln(stderr, err) + } + return app.ExitCode(err) +} + +func cobraUsageError(err error) bool { + message := err.Error() + for _, marker := range []string{"unknown command", "unknown flag", "requires at least", "accepts ", "required flag", "invalid argument"} { + if strings.Contains(message, marker) { + return true + } + } + return false +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..7514e88 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,123 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/calmcacil/CalmsToolkit/internal/app" + "github.com/calmcacil/CalmsToolkit/internal/config" +) + +func TestRootCommandSurface(t *testing.T) { + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"--help"}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + for _, name := range []string{"streams", "calendar", "requests", "airtime", "feed", "anime", "config", "doctor", "version"} { + if !strings.Contains(out.String(), name) { + t.Errorf("help missing %q", name) + } + } +} + +func TestInvalidOutputIsUsageError(t *testing.T) { + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"--output=xml", "version"}) + if code != app.ExitUsage { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } +} + +func TestMissingArgumentsIsUsageError(t *testing.T) { + var out, stderr bytes.Buffer + if code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"airtime"}); code != app.ExitUsage { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } +} + +func TestRequestsRejectsMachineMode(t *testing.T) { + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"--output=json", "requests"}) + if code != app.ExitUsage { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } +} + +func TestConfigAndFlagPrecedence(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + cfg := config.DefaultToolkitConfig() + cfg.General.NoColor = true + cfg.General.Theme = "catppuccin-mocha" + cfg.General.Timeout = "22s" + cfg.MediaStreams.WatchInterval = 77 + if err := cfg.SaveAt(path); err != nil { + t.Fatal(err) + } + var out, stderr bytes.Buffer + rt := app.NewRuntime(context.Background(), strings.NewReader(""), &out, &stderr) + root := NewRootCommand(rt) + root.SetArgs([]string{"--config", path, "--output=terminal", "--no-color=false", "--timeout=3s", "version"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if rt.NoColor { + t.Fatal("explicit flag did not override configured no_color") + } + if rt.Timeout.String() != "3s" { + t.Fatalf("timeout=%v", rt.Timeout) + } + if rt.Theme != "catppuccin-mocha" { + t.Fatalf("theme=%q", rt.Theme) + } + if rt.Config.MediaStreams.WatchInterval != 77 { + t.Fatal("configured interval was overwritten") + } +} + +func TestVersionJSONEnvelope(t *testing.T) { + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"--config", filepath.Join(t.TempDir(), "missing.json"), "--output=json", "version"}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if strings.Contains(out.String(), "\x1b[") { + t.Fatal("ANSI in JSON") + } + if !strings.Contains(out.String(), `"schema_version":"1"`) { + t.Fatalf("output=%s", out.String()) + } +} + +func TestConfigSetupPermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.json") + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"--config", path, "config", "setup", "--defaults"}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("mode=%o", info.Mode().Perm()) + } +} + +func TestConfigSetupEOFDoesNotSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"--config", path, "config", "setup"}) + if code != app.ExitUsage { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("configuration was saved after EOF: %v", err) + } +} diff --git a/internal/cmdutil/cmdutil.go b/internal/cmdutil/cmdutil.go deleted file mode 100644 index 97559ba..0000000 --- a/internal/cmdutil/cmdutil.go +++ /dev/null @@ -1,136 +0,0 @@ -package cmdutil - -import ( - "flag" - "fmt" - "log/slog" - "os" - "strings" - "time" - - "github.com/calmcacil/CalmsToolkit/internal/buildinfo" - "github.com/calmcacil/CalmsToolkit/internal/colors" - "github.com/calmcacil/CalmsToolkit/internal/config" - "github.com/calmcacil/CalmsToolkit/internal/slogx" -) - -type Options struct { - IncludeWatch bool - IncludeDebug bool - IncludeQuiet bool -} - -type Common struct { - Timeout time.Duration - NoColor bool - Theme string - Debug bool - Quiet bool - Palette *colors.Palette - Logger *slog.Logger - - Watch bool - WatchSeconds int - - version bool - noColor bool - theme string - json bool - watch bool - interval int - debug bool - quiet bool - timeout time.Duration -} - -func RegisterCommonFlags(fs *flag.FlagSet, tk *config.ToolkitConfig, opt Options) *Common { - c := &Common{ - timeout: 10 * time.Second, - interval: 10, - theme: "default", - } - if tk != nil { - if d, err := time.ParseDuration(tk.General.Timeout); err == nil && d > 0 { - c.timeout = d - } - c.theme = tk.General.Theme - if c.theme == "" { - c.theme = "default" - } - } - - fs.BoolVar(&c.version, "version", false, "Print version and exit") - fs.BoolVar(&c.noColor, "no-color", false, "Disable colored output") - fs.StringVar(&c.theme, "theme", c.theme, "Color theme (default, catppuccin-mocha, catppuccin-latte)") - fs.DurationVar(&c.timeout, "timeout", c.timeout, "HTTP connection timeout") - fs.BoolVar(&c.json, "json", false, "Output in JSON format") - - if opt.IncludeWatch { - fs.BoolVar(&c.watch, "watch", false, "Continuously monitor") - fs.IntVar(&c.interval, "interval", c.interval, "Watch mode refresh interval in seconds") - } - if opt.IncludeDebug { - fs.BoolVar(&c.debug, "debug", false, "Enable debug logging") - } - if opt.IncludeQuiet { - fs.BoolVar(&c.quiet, "quiet", false, "Suppress non-error output") - } - - return c -} - -func (c *Common) Apply() { - if c.version { - buildinfo.Print(os.Stdout) - os.Exit(0) - } - - c.NoColor = c.noColor || c.json - c.Theme = c.theme - if c.Theme != "" && !colors.ValidateTheme(c.Theme) { - valid := strings.Join(colors.ValidThemes(), ", ") - fmt.Fprintf(os.Stderr, "Warning: unknown theme %q, falling back to default (valid: %s)\n", - c.Theme, valid) - c.Theme = "default" - } - c.Palette = colors.GetPalette(c.Theme) - - c.Timeout = c.timeout - if c.Timeout <= 0 { - c.Timeout = 10 * time.Second - } - - c.Watch = c.watch - c.WatchSeconds = c.interval - c.Debug = c.debug - c.Quiet = c.quiet - - c.Logger = slogx.New(slogx.Options{ - Level: slog.LevelInfo, - JSON: c.json, - }) - if c.debug { - c.Logger = slogx.New(slogx.Options{ - Level: slog.LevelDebug, - JSON: c.json, - }) - } - slogx.SetDefault(c.Logger) -} - -func (c *Common) JSONFlag() bool { - return c.json -} - -func LoadAndValidate() *config.ToolkitConfig { - tk, err := config.LoadToolkitConfig() - if err != nil { - slogx.Default().Warn("config not loaded, using defaults", "err", err) - } - if tk != nil { - if err := tk.Validate(); err != nil { - slogx.Default().Warn("config validation", "err", err) - } - } - return tk -} diff --git a/internal/cmdutil/cmdutil_test.go b/internal/cmdutil/cmdutil_test.go deleted file mode 100644 index 85991cf..0000000 --- a/internal/cmdutil/cmdutil_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package cmdutil - -import ( - "flag" - "os" - "os/exec" - "testing" -) - -func TestLoadAndValidate_MissingConfigIsWarning(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - tk := LoadAndValidate() - if tk != nil { - t.Fatal("expected nil config when no config file exists") - } -} - -func TestRegisterCommonFlags_DefaultTimeout(t *testing.T) { - fs := flag.NewFlagSet("test", flag.ContinueOnError) - c := RegisterCommonFlags(fs, nil, Options{}) - if err := fs.Parse([]string{}); err != nil { - t.Fatal(err) - } - c.Apply() - if c.Timeout != 10e9 { - t.Errorf("default timeout = %v, want 10s", c.Timeout) - } -} - -func TestVersionFlagExits(t *testing.T) { - if os.Getenv("GO_TEST_SUBPROCESS") == "1" { - os.Args = []string{"test", "--version"} - flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError) - c := RegisterCommonFlags(flag.CommandLine, nil, Options{}) - flag.Parse() - c.Apply() - return - } - - cmd := exec.Command(os.Args[0], "-test.run=TestVersionFlagExits") - cmd.Env = append(os.Environ(), "GO_TEST_SUBPROCESS=1") - err := cmd.Run() - if err != nil { - t.Fatalf("--version should exit 0, got: %v", err) - } -} - -func TestNoColorImpliedByJSON(t *testing.T) { - fs := flag.NewFlagSet("test", flag.ContinueOnError) - c := RegisterCommonFlags(fs, nil, Options{}) - if err := fs.Parse([]string{"-json"}); err != nil { - t.Fatal(err) - } - c.Apply() - if !c.NoColor { - t.Error("NoColor should be true when JSON is enabled") - } -} - -func TestInvalidThemeFallsBack(t *testing.T) { - fs := flag.NewFlagSet("test", flag.ContinueOnError) - c := RegisterCommonFlags(fs, nil, Options{}) - if err := fs.Parse([]string{"-theme", "nonexistent"}); err != nil { - t.Fatal(err) - } - c.Apply() - if c.Theme != "default" { - t.Errorf("invalid theme should fall back to default, got %q", c.Theme) - } - if c.Palette == nil { - t.Error("Palette should not be nil after Apply") - } -} - -func TestWatchFlags(t *testing.T) { - fs := flag.NewFlagSet("test", flag.ContinueOnError) - c := RegisterCommonFlags(fs, nil, Options{IncludeWatch: true}) - if err := fs.Parse([]string{"-watch", "-interval", "5"}); err != nil { - t.Fatal(err) - } - c.Apply() - if !c.Watch { - t.Error("Watch should be true") - } - if c.WatchSeconds != 5 { - t.Errorf("WatchSeconds = %d, want 5", c.WatchSeconds) - } -} diff --git a/internal/config/config.go b/internal/config/config.go index e3f8317..232b461 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,9 +2,12 @@ package config import ( "encoding/json" + "errors" "fmt" + "net/url" "os" "path/filepath" + "regexp" "strings" "time" ) @@ -19,15 +22,41 @@ func TokenFromEnv(key, fallback string) string { // ToolkitConfig is the top-level configuration structure for CalmsToolkit. type ToolkitConfig struct { - Version int `json:"version"` - General GeneralConfig `json:"general"` - Sonarr []ArrInstance `json:"sonarr_instances"` - Radarr []ArrInstance `json:"radarr_instances"` - MediaCalendar CalendarConfig `json:"media_calendar"` - MediaStreams StreamsConfig `json:"media_streams"` - MediaRequests RequestsConfig `json:"media_requests"` - MediaAirtime AirtimeConfig `json:"media_airtime"` - ArrFeed FeedConfig `json:"arr_feed"` + Version int `json:"version"` + General GeneralConfig `json:"general"` + Sonarr []ArrInstance `json:"sonarr_instances"` + Radarr []ArrInstance `json:"radarr_instances"` + MediaCalendar CalendarConfig `json:"media_calendar"` + MediaStreams StreamsConfig `json:"media_streams"` + MediaRequests RequestsConfig `json:"media_requests"` + MediaAirtime AirtimeConfig `json:"media_airtime"` + ArrFeed FeedConfig `json:"arr_feed"` + AniSearch AniSearchConfig `json:"anisearch"` +} + +// CurrentVersion is unchanged until a configuration shape migration is needed. +const CurrentVersion = 1 + +type migration func(*ToolkitConfig) error + +var migrations = map[int]migration{} + +// Migrate applies registered one-version-at-a-time migrations in memory. +func Migrate(cfg *ToolkitConfig) error { + if cfg.Version > CurrentVersion { + return fmt.Errorf("unsupported config version: %d", cfg.Version) + } + for cfg.Version < CurrentVersion { + migrate, ok := migrations[cfg.Version] + if !ok { + return fmt.Errorf("no migration registered from config version %d", cfg.Version) + } + if err := migrate(cfg); err != nil { + return fmt.Errorf("migrating config version %d: %w", cfg.Version, err) + } + cfg.Version++ + } + return nil } // ArrInstance represents a Sonarr or Radarr server instance. @@ -78,6 +107,13 @@ type AirtimeConfig struct { Debug bool `json:"debug"` } +// AniSearchConfig holds anisearch tool settings. +type AniSearchConfig struct { + MappingURL string `json:"mapping_url"` + MappingPath string `json:"mapping_path"` + Limit int `json:"limit"` +} + // FeedConfig holds Arr event feed tool settings. type FeedConfig struct { PollInterval string `json:"poll_interval"` @@ -93,6 +129,9 @@ type FeedConfig struct { // ConfigPath returns the default config file path (~/.config/calmstoolkit/config.json). func ConfigPath() string { + if path := strings.TrimSpace(os.Getenv("CALMSTOOLKIT_CONFIG")); path != "" { + return path + } home, err := os.UserHomeDir() if err != nil { return "" @@ -100,6 +139,15 @@ func ConfigPath() string { return filepath.Join(home, ".config", "calmstoolkit", "config.json") } +// ResolvePath applies configuration path precedence: explicit flag, +// CALMSTOOLKIT_CONFIG, then the standard user configuration path. +func ResolvePath(explicit string) string { + if path := strings.TrimSpace(explicit); path != "" { + return path + } + return ConfigPath() +} + // DefaultToolkitConfig returns a ToolkitConfig with sensible defaults. func DefaultToolkitConfig() *ToolkitConfig { return &ToolkitConfig{ @@ -120,7 +168,7 @@ func DefaultToolkitConfig() *ToolkitConfig { PlexToken: "", JellyfinURL: "http://localhost:8096", JellyfinToken: "", - ServerType: "both", + ServerType: "", WatchInterval: 10, HistoryDuration: "15m", }, @@ -135,6 +183,11 @@ func DefaultToolkitConfig() *ToolkitConfig { FutureDays: 30, Debug: false, }, + AniSearch: AniSearchConfig{ + MappingURL: "https://github.com/anibridge/anibridge-mappings/releases/download/v3/mappings.json.zst", + MappingPath: "", + Limit: 5, + }, ArrFeed: FeedConfig{ PollInterval: "5s", HistoryWindow: "1h", @@ -151,7 +204,13 @@ func DefaultToolkitConfig() *ToolkitConfig { // LoadToolkitConfig reads and parses the config file from the default path. func LoadToolkitConfig() (*ToolkitConfig, error) { - path := ConfigPath() + return LoadToolkitConfigAt("") +} + +// LoadToolkitConfigAt loads a configuration file. An empty path uses +// CALMSTOOLKIT_CONFIG or the standard user path. +func LoadToolkitConfigAt(explicitPath string) (*ToolkitConfig, error) { + path := ResolvePath(explicitPath) if path == "" { return nil, fmt.Errorf("cannot determine home directory") } @@ -169,8 +228,8 @@ func LoadToolkitConfig() (*ToolkitConfig, error) { return nil, fmt.Errorf("parsing config: %w", err) } - if cfg.Version < 1 { - return nil, fmt.Errorf("unsupported config version: %d", cfg.Version) + if err := Migrate(cfg); err != nil { + return nil, err } for i := range cfg.Sonarr { @@ -180,91 +239,176 @@ func LoadToolkitConfig() (*ToolkitConfig, error) { cfg.Radarr[i].URL = strings.TrimSuffix(cfg.Radarr[i].URL, "/") } + ApplyEnvironment(cfg) return cfg, nil } +var nonEnvName = regexp.MustCompile(`[^A-Z0-9]+`) + +// NormalizeInstanceName converts an instance name into its environment suffix. +func NormalizeInstanceName(name string) string { + return strings.Trim(nonEnvName.ReplaceAllString(strings.ToUpper(strings.TrimSpace(name)), "_"), "_") +} + +// ApplyEnvironment overlays supported environment variables on cfg. New +// CALMSTOOLKIT names take precedence over legacy names. +func ApplyEnvironment(cfg *ToolkitConfig) { + if cfg == nil { + return + } + cfg.MediaStreams.PlexToken = firstEnvironment("CALMSTOOLKIT_PLEX_TOKEN", "PLEX_TOKEN", cfg.MediaStreams.PlexToken) + cfg.MediaStreams.JellyfinToken = firstEnvironment("CALMSTOOLKIT_JELLYFIN_TOKEN", "JELLYFIN_TOKEN", cfg.MediaStreams.JellyfinToken) + cfg.MediaRequests.APIKey = firstEnvironment("CALMSTOOLKIT_REQUESTS_API_KEY", "OVERSEERR_API_KEY", cfg.MediaRequests.APIKey) + for i := range cfg.Sonarr { + cfg.Sonarr[i].APIKey = firstEnvironment("CALMSTOOLKIT_SONARR_"+NormalizeInstanceName(cfg.Sonarr[i].Name)+"_API_KEY", "", cfg.Sonarr[i].APIKey) + } + for i := range cfg.Radarr { + cfg.Radarr[i].APIKey = firstEnvironment("CALMSTOOLKIT_RADARR_"+NormalizeInstanceName(cfg.Radarr[i].Name)+"_API_KEY", "", cfg.Radarr[i].APIKey) + } +} + +func firstEnvironment(primary, legacy, fallback string) string { + if value := os.Getenv(primary); value != "" { + return value + } + if legacy != "" { + if value := os.Getenv(legacy); value != "" { + return value + } + } + return fallback +} + // Validate checks the configuration for required fields and valid values. func (c *ToolkitConfig) Validate() error { + var problems []error + add := func(format string, args ...any) { problems = append(problems, fmt.Errorf(format, args...)) } if c.Version < 1 { - return fmt.Errorf("unsupported version: %d", c.Version) + add("unsupported version: %d", c.Version) } + seenSonarr := make(map[string]bool) for i, inst := range c.Sonarr { if inst.URL == "" { - return fmt.Errorf("sonarr_instances[%d]: url is required", i) + add("sonarr_instances[%d]: url is required", i) + } else if !validHTTPURL(inst.URL) { + add("sonarr_instances[%d]: invalid url %q", i, inst.URL) } if inst.APIKey == "" { - return fmt.Errorf("sonarr_instances[%d]: api_key is required", i) + add("sonarr_instances[%d]: api_key is required", i) } if inst.Name == "" { - return fmt.Errorf("sonarr_instances[%d]: name is required", i) + add("sonarr_instances[%d]: name is required", i) + } else { + key := strings.ToLower(strings.TrimSpace(inst.Name)) + if seenSonarr[key] { + add("sonarr_instances[%d]: duplicate name %q", i, inst.Name) + } + seenSonarr[key] = true } } + seenRadarr := make(map[string]bool) for i, inst := range c.Radarr { if inst.URL == "" { - return fmt.Errorf("radarr_instances[%d]: url is required", i) + add("radarr_instances[%d]: url is required", i) + } else if !validHTTPURL(inst.URL) { + add("radarr_instances[%d]: invalid url %q", i, inst.URL) } if inst.APIKey == "" { - return fmt.Errorf("radarr_instances[%d]: api_key is required", i) + add("radarr_instances[%d]: api_key is required", i) } if inst.Name == "" { - return fmt.Errorf("radarr_instances[%d]: name is required", i) + add("radarr_instances[%d]: name is required", i) + } else { + key := strings.ToLower(strings.TrimSpace(inst.Name)) + if seenRadarr[key] { + add("radarr_instances[%d]: duplicate name %q", i, inst.Name) + } + seenRadarr[key] = true } } - if _, err := time.ParseDuration(c.General.Timeout); err != nil { - return fmt.Errorf("general.timeout: invalid duration %q: %w", c.General.Timeout, err) + if d, err := time.ParseDuration(c.General.Timeout); err != nil || d <= 0 { + add("general.timeout: invalid positive duration %q", c.General.Timeout) } if dt := c.MediaStreams.HistoryDuration; dt != "" { if _, err := time.ParseDuration(dt); err != nil { - return fmt.Errorf("media_streams.history_duration: invalid duration %q: %w", dt, err) + add("media_streams.history_duration: invalid duration %q", dt) } } if dt := c.ArrFeed.PollInterval; dt != "" { if _, err := time.ParseDuration(dt); err != nil { - return fmt.Errorf("arr_feed.poll_interval: invalid duration %q: %w", dt, err) + add("arr_feed.poll_interval: invalid duration %q", dt) } } if dt := c.ArrFeed.HistoryWindow; dt != "" { if _, err := time.ParseDuration(dt); err != nil { - return fmt.Errorf("arr_feed.history_window: invalid duration %q: %w", dt, err) + add("arr_feed.history_window: invalid duration %q", dt) } } if c.ArrFeed.MaxEvents < 0 || c.ArrFeed.MaxEvents > 100 { - return fmt.Errorf("arr_feed.max_events: must be between 0 and 100") + add("arr_feed.max_events: must be between 0 and 100") } if c.MediaCalendar.Days < 0 { - return fmt.Errorf("media_calendar.days: must be >= 0") + add("media_calendar.days: must be >= 0") } if c.MediaCalendar.WatchInterval < 1 { - return fmt.Errorf("media_calendar.watch_interval: must be >= 1") + add("media_calendar.watch_interval: must be >= 1") } if c.MediaStreams.WatchInterval < 1 { - return fmt.Errorf("media_streams.watch_interval: must be >= 1") + add("media_streams.watch_interval: must be >= 1") } if c.MediaAirtime.Limit < 1 || c.MediaAirtime.Limit > 50 { - return fmt.Errorf("media_airtime.limit: must be between 1 and 50") + add("media_airtime.limit: must be between 1 and 50") } if c.MediaAirtime.PastDays < 0 { - return fmt.Errorf("media_airtime.past_days: must be >= 0") + add("media_airtime.past_days: must be >= 0") } if c.MediaAirtime.FutureDays < 0 { - return fmt.Errorf("media_airtime.future_days: must be >= 0") + add("media_airtime.future_days: must be >= 0") + } + if c.AniSearch.Limit < 1 || c.AniSearch.Limit > 50 { + add("anisearch.limit: must be between 1 and 50") + } + if c.MediaStreams.PlexURL != "" && !validHTTPURL(c.MediaStreams.PlexURL) { + add("media_streams.plex_url: invalid url %q", c.MediaStreams.PlexURL) + } + if c.MediaStreams.JellyfinURL != "" && !validHTTPURL(c.MediaStreams.JellyfinURL) { + add("media_streams.jellyfin_url: invalid url %q", c.MediaStreams.JellyfinURL) + } + if c.MediaRequests.OverseerrURL != "" && !validHTTPURL(c.MediaRequests.OverseerrURL) { + add("media_requests.overseerr_url: invalid url %q", c.MediaRequests.OverseerrURL) } serverType := c.MediaStreams.ServerType if serverType != "" && serverType != "plex" && serverType != "jellyfin" && serverType != "both" { - return fmt.Errorf("media_streams.server_type: must be 'plex', 'jellyfin', or 'both'") + add("media_streams.server_type: must be 'plex', 'jellyfin', or 'both'") + } + if (serverType == "plex" || serverType == "both") && c.MediaStreams.PlexToken == "" { + add("media_streams.plex_token: credential is required") + } + if (serverType == "jellyfin" || serverType == "both") && c.MediaStreams.JellyfinToken == "" { + add("media_streams.jellyfin_token: credential is required") } - return nil + return errors.Join(problems...) +} + +func validHTTPURL(value string) bool { + u, err := url.ParseRequestURI(value) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" } // Save writes the configuration to the default config file path. func (c *ToolkitConfig) Save() error { - path := ConfigPath() + return c.SaveAt("") +} + +// SaveAt atomically writes the configuration with owner-only permissions. +func (c *ToolkitConfig) SaveAt(explicitPath string) error { + path := ResolvePath(explicitPath) if path == "" { return fmt.Errorf("cannot determine home directory") } @@ -282,12 +426,36 @@ func (c *ToolkitConfig) Save() error { return fmt.Errorf("encoding config: %w", err) } - if err := os.WriteFile(path, data, 0600); err != nil { + tmp, err := os.CreateTemp(dir, ".config-*.tmp") + if err != nil { + return fmt.Errorf("creating temporary config: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0600); err != nil { + tmp.Close() + return fmt.Errorf("setting temporary config permissions: %w", err) + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() return fmt.Errorf("writing config: %w", err) } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("syncing config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing config: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("replacing config: %w", err) + } + if dirHandle, err := os.Open(dir); err == nil { + _ = dirHandle.Sync() + _ = dirHandle.Close() + } if err := os.Chmod(path, 0600); err != nil { return fmt.Errorf("setting config file permissions: %w", err) } - return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index da7be31..484712b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -17,8 +18,8 @@ func TestDefaultToolkitConfig(t *testing.T) { if cfg.MediaCalendar.Days != 1 { t.Errorf("Days = %d, want 1", cfg.MediaCalendar.Days) } - if cfg.MediaStreams.ServerType != "both" { - t.Errorf("ServerType = %q, want %q", cfg.MediaStreams.ServerType, "both") + if cfg.MediaStreams.ServerType != "" { + t.Errorf("ServerType = %q, want empty until a service is configured", cfg.MediaStreams.ServerType) } if cfg.MediaRequests.OverseerrURL != "http://localhost:5055" { t.Errorf("OverseerrURL = %q, want %q", cfg.MediaRequests.OverseerrURL, "http://localhost:5055") @@ -28,6 +29,37 @@ func TestDefaultToolkitConfig(t *testing.T) { } } +func TestValidateAggregatesProblems(t *testing.T) { + cfg := DefaultToolkitConfig() + cfg.General.Timeout = "bad" + cfg.Sonarr = []ArrInstance{{Name: "Same", URL: "not-url"}, {Name: "same", URL: "also-bad"}} + err := cfg.Validate() + if err == nil { + t.Fatal("expected validation errors") + } + for _, want := range []string{"general.timeout", "invalid url", "duplicate name", "api_key"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q: %v", want, err) + } + } +} + +func TestApplyEnvironmentPrecedence(t *testing.T) { + t.Setenv("PLEX_TOKEN", "legacy") + t.Setenv("CALMSTOOLKIT_PLEX_TOKEN", "new") + t.Setenv("CALMSTOOLKIT_SONARR_SONARR_HD_API_KEY", "instance") + cfg := DefaultToolkitConfig() + cfg.MediaStreams.PlexToken = "file" + cfg.Sonarr = []ArrInstance{{Name: "Sonarr HD", URL: "http://localhost", APIKey: "file"}} + ApplyEnvironment(cfg) + if cfg.MediaStreams.PlexToken != "new" { + t.Fatalf("token=%q", cfg.MediaStreams.PlexToken) + } + if cfg.Sonarr[0].APIKey != "instance" { + t.Fatalf("instance token=%q", cfg.Sonarr[0].APIKey) + } +} + func TestConfigSaveLoadRoundTrip(t *testing.T) { dir := t.TempDir() origHome := os.Getenv("HOME") diff --git a/internal/console/console.go b/internal/console/console.go new file mode 100644 index 0000000..5891c23 --- /dev/null +++ b/internal/console/console.go @@ -0,0 +1,153 @@ +// Package console owns terminal capability detection and machine rendering. +package console + +import ( + "encoding/json" + "io" + "os" + "regexp" + "strings" + "time" + + "github.com/mattn/go-runewidth" + "golang.org/x/term" +) + +// OutputMode is a stable output contract shared by every command. +type OutputMode string + +const ( + OutputAuto OutputMode = "auto" + OutputTerminal OutputMode = "terminal" + OutputPlain OutputMode = "plain" + OutputJSON OutputMode = "json" + OutputNDJSON OutputMode = "ndjson" +) + +// Capabilities describes the output terminal without coupling views to os.Stdout. +type Capabilities struct { + TTY, UTF8, Color, Unicode bool + Width int +} + +// Layout selects the responsive information density. +type Layout int + +const ( + LayoutStacked Layout = iota + LayoutCompact + LayoutFull +) + +func LayoutForWidth(width int) Layout { + if width < 60 { + return LayoutStacked + } + if width < 100 { + return LayoutCompact + } + return LayoutFull +} + +// BorderSet contains the shared box glyphs. +type BorderSet struct{ TopLeft, TopRight, BottomLeft, BottomRight, Horizontal, Vertical string } + +func Borders(unicode bool) BorderSet { + if unicode { + return BorderSet{"┌", "┐", "└", "┘", "─", "│"} + } + return BorderSet{"+", "+", "+", "+", "-", "|"} +} + +// Detect inspects an output file and relevant terminal environment variables. +func Detect(file *os.File, noColor bool) Capabilities { + c := Capabilities{Width: 80} + if file != nil { + c.TTY = term.IsTerminal(int(file.Fd())) + if width, _, err := term.GetSize(int(file.Fd())); err == nil && width > 0 { + c.Width = width + } + } + lang := strings.ToLower(os.Getenv("LC_ALL") + os.Getenv("LC_CTYPE") + os.Getenv("LANG")) + c.UTF8 = strings.Contains(lang, "utf-8") || strings.Contains(lang, "utf8") + c.Unicode = c.UTF8 && os.Getenv("TERM") != "dumb" + c.Color = c.TTY && os.Getenv("TERM") != "dumb" && os.Getenv("NO_COLOR") == "" && !noColor + return c +} + +// ResolveOutput resolves auto based on terminal capabilities. +func ResolveOutput(mode OutputMode, caps Capabilities) OutputMode { + if mode != OutputAuto { + return mode + } + if caps.TTY && caps.Unicode { + return OutputTerminal + } + return OutputPlain +} + +// Envelope is the versioned machine-output contract. +type Envelope struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + Command string `json:"command"` + Partial bool `json:"partial"` + Warnings []string `json:"warnings"` + Data any `json:"data"` +} + +// WriteEnvelope writes exactly one JSON value (also one NDJSON record). +func WriteEnvelope(w io.Writer, command string, data any, partial bool, warnings []string, now time.Time) error { + if warnings == nil { + warnings = []string{} + } + return json.NewEncoder(w).Encode(Envelope{SchemaVersion: "1", GeneratedAt: now.UTC(), Command: command, Partial: partial, Warnings: warnings, Data: data}) +} + +var ansiPattern = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))`) + +// StripANSI removes terminal control sequences from machine and plain output. +func StripANSI(value string) string { return ansiPattern.ReplaceAllString(value, "") } + +// Truncate shortens text by display cells and never counts ANSI sequences. +func Truncate(value string, width int) string { + value = StripANSI(value) + if width <= 0 { + return "" + } + return runewidth.Truncate(value, width, "") +} + +// PadRight pads or truncates a string to exactly width display cells. +func PadRight(value string, width int) string { + value = Truncate(value, width) + return value + strings.Repeat(" ", max(0, width-runewidth.StringWidth(value))) +} + +// Wrap splits text into display-width-bounded lines. +func Wrap(value string, width int) []string { + if width <= 0 { + return []string{""} + } + wrapped := runewidth.Wrap(StripANSI(value), width) + return strings.Split(wrapped, "\n") +} + +// Progress renders a bounded semantic progress bar without color. +func Progress(percent float64, width int, unicode bool) string { + if percent < 0 { + percent = 0 + } + if percent > 100 { + percent = 100 + } + if width < 0 { + width = 0 + } + filled := int(percent*float64(width)/100 + .5) + full, empty := "#", "-" + if unicode { + full, empty = "█", "░" + } + return strings.Repeat(full, filled) + strings.Repeat(empty, width-filled) +} diff --git a/internal/console/console_test.go b/internal/console/console_test.go new file mode 100644 index 0000000..0953aee --- /dev/null +++ b/internal/console/console_test.go @@ -0,0 +1,70 @@ +package console + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestWriteEnvelopeAndNoANSI(t *testing.T) { + var out bytes.Buffer + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.FixedZone("CEST", 2*60*60)) + if err := WriteEnvelope(&out, "streams", map[string]int{"active": 2}, false, nil, now); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "\x1b[") { + t.Fatal("machine output contains ANSI") + } + var got Envelope + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.SchemaVersion != "1" || got.Command != "streams" || got.GeneratedAt.Location() != time.UTC { + t.Fatalf("unexpected envelope: %+v", got) + } +} + +func TestStripTruncateAndPadUnicode(t *testing.T) { + if got := StripANSI("\x1b[31mhello\x1b[0m"); got != "hello" { + t.Fatalf("StripANSI = %q", got) + } + if got := Truncate("界界界", 4); got != "界界" { + t.Fatalf("Truncate = %q", got) + } + if got := PadRight("界", 4); got != "界 " { + t.Fatalf("PadRight = %q", got) + } +} + +func TestResolveOutput(t *testing.T) { + if got := ResolveOutput(OutputAuto, Capabilities{}); got != OutputPlain { + t.Fatalf("got %q", got) + } + if got := ResolveOutput(OutputAuto, Capabilities{TTY: true, Unicode: true}); got != OutputTerminal { + t.Fatalf("got %q", got) + } +} + +func TestResponsiveLayoutAndPrimitives(t *testing.T) { + for _, tt := range []struct { + width int + want Layout + }{{40, LayoutStacked}, {59, LayoutStacked}, {60, LayoutCompact}, {99, LayoutCompact}, {100, LayoutFull}, {120, LayoutFull}} { + if got := LayoutForWidth(tt.width); got != tt.want { + t.Errorf("width %d layout=%v", tt.width, got) + } + } + if got := Progress(50, 6, false); got != "###---" { + t.Fatalf("progress=%q", got) + } + if got := Borders(false).TopLeft; got != "+" { + t.Fatalf("ASCII border=%q", got) + } + for _, line := range Wrap("界界界", 4) { + if len([]rune(line)) > 2 { + t.Fatalf("wide wrap=%q", line) + } + } +} diff --git a/internal/core/core.go b/internal/core/core.go index 777a61a..2bff7f4 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -1,6 +1,7 @@ package core import ( + "fmt" "log/slog" "time" @@ -13,14 +14,22 @@ type CommonConfig struct { NoColor bool Theme string JSONOutput bool + PlainOutput bool Watch bool WatchSeconds int Debug bool Quiet bool + Strict bool Logger *slog.Logger Palette *colors.Palette } +// PartialError reports usable data accompanied by source failures. +type PartialError struct{ Warnings []string } + +func (e *PartialError) Error() string { return fmt.Sprintf("partial result: %v", e.Warnings) } +func (e *PartialError) PartialResult() bool { return true } + func FromToolkit(tk *config.ToolkitConfig) CommonConfig { c := CommonConfig{} if tk == nil { diff --git a/internal/feed/client.go b/internal/feed/client.go index 6fdd562..331b6c0 100644 --- a/internal/feed/client.go +++ b/internal/feed/client.go @@ -14,6 +14,11 @@ import ( ) func fetchAllHistory(ctx context.Context, client *httputil.Client, cfg ToolConfig, since time.Time) ([]HistoryEvent, error) { + events, _, err := fetchAllHistoryDetailed(ctx, client, cfg, since) + return events, err +} + +func fetchAllHistoryDetailed(ctx context.Context, client *httputil.Client, cfg ToolConfig, since time.Time) ([]HistoryEvent, []string, error) { var wg sync.WaitGroup eventsChan := make(chan []HistoryEvent, len(cfg.SonarrInstances)+len(cfg.RadarrInstances)) errorsChan := make(chan error, len(cfg.SonarrInstances)+len(cfg.RadarrInstances)) @@ -49,7 +54,9 @@ func fetchAllHistory(ctx context.Context, client *httputil.Client, cfg ToolConfi close(errorsChan) var allEvents []HistoryEvent + successes := 0 for events := range eventsChan { + successes++ allEvents = append(allEvents, events...) } @@ -58,15 +65,19 @@ func fetchAllHistory(ctx context.Context, client *httputil.Client, cfg ToolConfi errors = append(errors, err) } - if len(allEvents) == 0 && len(errors) > 0 { - return nil, fmt.Errorf("all instances failed: %v", errors) + warnings := make([]string, len(errors)) + for i, err := range errors { + warnings[i] = err.Error() + } + if successes == 0 && len(errors) > 0 { + return nil, warnings, fmt.Errorf("all instances failed: %v", errors) } sort.Slice(allEvents, func(i, j int) bool { return allEvents[i].When.After(allEvents[j].When) }) - return allEvents, nil + return allEvents, warnings, nil } func fetchSonarrHistory(ctx context.Context, client *httputil.Client, inst config.ArrInstance, since time.Time, showSubtitles bool) ([]HistoryEvent, error) { diff --git a/internal/feed/feed.go b/internal/feed/feed.go index f7fcc2d..7a29f25 100644 --- a/internal/feed/feed.go +++ b/internal/feed/feed.go @@ -6,9 +6,7 @@ import ( "encoding/json" "fmt" "os" - "os/signal" "slices" - "syscall" "time" "github.com/calmcacil/CalmsToolkit/internal/colors" @@ -61,32 +59,29 @@ func BuildToolConfig(tk *config.ToolkitConfig) ToolConfig { } // Run executes the Arr event feed tool. -func Run(cfg ToolConfig) { +func Run(ctx context.Context, cfg ToolConfig) error { if len(cfg.SonarrInstances) == 0 && len(cfg.RadarrInstances) == 0 { - fmt.Fprintf(os.Stderr, "ERROR: No Sonarr or Radarr instances configured\n") - fmt.Fprintf(os.Stderr, "Run 'make setup' or edit ~/.config/calmstoolkit/config.json\n") - os.Exit(1) + return fmt.Errorf("no Sonarr or Radarr instances configured") } client := httputil.NewClient(cfg.Timeout) - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - p := colors.GetPalette(cfg.Theme) if cfg.Watch { - runWatchMode(ctx, cfg, client, p) + return runWatchMode(ctx, cfg, client, p) } else { - runSingleMode(ctx, cfg, client, p) + if err := runSingleMode(ctx, cfg, client, p); err != nil { + return err + } } + return nil } -func runSingleMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, p *colors.Palette) { +func runSingleMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, p *colors.Palette) error { since := time.Now().Add(-cfg.HistoryWindow) - events, err := fetchAllHistory(ctx, client, cfg, since) + events, warnings, err := fetchAllHistoryDetailed(ctx, client, cfg, since) if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) - os.Exit(1) + return err } events = filterEvents(events, cfg) @@ -96,14 +91,21 @@ func runSingleMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, } if cfg.JSONOutput { - renderJSON(events) + renderJSON(events, len(warnings) > 0, warnings) } else { + for _, warning := range warnings { + fmt.Fprintf(os.Stderr, "WARNING: %s\n", warning) + } renderTable(events, cfg, p) } + if len(warnings) > 0 && cfg.Strict { + return &core.PartialError{Warnings: warnings} + } + return nil } -func runWatchMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, p *colors.Palette) { - if !cfg.JSONOutput { +func runWatchMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, p *colors.Palette) error { + if !cfg.JSONOutput && !cfg.PlainOutput { fmt.Print(colors.HideCursor) defer fmt.Print(colors.ShowCursor) } @@ -111,14 +113,16 @@ func runWatchMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, eventCache := make([]HistoryEvent, 0, 100) lastFetch := time.Now().Add(-cfg.HistoryWindow) - fmt.Print(colors.ClearScreen + colors.HomeCursor) + if !cfg.JSONOutput && !cfg.PlainOutput { + fmt.Print(colors.ClearScreen + colors.HomeCursor) + } var lastHash string for { - newEvents, err := fetchAllHistory(ctx, client, cfg, lastFetch) + newEvents, warnings, err := fetchAllHistoryDetailed(ctx, client, cfg, lastFetch) if err != nil { - if !cfg.JSONOutput { + if !cfg.JSONOutput && !cfg.PlainOutput { fmt.Print(colors.HomeCursor) clr := getColorFunc(cfg, p) fmt.Fprintf(os.Stderr, "%sERROR: %v%s\n", clr(p.Error), err, clr(p.Reset)) @@ -127,6 +131,7 @@ func runWatchMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, } } else { newCount := 0 + newlyAdded := make([]HistoryEvent, 0, len(newEvents)) for _, event := range newEvents { dup := false for i := len(eventCache) - 1; i >= 0 && i >= len(eventCache)-50; i-- { @@ -137,6 +142,7 @@ func runWatchMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, } if !dup { eventCache = append(eventCache, event) + newlyAdded = append(newlyAdded, event) newCount++ } } @@ -152,35 +158,47 @@ func runWatchMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, } if cfg.JSONOutput { - renderJSON(filteredEvents) + newlyAdded = filterEvents(newlyAdded, cfg) + if len(newlyAdded) > 0 || len(warnings) > 0 { + renderJSON(newlyAdded, len(warnings) > 0, warnings) + } } else { newHash := computeFeedHash(filteredEvents) if newHash == lastHash && newCount == 0 { select { case <-ctx.Done(): - fmt.Print(colors.ShowCursor) - return + if !cfg.PlainOutput { + fmt.Print(colors.ShowCursor) + } + return ctx.Err() case <-time.After(cfg.PollInterval): } continue } lastHash = newHash - fmt.Print(colors.HomeCursor) + if !cfg.PlainOutput { + fmt.Print(colors.HomeCursor) + } renderTable(filteredEvents, cfg, p) - fmt.Print(colors.EraseDown) + if !cfg.PlainOutput { + fmt.Print(colors.EraseDown) + } } if newCount > 0 { lastFetch = time.Now() } + if len(warnings) > 0 && cfg.Strict { + return &core.PartialError{Warnings: warnings} + } } select { case <-ctx.Done(): - if !cfg.JSONOutput { + if !cfg.JSONOutput && !cfg.PlainOutput { fmt.Print(colors.ShowCursor) } - return + return ctx.Err() case <-time.After(cfg.PollInterval): } } diff --git a/internal/feed/render.go b/internal/feed/render.go index df3392b..2a27d5f 100644 --- a/internal/feed/render.go +++ b/internal/feed/render.go @@ -3,7 +3,6 @@ package feed import ( "bufio" "bytes" - "encoding/json" "fmt" "os" "strings" @@ -12,6 +11,7 @@ import ( "golang.org/x/term" "github.com/calmcacil/CalmsToolkit/internal/colors" + "github.com/calmcacil/CalmsToolkit/internal/console" ) func formatRelativeTime(t time.Time) string { @@ -227,10 +227,8 @@ func renderTable(events []HistoryEvent, cfg ToolConfig, p *colors.Palette) { os.Stdout.Write(buf.Bytes()) } -func renderJSON(events []HistoryEvent) { - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - encoder.Encode(events) +func renderJSON(events []HistoryEvent, partial bool, warnings []string) { + _ = console.WriteEnvelope(os.Stdout, "feed", events, partial, warnings, time.Now()) } func getActionColor(action string, p *colors.Palette) string { diff --git a/internal/httputil/client.go b/internal/httputil/client.go index 3fcc71d..1fc5ce3 100644 --- a/internal/httputil/client.go +++ b/internal/httputil/client.go @@ -2,6 +2,7 @@ package httputil import ( + "bytes" "context" "encoding/json" "encoding/xml" @@ -9,6 +10,8 @@ import ( "io" "math/rand" "net/http" + "net/url" + "regexp" "strconv" "strings" "time" @@ -20,6 +23,17 @@ type Client struct { MaxBodySize int64 } +// ResponseError describes a non-success HTTP response without exposing request +// credentials. Body is bounded and redacted before it is stored. +type ResponseError struct { + StatusCode int + Method, URL, Body string +} + +func (e *ResponseError) Error() string { + return fmt.Sprintf("%s %s: unexpected status %d: %s", e.Method, e.URL, e.StatusCode, e.Body) +} + // NewClient creates a new Client with the given timeout. func NewClient(timeout time.Duration) *Client { return &Client{ @@ -32,15 +46,15 @@ func NewClient(timeout time.Duration) *Client { // NewTransportClient creates a new Client with a tuned transport (connection pooling). func NewTransportClient(timeout time.Duration) *Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 20 + transport.IdleConnTimeout = 30 * time.Second return &Client{ Client: &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - MaxIdleConns: 20, - IdleConnTimeout: 30 * time.Second, - DisableCompression: false, - }, + Timeout: timeout, + Transport: transport, }, + MaxBodySize: 10 * 1024 * 1024, } } @@ -81,16 +95,21 @@ func jitter(d time.Duration) time.Duration { // DoRequest performs an HTTP request and returns the raw body bytes and HTTP status code. func (c *Client) DoRequest(ctx context.Context, method, url string, headers map[string]string, body io.Reader) ([]byte, int, error) { - req, err := http.NewRequestWithContext(ctx, method, url, body) + data, status, _, err := c.doRequest(ctx, method, url, headers, body) + return data, status, err +} + +func (c *Client) doRequest(ctx context.Context, method, requestURL string, headers map[string]string, body io.Reader) ([]byte, int, http.Header, error) { + req, err := http.NewRequestWithContext(ctx, method, requestURL, body) if err != nil { - return nil, 0, err + return nil, 0, nil, err } for k, v := range headers { req.Header.Set(k, v) } resp, err := c.Do(req) if err != nil { - return nil, 0, err + return nil, 0, nil, err } defer resp.Body.Close() maxSize := c.MaxBodySize @@ -99,12 +118,12 @@ func (c *Client) DoRequest(ctx context.Context, method, url string, headers map[ } data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1)) if err != nil { - return nil, resp.StatusCode, err + return nil, resp.StatusCode, resp.Header.Clone(), err } if int64(len(data)) > maxSize { - return nil, resp.StatusCode, fmt.Errorf("response body too large (exceeds %d bytes)", maxSize) + return nil, resp.StatusCode, resp.Header.Clone(), fmt.Errorf("response body too large (exceeds %d bytes)", maxSize) } - return data, resp.StatusCode, nil + return data, resp.StatusCode, resp.Header.Clone(), nil } // DoJSON performs an HTTP request and decodes the JSON response into result. @@ -114,7 +133,7 @@ func (c *Client) DoJSON(ctx context.Context, method, url string, headers map[str return err } if status != http.StatusOK && status != http.StatusCreated { - return fmt.Errorf("unexpected status %d: %s", status, truncateBody(strings.TrimSpace(string(data)))) + return responseError(method, url, status, data) } if result != nil { if err := json.Unmarshal(data, result); err != nil { @@ -131,7 +150,7 @@ func (c *Client) DoXML(ctx context.Context, method, url string, headers map[stri return err } if status != http.StatusOK { - return fmt.Errorf("unexpected status %d: %s", status, truncateBody(strings.TrimSpace(string(data)))) + return responseError(method, url, status, data) } if result != nil { if err := xml.Unmarshal(data, result); err != nil { @@ -145,7 +164,7 @@ func (c *Client) DoXML(ctx context.Context, method, url string, headers map[stri func (c *Client) DoJSONWithRetry(ctx context.Context, method, url string, headers map[string]string, body io.Reader, result interface{}, rc RetryConfig) error { return c.doWithRetry(ctx, method, url, headers, body, func(data []byte, status int) error { if status != http.StatusOK && status != http.StatusCreated { - return fmt.Errorf("unexpected status %d: %s", status, truncateBody(string(data))) + return responseError(method, url, status, data) } if result == nil { return nil @@ -158,7 +177,7 @@ func (c *Client) DoJSONWithRetry(ctx context.Context, method, url string, header func (c *Client) DoXMLWithRetry(ctx context.Context, method, url string, headers map[string]string, body io.Reader, result interface{}, rc RetryConfig) error { return c.doWithRetry(ctx, method, url, headers, body, func(data []byte, status int) error { if status != http.StatusOK { - return fmt.Errorf("unexpected status %d: %s", status, truncateBody(string(data))) + return responseError(method, url, status, data) } if result == nil { return nil @@ -170,13 +189,35 @@ func (c *Client) DoXMLWithRetry(ctx context.Context, method, url string, headers type responseHandler func([]byte, int) error func (c *Client) doWithRetry(ctx context.Context, method, url string, headers map[string]string, body io.Reader, fn responseHandler, rc RetryConfig) error { + var bodyData []byte + var err error + if body != nil { + bodyData, err = io.ReadAll(body) + if err != nil { + return fmt.Errorf("reading request body: %w", err) + } + } + if !retryableMethod(method) { + rc.MaxAttempts = min(rc.MaxAttempts, 1) + } + if rc.MaxAttempts < 1 { + rc.MaxAttempts = 1 + } backoff := rc.InitialBackoff for attempt := 1; attempt <= rc.MaxAttempts; attempt++ { - data, status, err := c.DoRequest(ctx, method, url, headers, body) + var requestBody io.Reader + if bodyData != nil { + requestBody = bytes.NewReader(bodyData) + } + data, status, responseHeaders, err := c.doRequest(ctx, method, url, headers, requestBody) if err == nil { if rc.shouldRetry(status) && attempt < rc.MaxAttempts { if rc.RespectRetryAfter { - if ra := parseRetryAfter(string(data)); ra > 0 && ra < rc.MaxBackoff { + ra := parseRetryAfterHeader(responseHeaders.Get("Retry-After"), time.Now()) + if ra <= 0 { + ra = parseRetryAfter(string(data)) + } + if ra > 0 && (rc.MaxBackoff <= 0 || ra <= rc.MaxBackoff) { backoff = ra } } @@ -204,12 +245,59 @@ func (c *Client) doWithRetry(ctx context.Context, method, url string, headers ma } func truncateBody(s string) string { + s = redact(s) if len(s) > 512 { return s[:512] + "..." } return s } +func responseError(method, requestURL string, status int, data []byte) error { + return &ResponseError{StatusCode: status, Method: method, URL: safeURL(requestURL), Body: truncateBody(strings.TrimSpace(string(data)))} +} + +func safeURL(value string) string { + u, err := url.Parse(value) + if err != nil { + return "" + } + query := u.Query() + for key := range query { + lower := strings.ToLower(key) + if strings.Contains(lower, "token") || strings.Contains(lower, "key") || strings.Contains(lower, "auth") { + query.Set(key, "REDACTED") + } + } + u.RawQuery = query.Encode() + return u.String() +} + +var secretPattern = regexp.MustCompile(`(?i)(api[_-]?key|token|authorization)(["']?\s*[:=]\s*["']?)[^"'\s&,}]+`) + +func redact(value string) string { return secretPattern.ReplaceAllString(value, `${1}${2}REDACTED`) } + +func retryableMethod(method string) bool { + switch strings.ToUpper(method) { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPut, http.MethodDelete: + return true + } + return false +} + +func parseRetryAfterHeader(value string, now time.Time) time.Duration { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 { + return time.Duration(seconds) * time.Second + } + if when, err := http.ParseTime(value); err == nil && when.After(now) { + return when.Sub(now) + } + return 0 +} + func parseRetryAfter(body string) time.Duration { var h struct { RetryAfter string `json:"retryAfter"` diff --git a/internal/httputil/client_test.go b/internal/httputil/client_test.go index 318ff10..e9f599f 100644 --- a/internal/httputil/client_test.go +++ b/internal/httputil/client_test.go @@ -2,6 +2,8 @@ package httputil import ( "context" + "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -9,6 +11,52 @@ import ( "time" ) +func TestRetryReplaysBodyAndReadsRetryAfterHeader(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + body, _ := io.ReadAll(r.Body) + if string(body) != `{"value":1}` { + t.Errorf("attempt %d body=%q", attempts, body) + } + if attempts == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"message":"ok"}`)) + })) + defer server.Close() + client := NewClient(time.Second) + var result testJSON + rc := DefaultRetry() + rc.InitialBackoff = time.Millisecond + rc.MaxBackoff = 2 * time.Millisecond + if err := client.DoJSONWithRetry(context.Background(), http.MethodPut, server.URL, nil, strings.NewReader(`{"value":1}`), &result, rc); err != nil { + t.Fatal(err) + } + if attempts != 2 || result.Message != "ok" { + t.Fatalf("attempts=%d result=%+v", attempts, result) + } +} + +func TestResponseErrorRedactsSecrets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"api_key":"supersecret"}`)) + })) + defer server.Close() + err := NewClient(time.Second).DoJSON(context.Background(), http.MethodGet, server.URL+"?token=querysecret", nil, nil, nil) + var responseErr *ResponseError + if !errors.As(err, &responseErr) { + t.Fatalf("error type=%T", err) + } + if strings.Contains(err.Error(), "supersecret") || strings.Contains(err.Error(), "querysecret") { + t.Fatalf("secret leaked: %v", err) + } +} + func TestNewClient(t *testing.T) { c := NewClient(5 * time.Second) if c.Client.Timeout != 5*time.Second { diff --git a/internal/requests/requests.go b/internal/requests/requests.go index d5c50da..6a068d5 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -3,7 +3,6 @@ package requests import ( "context" "fmt" - "os" "strings" "github.com/calmcacil/CalmsToolkit/internal/config" @@ -26,24 +25,19 @@ func BuildToolConfig(tk *config.ToolkitConfig) ToolConfig { } // Run executes the media requests interactive tool. -func Run(cfg ToolConfig) { +func Run(ctx context.Context, cfg ToolConfig) error { if cfg.APIKey == "" { - fmt.Fprintf(os.Stderr, "ERROR: API key is not set\n") - fmt.Fprintf(os.Stderr, "Set api_key in ~/.config/calmstoolkit/config.json or use -token flag\n") - os.Exit(1) + return fmt.Errorf("API key is not set") } if cfg.ServerURL == "" { - fmt.Fprintf(os.Stderr, "ERROR: Server URL is not set\n") - fmt.Fprintf(os.Stderr, "Set overseerr_url in ~/.config/calmstoolkit/config.json or use -url flag\n") - os.Exit(1) + return fmt.Errorf("server URL is not set") } - ctx := context.Background() if err := testConnection(ctx, cfg); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Failed to connect to server: %v\n", err) - os.Exit(1) + return fmt.Errorf("connect to server: %w", err) } runInteractiveMenu(ctx, cfg) + return nil } diff --git a/internal/streams/render.go b/internal/streams/render.go index a434f51..f649bc6 100644 --- a/internal/streams/render.go +++ b/internal/streams/render.go @@ -3,7 +3,6 @@ package streams import ( "bufio" "bytes" - "encoding/json" "fmt" "os" "sort" @@ -14,6 +13,7 @@ import ( "golang.org/x/term" "github.com/calmcacil/CalmsToolkit/internal/colors" + "github.com/calmcacil/CalmsToolkit/internal/console" ) func formatTimeSince(t time.Time) string { @@ -42,7 +42,7 @@ func formatTimeSince(t time.Time) string { return fmt.Sprintf("%d hours ago", hours) } -func displayJSONOutput(streams []StreamInfo, plexCount, jellyfinCount int) error { +func displayJSONOutput(streams []StreamInfo, plexCount, jellyfinCount int, partial bool, warnings []string) error { summary := Summary{ TotalStreams: len(streams), PlexStreams: plexCount, @@ -64,9 +64,7 @@ func displayJSONOutput(streams []StreamInfo, plexCount, jellyfinCount int) error summary.TranscodingCount = transcodeCount summary.TotalBandwidth = totalBandwidth - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - return encoder.Encode(summary) + return console.WriteEnvelope(os.Stdout, "streams", summary, partial, warnings, time.Now()) } func renderProgressBar(pct float64, width int) string { @@ -200,7 +198,7 @@ func displayTerminalOutput(streams []StreamInfo, plexCount, jellyfinCount int, n return nil } -func displayTerminalOutputWithHistory(currentStreams []StreamInfo, history *SessionHistory, plexCount, jellyfinCount int, noColor bool, p *colors.Palette) error { +func displayTerminalOutputWithHistory(currentStreams []StreamInfo, history *SessionHistory, plexCount, jellyfinCount int, noColor, plain bool, p *colors.Palette) error { clr := colors.ClrFunc(noColor) rawW := getTermWidth() @@ -209,7 +207,9 @@ func displayTerminalOutputWithHistory(currentStreams []StreamInfo, history *Sess var buf bytes.Buffer bw := bufio.NewWriter(&buf) - fmt.Fprint(bw, colors.ClearScreen+colors.HomeCursor) + if !plain { + fmt.Fprint(bw, colors.ClearScreen+colors.HomeCursor) + } // Title title := "Media Streams Monitor" @@ -246,7 +246,9 @@ func displayTerminalOutputWithHistory(currentStreams []StreamInfo, history *Sess fmt.Fprint(bw, clr(p.Reset)) fmt.Fprint(bw, "│\n") boxStreamBottom(bw, termW) - fmt.Fprint(bw, colors.EraseDown) + if !plain { + fmt.Fprint(bw, colors.EraseDown) + } bw.Flush() os.Stdout.Write(buf.Bytes()) return nil @@ -291,7 +293,9 @@ func displayTerminalOutputWithHistory(currentStreams []StreamInfo, history *Sess boxStreamBottom(bw, termW) } - fmt.Fprint(bw, colors.EraseDown) + if !plain { + fmt.Fprint(bw, colors.EraseDown) + } bw.Flush() os.Stdout.Write(buf.Bytes()) return nil diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 2ac258e..945dc11 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "os" - "os/signal" "strings" - "syscall" "time" "github.com/calmcacil/CalmsToolkit/internal/colors" @@ -47,42 +45,45 @@ func BuildToolConfig(tk *config.ToolkitConfig) ToolConfig { } // Run executes the media streams monitor tool. -func Run(cfg ToolConfig) { +func Run(ctx context.Context, cfg ToolConfig) error { p := colors.GetPalette(cfg.Theme) if cfg.Watch { - fmt.Print(colors.HideCursor) - defer fmt.Print(colors.ShowCursor) + if !cfg.JSONOutput && !cfg.PlainOutput { + fmt.Print(colors.HideCursor) + defer fmt.Print(colors.ShowCursor) + } history := &SessionHistory{ Records: make(map[string]*SessionRecord), } - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - fmt.Print(colors.ClearScreen + colors.HomeCursor) + if !cfg.JSONOutput && !cfg.PlainOutput { + fmt.Print(colors.ClearScreen + colors.HomeCursor) + } var lastHash string for { if err := displayAllSessionsWithHistory(ctx, cfg, history, &lastHash, p); err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + if cfg.Strict { + return err + } } select { case <-ctx.Done(): fmt.Fprintln(os.Stderr, "\nShutting down.") - return + return ctx.Err() case <-time.After(time.Duration(cfg.WatchSeconds) * time.Second): } } } - ctx := context.Background() if err := displayAllSessions(ctx, cfg, p); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) - return + return err } + return nil } func displayAllSessionsWithHistory(ctx context.Context, cfg ToolConfig, history *SessionHistory, lastHash *string, p *colors.Palette) error { @@ -117,18 +118,30 @@ func displayAllSessionsWithHistory(ctx context.Context, cfg ToolConfig, history if allFailed(cfg.ServerType, plexErr, jellyfinErr) { return fmt.Errorf("all servers failed: plex: %v, jellyfin: %v", maybeError(plexErr), maybeError(jellyfinErr)) } + partial, warnings := partialFailures(plexErr, jellyfinErr) - if cfg.JSONOutput { - return displayJSONOutput(allStreams, plexCount, jellyfinCount) - } - - newHash := computeStreamsHash(history) + newHash := computeStreamsHash(history) + strings.Join(warnings, "|") if *lastHash == newHash { return nil } *lastHash = newHash + if cfg.JSONOutput { + if err := displayJSONOutput(allStreams, plexCount, jellyfinCount, partial, warnings); err != nil { + return err + } + if partial && cfg.Strict { + return &core.PartialError{Warnings: warnings} + } + return nil + } + for _, warning := range warnings { + fmt.Fprintf(os.Stderr, "WARNING: %s\n", warning) + } + if partial && cfg.Strict { + return &core.PartialError{Warnings: warnings} + } - return displayTerminalOutputWithHistory(allStreams, history, plexCount, jellyfinCount, cfg.NoColor, p) + return displayTerminalOutputWithHistory(allStreams, history, plexCount, jellyfinCount, cfg.NoColor, cfg.PlainOutput, p) } func displayAllSessions(ctx context.Context, cfg ToolConfig, p *colors.Palette) error { @@ -161,14 +174,38 @@ func displayAllSessions(ctx context.Context, cfg ToolConfig, p *colors.Palette) if allFailed(cfg.ServerType, plexErr, jellyfinErr) { return fmt.Errorf("all servers failed: plex: %v, jellyfin: %v", maybeError(plexErr), maybeError(jellyfinErr)) } + partial, warnings := partialFailures(plexErr, jellyfinErr) if cfg.JSONOutput { - return displayJSONOutput(allStreams, plexCount, jellyfinCount) + if err := displayJSONOutput(allStreams, plexCount, jellyfinCount, partial, warnings); err != nil { + return err + } + if partial && cfg.Strict { + return &core.PartialError{Warnings: warnings} + } + return nil + } + for _, warning := range warnings { + fmt.Fprintf(os.Stderr, "WARNING: %s\n", warning) + } + if partial && cfg.Strict { + return &core.PartialError{Warnings: warnings} } return displayTerminalOutput(allStreams, plexCount, jellyfinCount, cfg.NoColor, p) } +func partialFailures(plexErr, jellyfinErr error) (bool, []string) { + warnings := make([]string, 0, 2) + if plexErr != nil { + warnings = append(warnings, "Plex: "+plexErr.Error()) + } + if jellyfinErr != nil { + warnings = append(warnings, "Jellyfin: "+jellyfinErr.Error()) + } + return len(warnings) > 0, warnings +} + func allFailed(serverType string, plexErr, jellyfinErr error) bool { switch serverType { case ServerBoth: diff --git a/internal/watch/runner.go b/internal/watch/runner.go new file mode 100644 index 0000000..0ec8449 --- /dev/null +++ b/internal/watch/runner.go @@ -0,0 +1,55 @@ +// Package watch provides the shared polling and change-detection lifecycle. +package watch + +import ( + "context" + "time" +) + +// Snapshot carries usable data and source warnings from one fetch. +type Snapshot[T any] struct { + Data T + Partial bool + Warnings []string +} + +// Runner performs an initial fetch followed by cancellable polling. +type Runner[T any] struct { + Interval time.Duration + Fetch func(context.Context) (Snapshot[T], error) + Hash func(Snapshot[T]) string + Emit func(Snapshot[T]) error +} + +// Run emits the initial snapshot and subsequent changed snapshots. +func (r Runner[T]) Run(ctx context.Context, once bool) error { + if r.Interval <= 0 { + r.Interval = time.Second + } + last := "" + for { + snapshot, err := r.Fetch(ctx) + if err != nil { + return err + } + hash := r.Hash(snapshot) + if hash != last { + if err := r.Emit(snapshot); err != nil { + return err + } + last = hash + } + if once { + return nil + } + timer := time.NewTimer(r.Interval) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + } + } +} diff --git a/internal/watch/runner_test.go b/internal/watch/runner_test.go new file mode 100644 index 0000000..f580388 --- /dev/null +++ b/internal/watch/runner_test.go @@ -0,0 +1,28 @@ +package watch + +import ( + "context" + "errors" + "strconv" + "testing" + "time" +) + +func TestRunnerInitialChangeAndCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + fetches, emits := 0, 0 + r := Runner[int]{Interval: time.Millisecond, Fetch: func(context.Context) (Snapshot[int], error) { + fetches++ + if fetches == 4 { + cancel() + } + return Snapshot[int]{Data: min(fetches, 2)}, nil + }, Hash: func(s Snapshot[int]) string { return strconv.Itoa(s.Data) }, Emit: func(Snapshot[int]) error { emits++; return nil }} + err := r.Run(ctx, false) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v", err) + } + if emits != 2 { + t.Fatalf("emits=%d", emits) + } +} diff --git a/llms.txt b/llms.txt index a2837e4..dc61105 100644 --- a/llms.txt +++ b/llms.txt @@ -1,30 +1,15 @@ # CalmsToolkit -Go module (go 1.25) for media server monitoring and management. +Linux Go module providing one `calmstoolkit` executable. -## Binaries +## Command/runtime boundaries -- `media-streams` — Monitor Plex/Jellyfin streams -- `media-calendar` — Sonarr/Radarr calendar view -- `media-requests` — Overseerr/Jellyseerr interactive request manager -- `media-airtime` — Fuzzy airtime lookup in Sonarr/Radarr -- `arr-feed` — Sonarr/Radarr history event feed -- `calmstoolkit-setup` — Interactive config wizard +- `cmd/calmstoolkit/main.go`: the only process entrypoint and only permitted `os.Exit`. +- `internal/cli`: Cobra registration, flag precedence, validation, exit mapping. +- `internal/app`: injected context, I/O, logger, clock, HTTP factory, terminal capabilities. +- `internal/console`: output modes, ANSI-safe primitives, machine envelopes. +- `internal/config`: compatible JSON config, environment overlays, aggregate validation, atomic saves. +- `internal/httputil`: bounded/retrying/redacted HTTP foundation. +- `internal/{streams,calendar,requests,airtime,feed,anisearch}`: feature behavior. -## Key packages - -- `internal/config` — `ToolkitConfig` JSON schema at `~/.config/calmstoolkit/config.json` -- `internal/httputil` — `DoJSONWithRetry`, `DoXMLWithRetry`, configurable `MaxBodySize` -- `internal/cmdutil` — Shared `--version`, `--json`, `--no-color`, `--timeout` flags -- `internal/core` — `CommonConfig` embedded in every tool struct -- `internal/buildinfo` — Version injected via `-ldflags -X` -- `internal/slogx` — Structured logging via `log/slog` - -## Build - -```bash -make build -make test -``` - -See `CHANGELOG.md`, `CONTRIBUTING.md`, and `docs/user/` for per-tool docs. +Use `make check`. Do not add standalone binaries or package-local console frameworks. See `docs/ARCHITECTURE.md`, `docs/ADDING_A_TOOL.md`, and `docs/user/CLI_SPEC.md`. diff --git a/scripts/tmux-demo.sh b/scripts/tmux-demo.sh index 95dbb51..cfc4dac 100755 --- a/scripts/tmux-demo.sh +++ b/scripts/tmux-demo.sh @@ -4,10 +4,10 @@ # # Layout: # ┌──────────────────────┬──────────────────────┐ -# │ media-streams │ media-calendar │ +# │ streams │ calendar │ # │ (single shot) │ (single shot) │ # ├──────────────────────┴───────────────────────┤ -# │ arr-feed │ +# │ feed │ # │ (single shot) │ # └───────────────────────────────────────────────┘ @@ -30,16 +30,16 @@ BIN="$(pwd)/bin" tmux new-session -d -s "$SESSION" -# Top-left: media-streams (snapshot mode) -tmux send-keys -t "$SESSION:0" "$BIN/media-streams 2>&1; echo '--- STREAMS DONE ---'" Enter +# Top-left: streams (snapshot mode) +tmux send-keys -t "$SESSION:0" "$BIN/calmstoolkit streams 2>&1; echo '--- STREAMS DONE ---'" Enter -# Top-right: media-calendar (snapshot mode) +# Top-right: calendar (snapshot mode) tmux split-window -h -tmux send-keys -t "$SESSION:0" "$BIN/media-calendar 2>&1; echo '--- CALENDAR DONE ---'" Enter +tmux send-keys -t "$SESSION:0" "$BIN/calmstoolkit calendar 2>&1; echo '--- CALENDAR DONE ---'" Enter -# Bottom: arr-feed (snapshot mode, no watch) +# Bottom: feed (snapshot mode, no watch) tmux split-window -v -tmux send-keys -t "$SESSION:0" "$BIN/arr-feed 2>&1; echo '--- FEED DONE ---'" Enter +tmux send-keys -t "$SESSION:0" "$BIN/calmstoolkit feed 2>&1; echo '--- FEED DONE ---'" Enter # Balance panes tmux select-layout -t "$SESSION:0" main-horizontal 2>/dev/null || true From 78d3993ce4174505886a0950c42c650fa35951e4 Mon Sep 17 00:00:00 2001 From: "Calmcacil B." <716671+calmcacil@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:35:49 +0200 Subject: [PATCH 2/4] docs: audit CI and release infrastructure --- AGENTS.md | 4 ++- CHANGELOG.md | 51 ++------------------------- CONTRIBUTING.md | 6 +++- LICENSE | 21 +++++++++++ README.md | 4 +++ docs/CI_AUDIT.md | 84 ++++++++++++++++++++++++++++++++++++++++++++ docs/CI_MIGRATION.md | 25 +++++++++++++ docs/CI_RELEASES.md | 84 ++++++++++++++++++++++++++++++++++++++++++++ llms.txt | 2 ++ 9 files changed, 231 insertions(+), 50 deletions(-) create mode 100644 LICENSE create mode 100644 docs/CI_AUDIT.md create mode 100644 docs/CI_MIGRATION.md create mode 100644 docs/CI_RELEASES.md diff --git a/AGENTS.md b/AGENTS.md index a11ba11..9cb76e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ CalmsToolkit is a Go module with one Linux CLI at `cmd/calmstoolkit`. Never add ## Build, Test, and Development Commands Use `make build`, `make build-all` (Linux amd64/arm64), `make test`, and `make tidy`. `make check` is the full gate. Run commands with `go run ./cmd/calmstoolkit `. Configuration is managed through `calmstoolkit config setup|validate` and `doctor`. +For CI-equivalent validation also run the pinned golangci-lint, actionlint, and govulncheck commands in `docs/CI_RELEASES.md`. Do not change workflows without updating the audit/migration docs and retaining explicit permissions plus immutable action SHAs. + ## Coding Style & Naming Conventions Format every change with `gofmt` (or `go fmt ./...`) before committing; the project mirrors idiomatic Go spacing and grouping. Keep exported types, funcs, and consts in UpperCamelCase with doc comments, use lowerCamelCase for locals, and reserve ALL_CAPS for environment variable labels surfaced to users. Group related constants and config structs to keep CLI flag wiring readable. @@ -15,7 +17,7 @@ Place table-driven tests in `_test.go` files beside the feature under test and c New commands must test terminal, plain, JSON/NDJSON, failure, and cancellation paths. Foundational packages `app`, `console`, `config`, `httputil`, and new domain clients target at least 80% coverage. JSON/NDJSON tests must assert schema validity and absence of ANSI sequences. ## Commit & Pull Request Guidelines -Match the existing history by writing imperative, present-tense commit subjects (`Add media calendar filters`). Squash incidental fixups locally so each commit is reviewable. Pull requests should link the relevant issue, summarize behavioral changes, call out new flags or env vars, and include screenshots or sample CLI output when they clarify UX changes. Flag any security-sensitive configuration updates explicitly. +Use Conventional Commit subjects and PR titles (`feat(calendar): add filters`). Squash incidental fixups locally so each commit is reviewable; the final PR title becomes the squash commit and drives Release Please. Pull requests should link the relevant issue, summarize behavioral changes, call out new flags or env vars, and include screenshots or sample CLI output when they clarify UX changes. Flag breaking or security-sensitive configuration updates explicitly. ## Configuration & Secrets The shared config file at `~/.config/calmstoolkit/config.json` stores API keys and tokens. It is created with `0600` permissions; never commit it. Local overrides should rely on shell exports or direnv, and scrub logs or fixtures before sharing them in reviews. diff --git a/CHANGELOG.md b/CHANGELOG.md index 43fcf95..cd90ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,51 +1,6 @@ # Changelog -## [7.0.0] — 2026-07-15 +All notable public changes to CalmsToolkit will be documented here. -### Changed -- Consolidated the toolkit behind one Linux `calmstoolkit` Cobra command tree. -- Added shared runtime injection, output modes, terminal capability detection, and versioned machine envelopes. -- Added config path/environment precedence, aggregate validation, atomic `0600` saves, `doctor`, and `config validate`. -- Hardened HTTP transport defaults, bounded bodies, safe replay/retry, `Retry-After`, and redacted typed errors. - -### Fixed -- Made AniSearch TVDB season-one selection deterministic. -- Preserved configured no-color and watch interval values unless flags are explicitly supplied. - -## [6.0.0] — 2026-06-01 - -Architecture refactor — no user-facing API changes. - -### Added -- `--version` flag on every binary (injected via ldflags). -- `internal/buildinfo` package with version/commit/date metadata. -- `internal/cmdutil` package for shared flag parsing, theme validation, and config loading. -- `internal/slogx` package with structured logging (text/JSON) via `log/slog`. -- `internal/core` package with `CommonConfig` embedded in every tool — removes 5x duplication of timeout/no-color/theme parsing. -- `internal/datex` package with flexible `DateTime` JSON type. -- `httputil.DoJSONWithRetry` / `DoXMLWithRetry` with exponential backoff and `Retry-After` respect. -- `httputil.Client.MaxBodySize` field (configurable per tool). -- `make clean-all` target for hygiene. -- CI coverage report step. -- Per-package test coverage for `core`, `datex`, `slogx`. - -### Changed -- `docs/` reorganized into `docs/user/` and `docs/research/`. -- Dev docs (`AGENT_QUICKSTART.md`, `CLAUDE.md`, `WORKTREE_*`) moved to `.opencode/`. -- `internal/requests` split into `model.go`, `client.go`, `menu.go`, `render.go`. -- `internal/feed` split into `model.go`, `client.go`, `render.go`. -- `internal/streams` split into `model.go`, `client.go`, `render.go`. -- All 5 `cmd/*/main.go` files refactored to use `cmdutil` (net -120 LOC). -- All 5 `ToolConfig` structs now embed `core.CommonConfig`. -- Response body in error messages truncated to 512 bytes. - -### Removed -- Stray `media-airtime` binary at repo root. -- Hard-coded 10 MiB body cap in `DoRequest`. -- Duplicated theme/flag/validation code in every `main.go`. -- Duplicated timeout/no-color/theme parsing in every `BuildToolConfig`. -- `NewTransportClient` (dead code). - -### Fixed -- Error messages now consistently go to stderr (via slog). -- Theme validation centralized (5x → 1x). +The first public release will be `v1.0.0`. Release Please maintains this file +from Conventional Commit titles after changes merge to `main`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b64f02c..e82045f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,8 +2,12 @@ Run `make check` before submitting changes. It formats source, verifies module tidiness, runs vet and race tests, and builds Linux amd64/arm64 artifacts. +Also run the pinned golangci-lint, actionlint, and govulncheck commands in `docs/CI_RELEASES.md` when changing code or workflows. CI is authoritative. + There is one executable: `cmd/calmstoolkit`. Register commands with Cobra in `internal/cli`; put behavior in a feature or domain package; inject `internal/app.Runtime`; use `internal/console` for terminal/machine output and `internal/httputil` or a typed domain client for HTTP. Only `cmd/calmstoolkit/main.go` may call `os.Exit`. Tests should cover terminal/plain/machine output, failure, cancellation, and HTTP fixtures where applicable. JSON and NDJSON stdout must contain only documented envelopes; diagnostics and logs belong on stderr. Never log or fixture tokens, API keys, authorization headers, or secret-bearing URLs. -Update command help, user docs, the migration guide when an interface changes, and `docs/ADDING_A_TOOL.md` checklists. Use imperative commit subjects such as `Add calendar filters`. +Update command help, user docs, the migration guide when an interface changes, and `docs/ADDING_A_TOOL.md` checklists. Use Conventional Commit subjects and PR titles such as `feat(calendar): add filters`. Squash merging uses the PR title to determine the next semantic release; mark breaking changes with `!` and describe migration impact in the PR body. + +GitHub Actions must use explicit least-privilege permissions and immutable full-SHA action pins. Never add `pull_request_target`, release secrets, privileged artifact consumption, or a self-hosted runner without a documented threat-model review. See `docs/CI_AUDIT.md` and `docs/CI_RELEASES.md`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ec7b5f4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 calmcacil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index deb79bc..613ee7b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ calmstoolkit anime "Frieren" --output json See [migration](docs/user/MIGRATION_UNIFIED_CLI.md), [CLI behavior](docs/user/CLI_SPEC.md), and [architecture](docs/ARCHITECTURE.md). +Prebuilt Linux `amd64` and `arm64` archives are published on GitHub Releases. Public releases follow Semantic Versioning and are generated from Conventional Commit pull-request titles. + ## Development ```bash @@ -46,3 +48,5 @@ make check # format, tidy, vet, race tests, Linux builds ``` New commands must follow [Adding a Tool](docs/ADDING_A_TOOL.md). Never commit the configuration file, tokens, or logs containing credentials. + +Pull requests run formatting, lint, race tests, Linux builds, dependency validation, vulnerability analysis, and CodeQL. See [CI and releases](docs/CI_RELEASES.md) for required checks and the automated release flow. CalmsToolkit is available under the [MIT License](LICENSE). diff --git a/docs/CI_AUDIT.md b/docs/CI_AUDIT.md new file mode 100644 index 0000000..ef78a2e --- /dev/null +++ b/docs/CI_AUDIT.md @@ -0,0 +1,84 @@ +# GitHub Actions audit + +Date: 2026-07-15 + +This audit records the state before the CI redesign and the resulting controls. The repository is intended to be public; no workflow may trust code from a fork. + +## Previous state + +The repository had one workflow, `.github/workflows/ci.yml`, named `CI`. + +| Area | Previous behavior | Assessment | +| --- | --- | --- | +| Triggers | Pushes and pull requests targeting `main` | Safe trigger choice; duplicate runs were possible around merges, and there was no manual diagnostic trigger. | +| Permissions | Top-level `contents: read` | Good least-privilege default. No secrets were used and no privileged PR event was used. | +| Execution | One `Quality, Test & Build` job on `ubuntu-latest` | Safe for public forks, but serialized unrelated checks and delayed feedback. | +| Actions | `actions/checkout@v4` and `actions/setup-go@v5` | Maintainer-controlled actions, but mutable major tags did not provide supply-chain immutability. | +| Formatting | `gofmt` diff check | Appropriate and inexpensive. | +| Modules | `go mod tidy` plus a diff check | Useful, but `go mod verify` was absent. | +| Static analysis | `go vet` | Useful but narrow; the checked-in golangci-lint policy was not enforced. | +| Tests | Race tests with coverage | Strong unit gate. Coverage was displayed but not persisted or summarized clearly. | +| Builds | `go build ./cmd/...` | Verified the host build, not both supported Linux architectures. | +| Security | None | No dependency review, CodeQL, or Go vulnerability analysis. | +| Release | None | Tags, notes, version selection, and binaries were manual or undefined. | + +There was no `pull_request_target`, no self-hosted runner, no secret use, no artifact ingestion, and no untrusted code running with write permissions. The old workflow therefore had no direct secret-exposure path. Its main security weakness was missing analysis and mutable action references rather than an actively privileged design. + +## Identified issues + +- CI did not enforce the repository's golangci-lint policy, action syntax, module checksums, command/documentation registration, or the single-entrypoint architecture. +- A single job made failures less obvious and prevented independent required checks. +- Supported Linux `amd64` and `arm64` release builds were not both validated. +- There was no dependency-change review, CodeQL scan, or `govulncheck` gate. +- Action references were not pinned to immutable commit SHAs. +- No dependency update automation existed for Go modules or actions. +- No release automation, release credential boundary, or reproducible artifact configuration existed. +- Superseded runs were cancelled only by workflow/ref; the new grouping makes PR intent explicit. +- The v6/v7 changelog described private development history even though public versioning must begin at v1. + +## Resulting workflows + +### CI + +`.github/workflows/ci.yml` runs for pull requests and pushes to `main`, with a manual trigger for maintainers. It has four independently visible jobs: + +- **Quality**: formatting, tidy/diff, module verification, vet, golangci-lint, actionlint, PR-title convention, architecture boundaries, and command documentation. +- **Tests**: race-enabled tests and a coverage job summary. +- **Linux builds**: static builds for Linux `amd64` and `arm64` with injected build information. +- **Vulnerabilities**: pinned `govulncheck` across all packages. + +The split makes every required gate explicit while setup-go caching and concurrency cancellation limit repeat work. Tests are not sharded because this repository is small and repeated Go setup would cost more than it saves. + +### Security + +`.github/workflows/security.yml` runs dependency review on pull requests and CodeQL on pull requests, `main`, a weekly schedule, and manual dispatch. Dependency review does not check out or execute pull-request code. CodeQL receives only the scopes it requires; all other jobs retain `contents: read`. + +### Release + +`.github/workflows/release.yml` runs only from the canonical repository's trusted `main` ref. Release Please uses a short-lived GitHub App installation token to maintain a release PR and create the tag/release after that PR is manually merged. GoReleaser runs only when Release Please reports a newly created release, checks out that exact tag without persisting credentials, and attaches Linux archives plus checksums. + +The default `GITHUB_TOKEN` stays read-only. The release app should be installed only on this repository with **Contents: read/write** and **Pull requests: read/write**. Its private key is stored only as an Actions secret. Fork pull requests cannot invoke the release workflow or access these secrets. + +## Security analysis + +- Every third-party action is pinned to a full commit SHA with a version comment for reviewability. +- There is no `pull_request_target`, privileged checkout of pull-request code, or self-hosted runner assumption. +- Pull-request jobs receive no secrets and only a read-only token. Shell interpolation uses environment variables rather than embedding untrusted PR fields in scripts. +- No pull-request artifact is consumed by a privileged workflow. Release binaries are built from an immutable tag created on protected `main`. +- Go's module cache is managed by `setup-go`. Fork pull requests can restore eligible caches but cannot write repository caches, limiting cache-poisoning risk. +- Release credentials use a short-lived GitHub App token rather than a long-lived PAT. OIDC is unnecessary because no external package registry or cloud provider is used. +- Dependabot keeps action SHAs and Go dependencies reviewable and current. +- License checking in dependency review is deliberately disabled: automated license classification is noisy for this small binary, while vulnerability severity `moderate` or above is enforced. + +## Runtime and cost assessment + +Public repositories using standard GitHub-hosted runners receive free, effectively unlimited standard runner minutes under GitHub's current plan model. Costs can still arise from larger runners, paid storage beyond allowances, external/self-hosted infrastructure, or abuse that violates acceptable-use limits. Plan terms can change and should be checked before enabling larger runners. + +This design uses only standard `ubuntu-latest` runners. It cancels superseded CI/security runs, caches downloaded Go modules, avoids uploading routine artifacts, and keeps scheduled security work weekly. Fast checks remain on every PR because path-skipping them would create ambiguous required checks and documentation/config changes can affect delivery. Release work runs only after a merge and only builds when a release was actually created. + +## Deliberate omissions + +- No spell checker: the repository is small and the false-positive/configuration burden exceeds its current value. +- No generated-file gate: the repository has no committed code generation contract. +- No separate integration matrix: HTTP/service behavior uses deterministic Go tests; live credentials must never run on public PRs. +- No SBOM, provenance, signing, package publication, larger runners, or merge queue at this stage. These add operational cost without a current distribution requirement. diff --git a/docs/CI_MIGRATION.md b/docs/CI_MIGRATION.md new file mode 100644 index 0000000..9ba557a --- /dev/null +++ b/docs/CI_MIGRATION.md @@ -0,0 +1,25 @@ +# CI and release migration + +## What changed + +- The previous combined quality/test/build job became independently required quality, test, Linux build, and vulnerability jobs. +- golangci-lint, actionlint, module verification, architecture/documentation checks, dependency review, and CodeQL are now enforced. +- All actions are pinned to immutable SHAs and workflows use explicit least-privilege permissions. +- Superseded pull-request runs are cancelled; Go dependency caching remains enabled. +- Dependabot now proposes weekly grouped Go and Actions updates. +- Release Please now prepares semantic release pull requests, and GoReleaser attaches Linux archives and checksums after a release is created. +- Public version history starts at `v1.0.0`; private v6/v7 labels were removed from the changelog. +- The repository is now MIT licensed. + +## Maintainer migration checklist + +1. Make the repository public, then enable dependency graph, Dependabot alerts/security updates, secret scanning with push protection, and CodeQL. +2. Create and install the narrowly scoped release GitHub App described in `docs/CI_RELEASES.md`. +3. Add `RELEASE_APP_ID` and `RELEASE_APP_PRIVATE_KEY` as Actions secrets. +4. Merge this infrastructure change and wait for all job context names to appear at least once. +5. Protect `main` with the exact checks and settings documented in `docs/CI_RELEASES.md`. +6. Confirm merge commits and rebase merges are disabled and squash merge is enabled. +7. Merge future feature PRs with Conventional Commit titles. +8. Review and manually merge the first Release Please PR; verify it proposes `v1.0.0` and that both Linux archives and `checksums.txt` appear on the GitHub Release. + +Do not manually create the initial tag before Release Please runs. If the release App is unavailable, leave the generated release PR unmerged until credentials are repaired; do not substitute a maintainer PAT in the workflow. diff --git a/docs/CI_RELEASES.md b/docs/CI_RELEASES.md new file mode 100644 index 0000000..e1a3161 --- /dev/null +++ b/docs/CI_RELEASES.md @@ -0,0 +1,84 @@ +# CI and releases + +## Pull request validation + +Every pull request to `main` runs `CI` and `Security`. The required status contexts are: + +- `CI / Quality` +- `CI / Tests` +- `CI / Linux builds` +- `CI / Vulnerabilities` +- `Security / Dependency review` +- `Security / CodeQL (Go)` + +`Quality` is the fast correctness and maintainability gate. `Tests` catches behavior and data-race regressions. `Linux builds` verifies both supported distribution targets. `Vulnerabilities` detects reachable known Go vulnerabilities. Dependency review blocks newly introduced vulnerable dependencies at moderate severity or higher, while CodeQL supplies semantic security analysis. + +Contributors should run `make check` and the commands below before pushing: + +```bash +go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run --timeout=5m +go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 +go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... +``` + +Pull request titles must use Conventional Commits because squash merging turns that title into the release input. Examples are `fix(config): preserve environment precedence` and `feat!: unify the CLI`. + +## Required repository settings + +Configure a branch ruleset or protection rule for `main` with: + +- Require a pull request before merging, with zero required approvals. +- Dismiss stale approvals if approvals are added later. +- Require all six status checks above and require the branch to be up to date. +- Require conversation resolution and linear history. +- Apply the rule to administrators; disallow force pushes and branch deletion. +- Allow squash merging only. Disable merge commits and rebase merging so the PR title is the one release input. +- Enable auto-merge if desired, but keep Release Please release PRs manually merged. +- Do not require signed commits yet: GitHub squash commits and automation identity make enforcement awkward without materially improving this threat model. +- Do not enable a merge queue yet: the repository's traffic does not justify its operational complexity. + +Repository Actions settings should leave the default workflow token at **read repository contents** and disallow workflows from approving pull requests. Allow only actions used by the checked-in workflows, or require full-length SHA pinning through organization/repository policy where available. + +Dependency graph, Dependabot alerts/security updates, secret scanning with push protection, and CodeQL default capabilities should be enabled when the repository becomes public. + +## Release model + +The project uses SemVer, Conventional Commit squash titles, Release Please, and GoReleaser. + +- `fix` produces a patch release. +- `feat` produces a minor release. +- `!` or a `BREAKING CHANGE` footer produces a major release. +- Documentation, test, CI, and chore changes remain in the release PR but normally do not force a release alone. + +Release Please is preferred over semantic-release because it is GitHub-native, keeps a human-reviewable release PR, and does not introduce a Node release runtime. Changesets is strongest for multi-package repositories and would add manual files to every PR. Pure GoReleaser handles artifacts well but does not replace release-version/changelog management. Release Please plus GoReleaser cleanly separates version policy from binary production. + +The initial manifest is `0.0.0`; the first breaking feature release becomes public `v1.0.0`. Private v6/v7 development labels are intentionally not part of the public release history. + +### Release credentials + +Create a GitHub App installed only on `calmcacil/CalmsToolkit` with repository permissions: + +- Contents: read and write +- Pull requests: read and write +- Metadata: read (implicit) + +Store its App ID as `RELEASE_APP_ID` and its PEM private key as `RELEASE_APP_PRIVATE_KEY` in Actions secrets. Do not expose either as a repository variable or environment variable in pull-request jobs. + +After a normal PR is squash-merged, Release Please opens or updates its release PR. Review and manually squash-merge that PR. The resulting trusted `main` run creates the tag and GitHub Release, then GoReleaser attaches: + +- `calmstoolkit__linux_amd64.tar.gz` +- `calmstoolkit__linux_arm64.tar.gz` +- `checksums.txt` + +There is no package registry publication, OIDC trust, SBOM, or provenance attestation in the initial design. + +## Maintenance + +Dependabot opens weekly grouped updates for Go dependencies and GitHub Actions. Action updates must remain pinned to an immutable SHA and retain a version comment. Review upstream release notes before merging major action changes. + +To test the artifact definition without publishing: + +```bash +go run github.com/goreleaser/goreleaser/v2@v2.12.7 check +go run github.com/goreleaser/goreleaser/v2@v2.12.7 release --snapshot --clean +``` diff --git a/llms.txt b/llms.txt index dc61105..84e47a5 100644 --- a/llms.txt +++ b/llms.txt @@ -13,3 +13,5 @@ Linux Go module providing one `calmstoolkit` executable. - `internal/{streams,calendar,requests,airtime,feed,anisearch}`: feature behavior. Use `make check`. Do not add standalone binaries or package-local console frameworks. See `docs/ARCHITECTURE.md`, `docs/ADDING_A_TOOL.md`, and `docs/user/CLI_SPEC.md`. + +CI requires formatting, module consistency, vet, golangci-lint, race tests, Linux amd64/arm64 builds, govulncheck, dependency review, and CodeQL. Pull-request titles use Conventional Commits and drive Release Please after squash merge. Workflow actions must be full-SHA pinned with least-privilege permissions. See `docs/CI_AUDIT.md`, `docs/CI_RELEASES.md`, and `docs/CI_MIGRATION.md`. From 2be088b7fa12591aba2157b5082ec5f146d074eb Mon Sep 17 00:00:00 2001 From: "Calmcacil B." <716671+calmcacil@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:36:07 +0200 Subject: [PATCH 3/4] ci: secure validation and automate releases --- .github/dependabot.yml | 24 ++++++ .github/workflows/ci.yml | 141 +++++++++++++++++++++++++------ .github/workflows/release.yml | 59 +++++++++++++ .github/workflows/security.yml | 53 ++++++++++++ .golangci.yml | 68 +++++---------- .goreleaser.yaml | 41 +++++++++ .release-please-manifest.json | 3 + internal/airtime/render.go | 10 --- internal/anisearch/client.go | 8 +- internal/anisearch/mapping.go | 4 +- internal/calendar/calendar.go | 39 +++------ internal/calendar/render.go | 2 +- internal/cli/root.go | 8 +- internal/config/config.go | 8 +- internal/datex/datetime.go | 2 +- internal/datex/datetime_test.go | 2 +- internal/feed/client.go | 4 +- internal/httputil/client.go | 8 +- internal/httputil/client_test.go | 8 +- internal/requests/client.go | 32 +++---- internal/streams/render.go | 124 +-------------------------- release-please-config.json | 12 +++ 22 files changed, 385 insertions(+), 275 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/security.yml create mode 100644 .goreleaser.yaml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..35d32c4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + day: monday + time: "05:00" + timezone: Europe/Oslo + groups: + go-minor-and-patch: + update-types: [minor, patch] + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "05:30" + timezone: Europe/Oslo + groups: + actions: + patterns: ["*"] + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5ffcdb..e92bc43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,54 +5,139 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + GO_VERSION: "1.25.x" + jobs: - qc-test-build: - name: Quality, Test & Build + quality: + name: Quality runs-on: ubuntu-latest - timeout-minutes: 10 - + timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.25.0' + go-version: ${{ env.GO_VERSION }} cache: true - - - name: Run gofmt check + - name: Check formatting + shell: bash run: | - if [ -n "$(gofmt -l .)" ]; then - echo "Error: Code is not properly formatted. Run 'go fmt ./...' to fix the formatting." - gofmt -d . + files="$(gofmt -l .)" + if [[ -n "$files" ]]; then + echo "::error::Run 'gofmt -w .' on these files:" + echo "$files" exit 1 fi - - - name: Check go.mod tidiness + - name: Check module consistency run: | go mod tidy - if ! git diff --exit-code go.mod go.sum; then - echo "Error: go.mod or go.sum is not tidy. Run 'go mod tidy' to fix this." + git diff --exit-code -- go.mod go.sum + go mod verify + - name: Run go vet + run: go vet ./... + - name: Run golangci-lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.12.2 + args: --timeout=5m + - name: Validate workflow syntax + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + - name: Validate conventional PR title + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + shell: bash + run: | + pattern='^(feat|fix|perf|refactor|docs|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?!?: .+' + if [[ ! "$PR_TITLE" =~ $pattern ]]; then + echo "::error::PR title must use Conventional Commits, for example 'feat(cli): add command' or 'feat!: breaking change'." exit 1 fi + - name: Enforce architecture boundaries + shell: bash + run: | + mapfile -t mains < <(find cmd -name main.go -type f | sort) + [[ ${#mains[@]} -eq 1 && "${mains[0]}" == cmd/calmstoolkit/main.go ]] + mapfile -t exits < <(rg -l 'os\.Exit' --glob '*.go' | sort) + [[ ${#exits[@]} -eq 1 && "${exits[0]}" == cmd/calmstoolkit/main.go ]] + - name: Verify command documentation + shell: bash + run: | + help="$(go run ./cmd/calmstoolkit --help)" + for command in streams calendar requests airtime feed anime config doctor version; do + grep -Eq "^[[:space:]]+${command}[[:space:]]" <<<"$help" + grep -Fq "calmstoolkit ${command}" README.md + done - - name: Go vet - run: go vet ./... - - - name: Run tests with coverage - run: go test -v -race -count=1 -coverprofile=coverage.out ./... + tests: + name: Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Run race tests with coverage + run: go test -race -count=1 -covermode=atomic -coverprofile=coverage.out ./... + - name: Write coverage summary + shell: bash + run: | + { + echo '## Go coverage' + echo '```text' + go tool cover -func=coverage.out + echo '```' + } >> "$GITHUB_STEP_SUMMARY" - - name: Display coverage summary - run: go tool cover -func=coverage.out | tail -10 + builds: + name: Linux builds + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Build supported Linux targets + shell: bash + run: | + version=ci + commit="${GITHUB_SHA:0:12}" + date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + ldflags="-s -w -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Version=$version -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Commit=$commit -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Date=$date" + for arch in amd64 arm64; do + GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags "$ldflags" -o "/tmp/calmstoolkit-linux-$arch" ./cmd/calmstoolkit + done - - name: Verify build of all cmd tools - run: go build -v ./cmd/... + vulnerabilities: + name: Vulnerabilities + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Run govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3fd4ee8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-main + cancel-in-progress: false + +env: + GO_VERSION: "1.25.x" + +jobs: + release: + name: Release Please + if: github.ref == 'refs/heads/main' && github.repository == 'calmcacil/CalmsToolkit' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Create release GitHub App token + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.1 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Create or update the release pull request + id: release + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.0 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + - name: Check out the released tag + if: steps.release.outputs.release_created == 'true' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ steps.release.outputs.tag_name }} + fetch-depth: 0 + persist-credentials: false + - name: Set up Go + if: steps.release.outputs.release_created == 'true' + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Build and attach release artifacts + if: steps.release.outputs.release_created == 'true' + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + distribution: goreleaser + version: v2.12.7 + args: release --clean + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..a5324cd --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,53 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "17 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + name: Dependency review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: moderate + license-check: false + comment-summary-in-pr: never + + codeql: + name: CodeQL (Go) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + packages: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Initialize CodeQL + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: go + build-mode: autobuild + - name: Analyze + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + category: /language:go diff --git a/.golangci.yml b/.golangci.yml index 1030645..1abb00a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,52 +1,30 @@ +version: "2" + run: timeout: 5m linters: - enable: - - errcheck - - gosimple - - govet - - ineffassign - - staticcheck - - unused - - gocritic - - prealloc - - unconvert - - whitespace - - nolintlint - disable: - - exhaustruct - - forcetypeassert - - gochecknoglobals - - gochecknoinits + default: standard + settings: + errcheck: + check-type-assertions: true + check-blank: false + exclusions: + rules: + # Test fixtures intentionally ignore writes to in-memory/httptest helpers. + - path: _test\.go + linters: [errcheck] + # Terminal views are best-effort until rendering is fully writer-returning. + - path: internal/(airtime|anisearch|calendar|feed|requests|streams)/(render|input|menu)\.go + linters: [errcheck] + - path: internal/anisearch/search\.go + linters: [errcheck] + - path: internal/(buildinfo|cli)/.*\.go + linters: [errcheck] -linters-settings: - errcheck: - check-type-assertions: true - check-blank: false - gocritic: - enabled-tags: - - performance - - diagnostic - - experimental - disabled-checks: - - paramTypeCombine - staticcheck: - checks: - - all - - -SA1019 - prealloc: - simple: true - range-loops: true - for-loops: true +formatters: + enable: [gofmt] issues: - exclude-rules: - - path: _test\.go - linters: - - errcheck - - gocritic - - path: internal/colors/colors.go - text: "WhyNoLint" - linters: - - staticcheck + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..9fb0f19 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,41 @@ +version: 2 + +project_name: calmstoolkit + +builds: + - id: calmstoolkit + main: ./cmd/calmstoolkit + binary: calmstoolkit + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + flags: + - -trimpath + ldflags: + - >- + -s -w + -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Version={{ .Version }} + -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Commit={{ .Commit }} + -X github.com/calmcacil/CalmsToolkit/internal/buildinfo.Date={{ .Date }} + +archives: + - id: linux + ids: + - calmstoolkit + formats: + - tar.gz + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + +checksum: + name_template: checksums.txt + +changelog: + disable: true + +release: + mode: append diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/internal/airtime/render.go b/internal/airtime/render.go index 88a7070..14c17ec 100644 --- a/internal/airtime/render.go +++ b/internal/airtime/render.go @@ -383,16 +383,6 @@ func terminalWidth() int { return w } -func truncate(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} - func truncateVis(s string, maxLen int) string { visLen := colors.VisibleLen(s) if visLen <= maxLen { diff --git a/internal/anisearch/client.go b/internal/anisearch/client.go index f63fffb..4dbb82b 100644 --- a/internal/anisearch/client.go +++ b/internal/anisearch/client.go @@ -269,7 +269,7 @@ func (c *AniListClient) doRequest(ctx context.Context, payload []byte, dst any) c.lastRateLimit = time.Now() c.rateLimitMu.Unlock() retryAfter := resp.Header.Get("Retry-After") - resp.Body.Close() + _ = resp.Body.Close() if retryAfter != "" { if sec, err := strconv.Atoi(retryAfter); err == nil && sec > 0 { slog.Warn("rate limited, waiting retry-after", "seconds", sec, "attempt", attempt+1) @@ -286,7 +286,7 @@ func (c *AniListClient) doRequest(ctx context.Context, payload []byte, dst any) if resp.StatusCode != http.StatusOK { respBody, readErr := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if readErr != nil { lastErr = fmt.Errorf("API error (HTTP %d): failed to read body: %w", resp.StatusCode, readErr) } else { @@ -300,11 +300,11 @@ func (c *AniListClient) doRequest(ctx context.Context, payload []byte, dst any) } if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { - resp.Body.Close() + _ = resp.Body.Close() lastErr = fmt.Errorf("decode response: %w", err) break } - resp.Body.Close() + _ = resp.Body.Close() return nil } diff --git a/internal/anisearch/mapping.go b/internal/anisearch/mapping.go index 98a0416..0d8ee9a 100644 --- a/internal/anisearch/mapping.go +++ b/internal/anisearch/mapping.go @@ -134,7 +134,7 @@ func headMapping(ctx context.Context, url string) (mappingMetadata, error) { if err != nil { return mappingMetadata{}, fmt.Errorf("HEAD failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return mappingMetadata{}, fmt.Errorf("HEAD returned HTTP %d", resp.StatusCode) @@ -161,7 +161,7 @@ func fetchMapping(ctx context.Context, url string) ([]byte, mappingMetadata, err if err != nil { return nil, mappingMetadata{}, fmt.Errorf("GET failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, mappingMetadata{}, fmt.Errorf("GET returned HTTP %d", resp.StatusCode) diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 50c1a76..6c84b9b 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -124,24 +124,6 @@ type Summary struct { Items []CalendarItem `json:"items"` } -type fetchEpisodeResult struct { - Instance string - Episodes []SonarrEpisode - Err error -} - -type fetchMovieResult struct { - Instance string - Movies []RadarrMovie - Err error -} - -type fetchQueueResult struct { - Instance string - Queue *QueueResponse - Err error -} - // BuildToolConfig constructs a ToolConfig from the global toolkit configuration. func BuildToolConfig(tk *config.ToolkitConfig) ToolConfig { cfg := ToolConfig{ @@ -322,7 +304,7 @@ func aggregateCalendarDetailed(ctx context.Context, cfg ToolConfig) ([]CalendarI func fetchSonarrInstance(ctx context.Context, client *httputil.Client, inst config.ArrInstance, start, end time.Time, debug bool, mu, qMu *sync.Mutex, items *[]CalendarItem, queueIssues *[]QueueIssue, seen map[string]bool) error { episodes, err := fetchSonarrCalendar(ctx, client, inst, start, end, debug) if err != nil { - return fmt.Errorf("Sonarr %s: %w", inst.Name, err) + return fmt.Errorf("sonarr %s: %w", inst.Name, err) } mu.Lock() @@ -383,7 +365,7 @@ func fetchSonarrInstance(ctx context.Context, client *httputil.Client, inst conf func fetchRadarrInstance(ctx context.Context, client *httputil.Client, inst config.ArrInstance, start, end time.Time, debug bool, mu, qMu *sync.Mutex, items *[]CalendarItem, queueIssues *[]QueueIssue, seen map[string]bool) error { movies, err := fetchRadarrCalendar(ctx, client, inst, start, end, debug) if err != nil { - return fmt.Errorf("Radarr %s: %w", inst.Name, err) + return fmt.Errorf("radarr %s: %w", inst.Name, err) } mu.Lock() @@ -395,14 +377,15 @@ func fetchRadarrInstance(ctx context.Context, client *httputil.Client, inst conf seen[key] = true var releaseTime time.Time - if movie.DigitalDate != "" { - releaseTime, err = parseDateFlexible(movie.DigitalDate) - } - if releaseTime.IsZero() && movie.ReleaseDate != "" { - releaseTime, err = parseDateFlexible(movie.ReleaseDate) - } - if releaseTime.IsZero() && movie.InCinemas != "" { - releaseTime, err = parseDateFlexible(movie.InCinemas) + for _, candidate := range []string{movie.DigitalDate, movie.ReleaseDate, movie.InCinemas} { + if candidate == "" { + continue + } + parsed, parseErr := parseDateFlexible(candidate) + if parseErr == nil { + releaseTime = parsed + break + } } if releaseTime.IsZero() { if debug { diff --git a/internal/calendar/render.go b/internal/calendar/render.go index d266955..fd89ac9 100644 --- a/internal/calendar/render.go +++ b/internal/calendar/render.go @@ -110,7 +110,7 @@ func runWithSubagents(ctx context.Context, cfg ToolConfig, p *colors.Palette) er go dataAgent(ctx, cfg, dataCh) var ( - termWidth int = 80 + termWidth = 80 items []CalendarItem issues []QueueIssue ) diff --git a/internal/cli/root.go b/internal/cli/root.go index 4e1826c..dc183e9 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -188,10 +188,10 @@ func newStreamsCommand(rt *app.Runtime) *cobra.Command { return app.Error(app.ExitUsage, fmt.Errorf("invalid --server %q", cfg.ServerType)) } if (cfg.ServerType == "plex" || cfg.ServerType == "both") && cfg.PlexToken == "" { - return app.Error(app.ExitUsage, errors.New("Plex token is required")) + return app.Error(app.ExitUsage, errors.New("plex token is required")) } if (cfg.ServerType == "jellyfin" || cfg.ServerType == "both") && cfg.JellyfinToken == "" { - return app.Error(app.ExitUsage, errors.New("Jellyfin token is required")) + return app.Error(app.ExitUsage, errors.New("jellyfin token is required")) } return streams.Run(rt.Context, cfg) }} @@ -578,14 +578,14 @@ func runDoctor(rt *app.Runtime) []doctorCheck { resp, err := rt.HTTPClient(rt.Timeout).Do(req) cancel() ok := err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 - detail := "reachable" + var detail string if err != nil { detail = err.Error() } else { detail = resp.Status } if resp != nil { - resp.Body.Close() + _ = resp.Body.Close() } checks = append(checks, doctorCheck{Name: e.name, OK: ok, Detail: detail}) } diff --git a/internal/config/config.go b/internal/config/config.go index 232b461..56048ca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -431,17 +431,17 @@ func (c *ToolkitConfig) SaveAt(explicitPath string) error { return fmt.Errorf("creating temporary config: %w", err) } tmpPath := tmp.Name() - defer os.Remove(tmpPath) + defer func() { _ = os.Remove(tmpPath) }() if err := tmp.Chmod(0600); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("setting temporary config permissions: %w", err) } if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("writing config: %w", err) } if err := tmp.Sync(); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("syncing config: %w", err) } if err := tmp.Close(); err != nil { diff --git a/internal/datex/datetime.go b/internal/datex/datetime.go index 8c4a235..8132fa0 100644 --- a/internal/datex/datetime.go +++ b/internal/datex/datetime.go @@ -34,7 +34,7 @@ func (d *DateTime) UnmarshalJSON(data []byte) error { } func (d DateTime) MarshalJSON() ([]byte, error) { - if d.Time.IsZero() { + if d.IsZero() { return []byte("null"), nil } return []byte(`"` + d.Time.UTC().Format(time.RFC3339) + `"`), nil diff --git a/internal/datex/datetime_test.go b/internal/datex/datetime_test.go index 24c439e..cdf25cf 100644 --- a/internal/datex/datetime_test.go +++ b/internal/datex/datetime_test.go @@ -73,7 +73,7 @@ func TestMarshalJSON_RoundTrip(t *testing.T) { if err := json.Unmarshal(data, &round); err != nil { t.Fatal(err) } - if !round.Time.Equal(orig.Time) { + if !round.Equal(orig.Time) { t.Errorf("round trip: got %v, want %v", round.Time, orig.Time) } } diff --git a/internal/feed/client.go b/internal/feed/client.go index 331b6c0..5a3c79c 100644 --- a/internal/feed/client.go +++ b/internal/feed/client.go @@ -29,7 +29,7 @@ func fetchAllHistoryDetailed(ctx context.Context, client *httputil.Client, cfg T defer wg.Done() events, err := fetchSonarrHistory(ctx, client, inst, since, cfg.ShowSubtitles) if err != nil { - errorsChan <- fmt.Errorf("Sonarr %s: %v", inst.Name, err) + errorsChan <- fmt.Errorf("sonarr %s: %v", inst.Name, err) return } eventsChan <- events @@ -42,7 +42,7 @@ func fetchAllHistoryDetailed(ctx context.Context, client *httputil.Client, cfg T defer wg.Done() events, err := fetchRadarrHistory(ctx, client, inst, since, cfg.ShowSubtitles) if err != nil { - errorsChan <- fmt.Errorf("Radarr %s: %v", inst.Name, err) + errorsChan <- fmt.Errorf("radarr %s: %v", inst.Name, err) return } eventsChan <- events diff --git a/internal/httputil/client.go b/internal/httputil/client.go index 1fc5ce3..0975216 100644 --- a/internal/httputil/client.go +++ b/internal/httputil/client.go @@ -46,7 +46,11 @@ func NewClient(timeout time.Duration) *Client { // NewTransportClient creates a new Client with a tuned transport (connection pooling). func NewTransportClient(timeout time.Duration) *Client { - transport := http.DefaultTransport.(*http.Transport).Clone() + baseTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + baseTransport = &http.Transport{Proxy: http.ProxyFromEnvironment} + } + transport := baseTransport.Clone() transport.MaxIdleConns = 20 transport.IdleConnTimeout = 30 * time.Second return &Client{ @@ -111,7 +115,7 @@ func (c *Client) doRequest(ctx context.Context, method, requestURL string, heade if err != nil { return nil, 0, nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() maxSize := c.MaxBodySize if maxSize <= 0 { maxSize = 10 * 1024 * 1024 diff --git a/internal/httputil/client_test.go b/internal/httputil/client_test.go index e9f599f..69c1df5 100644 --- a/internal/httputil/client_test.go +++ b/internal/httputil/client_test.go @@ -59,15 +59,15 @@ func TestResponseErrorRedactsSecrets(t *testing.T) { func TestNewClient(t *testing.T) { c := NewClient(5 * time.Second) - if c.Client.Timeout != 5*time.Second { - t.Errorf("Timeout = %v, want 5s", c.Client.Timeout) + if c.Timeout != 5*time.Second { + t.Errorf("Timeout = %v, want 5s", c.Timeout) } } func TestNewTransportClient(t *testing.T) { c := NewTransportClient(10 * time.Second) - if c.Client.Timeout != 10*time.Second { - t.Errorf("Timeout = %v, want 10s", c.Client.Timeout) + if c.Timeout != 10*time.Second { + t.Errorf("Timeout = %v, want 10s", c.Timeout) } } diff --git a/internal/requests/client.go b/internal/requests/client.go index 3b2f6f0..3cd88e7 100644 --- a/internal/requests/client.go +++ b/internal/requests/client.go @@ -28,7 +28,7 @@ func fetchServiceInstances(ctx context.Context, cfg ToolConfig, service string) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch %s servers: status %d", service, resp.StatusCode) @@ -52,7 +52,7 @@ func fetchServiceDetails(ctx context.Context, cfg ToolConfig, service string, id if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch %s details: status %d", service, resp.StatusCode) @@ -106,7 +106,7 @@ func searchMedia(ctx context.Context, cfg ToolConfig, query string) ([]SearchRes if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, _ := readBodyLimited(resp.Body) @@ -127,7 +127,7 @@ func getTVDetails(ctx context.Context, cfg ToolConfig, tmdbID int) (*TVDetails, if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to get TV details: status %d", resp.StatusCode) @@ -164,7 +164,7 @@ func createRequest(ctx context.Context, cfg ToolConfig, media SearchResult, seas if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { bodyBytes, _ := readBodyLimited(resp.Body) @@ -184,7 +184,7 @@ func checkUserPermissions(ctx context.Context, cfg ToolConfig) (*AuthMe, error) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, _ := readBodyLimited(resp.Body) @@ -204,7 +204,7 @@ func getRequestCount(ctx context.Context, cfg ToolConfig) (*RequestCount, error) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, _ := readBodyLimited(resp.Body) @@ -283,16 +283,16 @@ func getPendingRequests(ctx context.Context, cfg ToolConfig) ([]MediaRequest, er if resp.StatusCode != http.StatusOK { bodyBytes, _ := readBodyLimited(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() return nil, fmt.Errorf("failed to get requests: status %d - %s", resp.StatusCode, string(bodyBytes)) } var reqResp RequestsResponse if err := json.NewDecoder(resp.Body).Decode(&reqResp); err != nil { - resp.Body.Close() + _ = resp.Body.Close() return nil, err } - resp.Body.Close() + _ = resp.Body.Close() if cfg.Verbose { fmt.Fprintf(os.Stderr, "Page %d: Got %d results (total: %d)\n", @@ -341,16 +341,16 @@ func getPendingRequests(ctx context.Context, cfg ToolConfig) ([]MediaRequest, er if resp.StatusCode != http.StatusOK { bodyBytes, _ := readBodyLimited(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() return nil, fmt.Errorf("fallback fetch failed: status %d - %s", resp.StatusCode, string(bodyBytes)) } var reqResp RequestsResponse if err := json.NewDecoder(resp.Body).Decode(&reqResp); err != nil { - resp.Body.Close() + _ = resp.Body.Close() return nil, fmt.Errorf("fallback decode failed: %w", err) } - resp.Body.Close() + _ = resp.Body.Close() if cfg.Verbose { fmt.Fprintf(os.Stderr, "Fallback page %d: Got %d results (total: %d)\n", @@ -398,7 +398,7 @@ func approveRequest(ctx context.Context, cfg ToolConfig, requestID int) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, _ := readBodyLimited(resp.Body) @@ -423,7 +423,7 @@ func approveRequestWithOverrides(ctx context.Context, cfg ToolConfig, requestID if err != nil { return fmt.Errorf("failed to set request overrides before approval: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, _ := readBodyLimited(resp.Body) @@ -444,7 +444,7 @@ func declineRequest(ctx context.Context, cfg ToolConfig, requestID int) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, _ := readBodyLimited(resp.Body) diff --git a/internal/streams/render.go b/internal/streams/render.go index f649bc6..7fb7426 100644 --- a/internal/streams/render.go +++ b/internal/streams/render.go @@ -155,7 +155,7 @@ func displayTerminalOutput(streams []StreamInfo, plexCount, jellyfinCount int, n // Header serverLabel := buildServerLabel(plexCount, jellyfinCount) - header := "Active Sessions" + var header string if len(streams) == 0 { header = "Active Sessions: 0" } else { @@ -301,128 +301,6 @@ func displayTerminalOutputWithHistory(currentStreams []StreamInfo, history *Sess return nil } -func displayStreamToBox(bw *bufio.Writer, stream StreamInfo, boxW int, noColor bool, p *colors.Palette) { - clr := colors.ClrFunc(noColor) - - serverColor := p.ServerJellyfin - if stream.Server == "plex" { - serverColor = p.ServerPlex - } - - // Server + User line - line := fmt.Sprintf(" %s%s%s %sUser%s: %s%s%s", - clr(serverColor), strings.ToUpper(stream.Server), clr(p.Reset), - clr(p.Bold), clr(p.Reset), - clr(p.ServerPlex), stream.User, clr(p.Reset)) - fmt.Fprint(bw, "│") - fmt.Fprint(bw, colors.PadRight(line, boxW)) - fmt.Fprint(bw, "│\n") - - // Show/Title line - if stream.Type == "episode" && stream.Show != "" { - line := fmt.Sprintf(" %sShow%s: %s", clr(p.Bold), clr(p.Reset), stream.Show) - fmt.Fprint(bw, "│") - fmt.Fprint(bw, colors.PadRight(line, boxW)) - fmt.Fprint(bw, "│\n") - if stream.Season != "" || stream.Episode != "" { - epStr := "" - if stream.Season != "" { - epStr += fmt.Sprintf("S%02s", stream.Season) - } - if stream.Episode != "" { - epStr += fmt.Sprintf("E%02s", stream.Episode) - } - line := fmt.Sprintf(" %s - %s", epStr, stream.Title) - fmt.Fprint(bw, "│") - fmt.Fprint(bw, colors.PadRight(line, boxW)) - fmt.Fprint(bw, "│\n") - } - } else { - line := fmt.Sprintf(" %sTitle%s: %s", clr(p.Bold), clr(p.Reset), stream.Title) - if stream.Year != "" { - line += fmt.Sprintf(" %s(%s)%s", clr(p.Accent), stream.Year, clr(p.Reset)) - } - fmt.Fprint(bw, "│") - fmt.Fprint(bw, colors.PadRight(line, boxW)) - fmt.Fprint(bw, "│\n") - } - - // Client line - if stream.Client != "" { - var clientLine string - if stream.Device != "" { - clientLine = fmt.Sprintf(" %sClient%s: %s (%s)", clr(p.Bold), clr(p.Reset), stream.Client, stream.Device) - } else { - clientLine = fmt.Sprintf(" %sClient%s: %s", clr(p.Bold), clr(p.Reset), stream.Client) - } - fmt.Fprint(bw, "│") - fmt.Fprint(bw, colors.PadRight(clientLine, boxW)) - fmt.Fprint(bw, "│\n") - } - - // Status line - statusColor := p.Success - if stream.Transcoding { - statusColor = p.Error - } - statusText := stream.Status - if stream.IsPaused { - statusText += " (Paused)" - } - line = fmt.Sprintf(" %sStatus%s: %s%s%s", clr(p.Bold), clr(p.Reset), clr(statusColor), statusText, clr(p.Reset)) - if stream.Transcoding { - line += " !" - } - fmt.Fprint(bw, "│") - fmt.Fprint(bw, colors.PadRight(line, boxW)) - fmt.Fprint(bw, "│\n") - - // Bandwidth line - if stream.Bandwidth > 0 { - line := fmt.Sprintf(" %sBandwidth%s: %s%.2f Mbps%s", clr(p.Bold), clr(p.Reset), clr(p.Bandwidth), stream.Bandwidth, clr(p.Reset)) - fmt.Fprint(bw, "│") - fmt.Fprint(bw, colors.PadRight(line, boxW)) - fmt.Fprint(bw, "│\n") - } - - // Quality line - if stream.Resolution != "" || stream.VideoCodec != "" { - line := fmt.Sprintf(" %sQuality%s: ", clr(p.Bold), clr(p.Reset)) - if stream.Resolution != "" { - line += fmt.Sprintf("%s ", stream.Resolution) - } - if stream.VideoCodec != "" { - line += fmt.Sprintf("(%s", stream.VideoCodec) - if stream.AudioCodec != "" { - line += fmt.Sprintf("/%s", stream.AudioCodec) - } - line += ")" - } - fmt.Fprint(bw, "│") - fmt.Fprint(bw, colors.PadRight(line, boxW)) - fmt.Fprint(bw, "│\n") - } - - // Progress bar - if stream.Progress > 0 { - barW := boxW - 18 - if barW > 30 { - barW = 30 - } - if barW < 10 { - barW = 10 - } - bar := renderProgressBar(stream.Progress, barW) - line := fmt.Sprintf(" %sPlayback%s: %s %s%5.1f%%%s", - clr(p.Bold), clr(p.Reset), - bar, - clr(p.Accent), stream.Progress, clr(p.Reset)) - fmt.Fprint(bw, "│") - fmt.Fprint(bw, colors.PadRight(line, boxW)) - fmt.Fprint(bw, "│\n") - } -} - // streamContentLines returns the padded interior content of a stream box // (without border characters), one element per line. func streamContentLines(stream StreamInfo, boxW int, noColor bool, p *colors.Palette) []string { diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..af72c25 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "go", + "package-name": "calmstoolkit", + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false, + "initial-version": "1.0.0" + } + } +} From c35c2c83f612c5c6ab756fa91f4208b34e96a72f Mon Sep 17 00:00:00 2001 From: "Calmcacil B." <716671+calmcacil@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:40:29 +0200 Subject: [PATCH 4/4] ci: pin the patched Go toolchain --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e92bc43..56315c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.25.x" + GO_VERSION: "1.25.12" jobs: quality: @@ -69,7 +69,7 @@ jobs: run: | mapfile -t mains < <(find cmd -name main.go -type f | sort) [[ ${#mains[@]} -eq 1 && "${mains[0]}" == cmd/calmstoolkit/main.go ]] - mapfile -t exits < <(rg -l 'os\.Exit' --glob '*.go' | sort) + mapfile -t exits < <(grep -RIl --include='*.go' --exclude-dir=.git 'os\.Exit' . | sed 's#^\./##' | sort) [[ ${#exits[@]} -eq 1 && "${exits[0]}" == cmd/calmstoolkit/main.go ]] - name: Verify command documentation shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fd4ee8..b639645 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ concurrency: cancel-in-progress: false env: - GO_VERSION: "1.25.x" + GO_VERSION: "1.25.12" jobs: release: