diff --git a/CLAUDE.md b/CLAUDE.md index 785287a..e5d250f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,10 +67,9 @@ Follow this pattern (see `cmd/status/system.go` as the canonical example): 1. **Look up** the endpoint in `open-api.yaml` and the corresponding service in `pkg/api/api_*.go`. 2. **Write a failing test** in `tests/__test.go` using `httptest.NewServer()`. 3. **Implement** the command in `cmd//.go`: - - Build `api.NewConfiguration()`, set `configuration.Servers` to `viper.GetString("server") + "/api/v1"`. - - Add `X-Api-Key` header via `configuration.AddDefaultHeader`. - - Call the appropriate `apiClient..(ctx).Execute()`. - - Print JSON with `cmd.Println(string(jsonRes))` — never use `fmt` (breaks test output capture). + - Use `seerrclient.New()` (from `internal/seerrclient`) to build the API client — never call `api.NewConfiguration()` directly. + - Call the appropriate method on the client (e.g. `MovieGet(id, lang)`) or `sc.Unwrap()..` for endpoints without a typed wrapper. + - Print output via `apiutil.PrintOutput(cmd, res, mode)` or `apiutil.HandleResponse` — never use `fmt` (breaks test output capture). - Respect `viper.GetBool("verbose")` for progress/URL/status output. 4. Register via `Cmd.AddCommand(...)` in the file's `init()`. @@ -90,6 +89,12 @@ Global flags (`--server`, `--api-key`, `--verbose`) are bound to Viper keys `ser - **Normal mode**: Raw pretty-printed JSON only (piping to `jq` must work). - **Verbose mode**: Include progress messages, target URL, HTTP status code before the JSON. +### Output Conventions + +Every command returning structured data must accept `--output`/`-o` with values `json` (default), `yaml`, `table`. Register the flag with `apiutil.AddOutputFlag(cmd)` in `init()` and read the mode with `apiutil.GetOutputMode(cmd)`. Commands returning deeply nested objects may fall back to YAML for `table` mode. Use `apiutil.PrintOutput(cmd, res, mode)` for typed structs and `apiutil.PrintRawOutput(cmd, data, mode)` for raw JSON bytes. + +**Note on test isolation**: Cobra flag values persist across `Execute()` calls in the same test process. Tests that set a non-default `--output` value must reset it in `t.Cleanup` using `resetOutputFlag([]string{"cmd", "sub"})` to avoid breaking subsequent tests that omit the flag. + ### Claude Usage Rules - Never add `Co-Authored-By`, `Generated with`, or any mention of Claude or Anthropic in commit messages or PR descriptions. diff --git a/cmd/apiutil/output.go b/cmd/apiutil/output.go new file mode 100644 index 0000000..6d8a7c1 --- /dev/null +++ b/cmd/apiutil/output.go @@ -0,0 +1,167 @@ +package apiutil + +import ( + "encoding/json" + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// OutputMode represents the --output/-o flag value. +type OutputMode string + +const ( + // OutputJSON is the default output format: pretty-printed JSON. + OutputJSON OutputMode = "json" + // OutputYAML serialises the response as YAML. + OutputYAML OutputMode = "yaml" + // OutputTable renders results in a tab-separated table. + OutputTable OutputMode = "table" +) + +// AddOutputFlag registers the --output/-o flag on cmd. +func AddOutputFlag(cmd *cobra.Command) { + cmd.Flags().StringP("output", "o", "json", "Output format: json, yaml, table") +} + +// GetOutputMode reads the --output flag from cmd and returns the corresponding +// OutputMode. Defaults to OutputJSON for unknown values. +func GetOutputMode(cmd *cobra.Command) OutputMode { + mode, _ := cmd.Flags().GetString("output") + switch OutputMode(mode) { + case OutputYAML: + return OutputYAML + case OutputTable: + return OutputTable + default: + return OutputJSON + } +} + +// PrintOutput serialises v according to mode and writes it to cmd's output. +func PrintOutput(cmd *cobra.Command, v interface{}, mode OutputMode) error { + switch mode { + case OutputYAML: + out, err := yaml.Marshal(v) + if err != nil { + return fmt.Errorf("failed to marshal as YAML: %w", err) + } + cmd.Print(string(out)) + return nil + case OutputTable: + return printTable(cmd, v) + default: + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal as JSON: %w", err) + } + cmd.Println(string(out)) + return nil + } +} + +// PrintRawOutput parses data as JSON then re-renders it according to mode. +func PrintRawOutput(cmd *cobra.Command, data []byte, mode OutputMode) error { + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + return PrintOutput(cmd, v, mode) +} + +// printTable renders v in a tab-separated table. When v contains a "results" +// key, each element of that slice becomes a row. For other objects the key/value +// pairs are printed as a two-column table. Falls back to YAML for complex nested +// structures that cannot be rendered as a flat table. +func printTable(cmd *cobra.Command, v interface{}) error { + // Normalise to map via JSON round-trip. + raw, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("failed to serialise for table: %w", err) + } + + var top map[string]interface{} + if err := json.Unmarshal(raw, &top); err != nil { + // Not a JSON object — fall back to YAML. + return PrintOutput(cmd, v, OutputYAML) + } + + // If the response wraps a results array, render that. + if results, ok := top["results"].([]interface{}); ok { + return renderResultsTable(cmd, results) + } + + // Otherwise render the object itself as key→value rows. + return renderObjectTable(cmd, top) +} + +// renderResultsTable prints a table where each row is one element from results. +func renderResultsTable(cmd *cobra.Command, results []interface{}) error { + if len(results) == 0 { + cmd.Println("(no results)") + return nil + } + + // Collect a stable column order from the first element. + first, ok := results[0].(map[string]interface{}) + if !ok { + return PrintOutput(cmd, results, OutputYAML) + } + cols := tableColumns(first) + + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, strings.Join(cols, "\t")) + for _, item := range results { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + vals := make([]string, len(cols)) + for i, col := range cols { + vals[i] = fmt.Sprintf("%v", row[col]) + } + fmt.Fprintln(tw, strings.Join(vals, "\t")) + } + return tw.Flush() +} + +// renderObjectTable prints a two-column key/value table for a single object. +func renderObjectTable(cmd *cobra.Command, obj map[string]interface{}) error { + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "KEY\tVALUE") + for k, v := range obj { + fmt.Fprintf(tw, "%s\t%v\n", k, v) + } + return tw.Flush() +} + +// tableColumns returns a deterministic column order for a result row. The ID +// column always comes first when present; then mediaType; then title/name; +// then all remaining string/number fields in alphabetical order. +func tableColumns(row map[string]interface{}) []string { + priority := []string{"id", "mediaType", "title", "name"} + seen := map[string]bool{} + var cols []string + + for _, key := range priority { + if _, ok := row[key]; ok { + cols = append(cols, key) + seen[key] = true + } + } + // Add remaining scalar fields (skip nested objects/arrays). + for k, v := range row { + if seen[k] { + continue + } + switch v.(type) { + case map[string]interface{}, []interface{}: + continue + } + cols = append(cols, k) + } + return cols +} diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go new file mode 100644 index 0000000..7094b62 --- /dev/null +++ b/cmd/docs/docs.go @@ -0,0 +1,16 @@ +// Package docs provides commands for generating CLI reference documentation. +package docs + +import ( + "github.com/spf13/cobra" +) + +// Cmd is the parent command for documentation-related subcommands. +var Cmd = &cobra.Command{ + Use: "docs", + Short: "Generate CLI reference documentation", +} + +func init() { + // Subcommands are added in their respective files' init() functions. +} diff --git a/cmd/docs/generate.go b/cmd/docs/generate.go new file mode 100644 index 0000000..bac3ca7 --- /dev/null +++ b/cmd/docs/generate.go @@ -0,0 +1,36 @@ +package docs + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" +) + +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate CLI reference documentation as Markdown", + Example: ` # Generate docs into the default directory + seerr-cli docs generate + + # Generate docs into a custom directory + seerr-cli docs generate --output-dir /tmp/cli-docs`, + RunE: func(cmd *cobra.Command, args []string) error { + dir, _ := cmd.Flags().GetString("output-dir") + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", dir, err) + } + root := cmd.Root() + if err := doc.GenMarkdownTree(root, dir); err != nil { + return fmt.Errorf("failed to generate docs: %w", err) + } + cmd.Printf("Documentation written to %s\n", dir) + return nil + }, +} + +func init() { + generateCmd.Flags().String("output-dir", "./docs/cli/", "Directory to write generated Markdown files") + Cmd.AddCommand(generateCmd) +} diff --git a/cmd/doctor/check.go b/cmd/doctor/check.go new file mode 100644 index 0000000..fa9261b --- /dev/null +++ b/cmd/doctor/check.go @@ -0,0 +1,202 @@ +package doctor + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "seerr-cli/cmd/apiutil" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// CheckResult holds the outcome of a single doctor check. +type CheckResult struct { + Name string `json:"name"` + Status string `json:"status"` // "ok", "fail", "info", "skip" + Message string `json:"message"` +} + +const doctorHTTPTimeout = 5 * time.Second + +// Run executes all doctor checks against serverURL using the provided API key. +// Checks are short-circuited when the server is unreachable so that subsequent +// checks are marked as "skip" rather than reporting misleading errors. +func Run(serverURL, apiKey string) []CheckResult { + var results []CheckResult + + // 1 — Server configured. + if serverURL == "" { + results = append(results, CheckResult{ + Name: "server_configured", + Status: "fail", + Message: "server URL is not set (run: seerr-cli config set server )", + }) + return skipRemaining(results, []string{"server_reachable", "api_key_configured", "api_key_valid", "server_version"}) + } + results = append(results, CheckResult{ + Name: "server_configured", + Status: "ok", + Message: serverURL, + }) + + // 2 — Server reachable (no auth header). + statusPath := strings.TrimRight(serverURL, "/") + "/api/v1/status" + body, statusCode, err := doGet(statusPath, "") + if err != nil || statusCode >= 500 { + msg := fmt.Sprintf("GET %s failed", statusPath) + if err != nil { + msg = err.Error() + } + results = append(results, CheckResult{Name: "server_reachable", Status: "fail", Message: msg}) + return skipRemaining(results, []string{"api_key_configured", "api_key_valid", "server_version"}) + } + results = append(results, CheckResult{ + Name: "server_reachable", + Status: "ok", + Message: fmt.Sprintf("HTTP %d", statusCode), + }) + + // 3 — API key configured. + if apiKey == "" { + results = append(results, CheckResult{ + Name: "api_key_configured", + Status: "fail", + Message: "API key is not set (run: seerr-cli config set api-key )", + }) + return skipRemaining(results, []string{"api_key_valid", "server_version"}) + } + results = append(results, CheckResult{ + Name: "api_key_configured", + Status: "ok", + Message: maskKey(apiKey), + }) + + // 4 — API key valid (authenticated request). + _, authStatusCode, authErr := doGet(statusPath, apiKey) + if authErr != nil || authStatusCode >= 400 { + msg := fmt.Sprintf("HTTP %d", authStatusCode) + if authErr != nil { + msg = authErr.Error() + } + results = append(results, CheckResult{Name: "api_key_valid", Status: "fail", Message: msg}) + return skipRemaining(results, []string{"server_version"}) + } + results = append(results, CheckResult{ + Name: "api_key_valid", + Status: "ok", + Message: fmt.Sprintf("HTTP %d", authStatusCode), + }) + + // 5 — Server version (informational). + version := parseVersion(body) + results = append(results, CheckResult{ + Name: "server_version", + Status: "info", + Message: version, + }) + + return results +} + +// doGet performs an HTTP GET with a short timeout. Returns the body bytes, +// HTTP status code, and any transport-level error. +func doGet(url, apiKey string) ([]byte, int, error) { + client := &http.Client{Timeout: doctorHTTPTimeout} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, 0, err + } + if apiKey != "" { + req.Header.Set("X-Api-Key", apiKey) + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return body, resp.StatusCode, nil +} + +// parseVersion extracts the "version" field from a JSON body. +func parseVersion(body []byte) string { + var v map[string]interface{} + if err := json.Unmarshal(body, &v); err != nil { + return "unknown" + } + if ver, ok := v["version"].(string); ok && ver != "" { + return ver + } + return "unknown" +} + +// maskKey shows only the last four characters of an API key. +func maskKey(key string) string { + if len(key) <= 4 { + return strings.Repeat("*", len(key)) + } + return strings.Repeat("*", len(key)-4) + key[len(key)-4:] +} + +// skipRemaining appends skip results for any check names not yet in results. +func skipRemaining(results []CheckResult, names []string) []CheckResult { + for _, name := range names { + results = append(results, CheckResult{ + Name: name, + Status: "skip", + Message: "skipped due to earlier failure", + }) + } + return results +} + +var checkCmd = &cobra.Command{ + Use: "check", + Short: "Run all doctor checks", + // This command is also the default action of the doctor parent command. + Hidden: true, +} + +// runChecks runs the checks and prints results according to --output mode. +func runChecks(cmd *cobra.Command, _ []string) error { + serverURL := viper.GetString("seerr.server") + apiKey := viper.GetString("seerr.api_key") + + results := Run(serverURL, apiKey) + + outputFlag, _ := cmd.Flags().GetString("output") + if outputFlag == "json" { + b, err := json.MarshalIndent(results, "", " ") + if err != nil { + return err + } + cmd.Println(string(b)) + return nil + } + + // Human-readable output. + for _, r := range results { + tag := fmt.Sprintf("[%s]", r.Status) + cmd.Printf("%-8s %-22s %s\n", tag, r.Name, r.Message) + } + return nil +} + +func init() { + Cmd.Flags().String("output", "text", "Output format: text, json") + Cmd.RunE = runChecks + + checkCmd.Flags().String("output", "text", "Output format: text, json") + checkCmd.RunE = runChecks + Cmd.AddCommand(checkCmd) +} + +// NormalizeServerURL is re-exported here for doctor to use without cycling imports. +func normalizeServerURL(raw string) string { + return apiutil.NormalizeServerURL(raw) +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go new file mode 100644 index 0000000..23f9986 --- /dev/null +++ b/cmd/doctor/doctor.go @@ -0,0 +1,23 @@ +// Package doctor provides the "doctor" command which verifies that the CLI is +// correctly configured and can reach the configured Seerr server. +package doctor + +import ( + "github.com/spf13/cobra" +) + +// Cmd is the parent command for all doctor subcommands. +var Cmd = &cobra.Command{ + Use: "doctor", + Short: "Check CLI configuration and server connectivity", + Long: `Run a series of checks to verify that seerr-cli is correctly configured and can reach the Seerr API.`, + Example: ` # Run all checks (human-readable output) + seerr-cli doctor + + # Output check results as JSON + seerr-cli doctor --output json`, +} + +func init() { + // The check subcommand is added in check.go's init(). +} diff --git a/cmd/mcp/tools_movies.go b/cmd/mcp/tools_movies.go index 44712e0..a43c6bb 100644 --- a/cmd/mcp/tools_movies.go +++ b/cmd/mcp/tools_movies.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" + "seerr-cli/internal/seerrclient" + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -73,8 +75,8 @@ func MoviesGetHandler() server.ToolHandlerFunc { if err != nil { return nil, err } - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) - res, _, err := client.MoviesAPI.MovieMovieIdGet(callCtx, float32(movieId)).Execute() + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) + res, _, err := sc.MovieGetCtx(callCtx, movieId, "") if err != nil { return apiToolError("MovieMovieIdGet failed", err) } @@ -92,12 +94,9 @@ func MoviesRecommendationsHandler() server.ToolHandlerFunc { if err != nil { return nil, err } - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) - r := client.MoviesAPI.MovieMovieIdRecommendationsGet(callCtx, float32(movieId)) - if page := req.GetInt("page", 0); page > 0 { - r = r.Page(float32(page)) - } - res, _, err := r.Execute() + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) + page := req.GetInt("page", 0) + res, _, err := sc.MovieRecommendations(movieId, page, "") if err != nil { return apiToolError("MovieMovieIdRecommendationsGet failed", err) } @@ -115,12 +114,9 @@ func MoviesSimilarHandler() server.ToolHandlerFunc { if err != nil { return nil, err } - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) - r := client.MoviesAPI.MovieMovieIdSimilarGet(callCtx, float32(movieId)) - if page := req.GetInt("page", 0); page > 0 { - r = r.Page(float32(page)) - } - res, _, err := r.Execute() + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) + page := req.GetInt("page", 0) + res, _, err := sc.MovieSimilar(movieId, page, "") if err != nil { return apiToolError("MovieMovieIdSimilarGet failed", err) } @@ -138,8 +134,8 @@ func MoviesRatingsCombinedHandler() server.ToolHandlerFunc { if err != nil { return nil, err } - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) - res, _, err := client.MoviesAPI.MovieMovieIdRatingscombinedGet(callCtx, float32(movieId)).Execute() + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) + res, _, err := sc.MovieRatingsCombined(movieId) if err != nil { return apiToolError("MovieMovieIdRatingscombinedGet failed", err) } @@ -157,8 +153,8 @@ func MoviesRatingsHandler() server.ToolHandlerFunc { if err != nil { return nil, err } - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) - res, _, err := client.MoviesAPI.MovieMovieIdRatingsGet(callCtx, float32(movieId)).Execute() + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) + res, _, err := sc.MovieRatings(movieId) if err != nil { return apiToolError("MovieMovieIdRatingsGet failed", err) } diff --git a/cmd/mcp/tools_search.go b/cmd/mcp/tools_search.go index 82174a2..39cb421 100644 --- a/cmd/mcp/tools_search.go +++ b/cmd/mcp/tools_search.go @@ -7,7 +7,7 @@ import ( "net/url" "strconv" - "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -116,21 +116,14 @@ func SearchMultiHandler() server.ToolHandlerFunc { if query == "" { return nil, fmt.Errorf("query is required") } - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) // Fetch all genres concurrently while the search request is in flight. genresCh := make(chan GenreMap, 1) - go func() { genresCh <- FetchAllGenres(callCtx, client) }() + go func() { genresCh <- FetchAllGenres(callCtx, sc.Unwrap()) }() - // Use a raw HTTP request to avoid the broken union-type unmarshal in the - // generated client (TV results are incorrectly parsed as PersonResult) and - // to ensure spaces are encoded as %20 rather than + in the query string. - params := url.Values{} - params.Set("query", query) - if page := req.GetFloat("page", 0); page > 0 { - params.Set("page", strconv.Itoa(int(page))) - } - b, err := apiutil.RawGet(callCtx, client, "/search", params) + page := int(req.GetFloat("page", 0)) + b, err := sc.SearchMultiCtx(callCtx, query, page, "") if err != nil { return apiToolError("SearchGet failed", err) } @@ -144,11 +137,11 @@ func SearchMultiHandler() server.ToolHandlerFunc { func SearchDiscoverMoviesHandler() server.ToolHandlerFunc { return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) // Fetch movie genres concurrently while the discover request is in flight. genresCh := make(chan GenreMap, 1) - go func() { genresCh <- FetchMovieGenres(callCtx, client) }() + go func() { genresCh <- FetchMovieGenres(callCtx, sc.Unwrap()) }() params := url.Values{} if page := req.GetFloat("page", 0); page > 0 { @@ -164,7 +157,7 @@ func SearchDiscoverMoviesHandler() server.ToolHandlerFunc { params.Set(key, strconv.FormatFloat(v, 'f', -1, 64)) } } - b, err := apiutil.RawGet(callCtx, client, "/discover/movies", params) + b, err := sc.RawGetCtx(callCtx, "/discover/movies", params) if err != nil { return apiToolError("DiscoverMoviesGet failed", err) } @@ -178,11 +171,11 @@ func SearchDiscoverMoviesHandler() server.ToolHandlerFunc { func SearchDiscoverTVHandler() server.ToolHandlerFunc { return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) // Fetch TV genres concurrently while the discover request is in flight. genresCh := make(chan GenreMap, 1) - go func() { genresCh <- FetchTVGenres(callCtx, client) }() + go func() { genresCh <- FetchTVGenres(callCtx, sc.Unwrap()) }() params := url.Values{} if page := req.GetFloat("page", 0); page > 0 { @@ -198,7 +191,7 @@ func SearchDiscoverTVHandler() server.ToolHandlerFunc { params.Set(key, strconv.FormatFloat(v, 'f', -1, 64)) } } - b, err := apiutil.RawGet(callCtx, client, "/discover/tv", params) + b, err := sc.RawGetCtx(callCtx, "/discover/tv", params) if err != nil { return apiToolError("DiscoverTvGet failed", err) } @@ -216,8 +209,8 @@ func SearchCompanyHandler() server.ToolHandlerFunc { if query == "" { return nil, fmt.Errorf("query is required") } - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) - r := client.SearchAPI.SearchCompanyGet(callCtx).Query(query) + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) + r := sc.Unwrap().SearchAPI.SearchCompanyGet(callCtx).Query(query) if page := req.GetFloat("page", 0); page > 0 { r = r.Page(float32(page)) } @@ -239,8 +232,8 @@ func SearchKeywordHandler() server.ToolHandlerFunc { if query == "" { return nil, fmt.Errorf("query is required") } - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) - r := client.SearchAPI.SearchKeywordGet(callCtx).Query(query) + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) + r := sc.Unwrap().SearchAPI.SearchKeywordGet(callCtx).Query(query) if page := req.GetFloat("page", 0); page > 0 { r = r.Page(float32(page)) } @@ -258,17 +251,17 @@ func SearchKeywordHandler() server.ToolHandlerFunc { func SearchTrendingHandler() server.ToolHandlerFunc { return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + sc := seerrclient.NewWithKey(apiKeyFromContext(callCtx)) // Fetch all genres concurrently while the trending request is in flight. genresCh := make(chan GenreMap, 1) - go func() { genresCh <- FetchAllGenres(callCtx, client) }() + go func() { genresCh <- FetchAllGenres(callCtx, sc.Unwrap()) }() params := url.Values{} if page := req.GetFloat("page", 0); page > 0 { params.Set("page", strconv.Itoa(int(page))) } - b, err := apiutil.RawGet(callCtx, client, "/discover/trending", params) + b, err := sc.DiscoverTrendingCtx(callCtx, params) if err != nil { return apiToolError("DiscoverTrendingGet failed", err) } diff --git a/cmd/movies/get.go b/cmd/movies/get.go index 22d6e5b..dfc3bb6 100644 --- a/cmd/movies/get.go +++ b/cmd/movies/get.go @@ -4,8 +4,10 @@ import ( "strconv" "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var getCmd = &cobra.Command{ @@ -16,28 +18,36 @@ var getCmd = &cobra.Command{ seerr-cli movies get 603 # Get details in Spanish - seerr-cli movies get 603 --language es`, - RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := apiutil.NewAPIClient() + seerr-cli movies get 603 --language es + # Output as YAML + seerr-cli movies get 603 --output yaml`, + RunE: func(cmd *cobra.Command, args []string) error { movieId, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return err } language, _ := cmd.Flags().GetString("language") + if !cmd.Flags().Changed("language") { + language = "" + } - req := apiClient.MoviesAPI.MovieMovieIdGet(ctx, float32(movieId)) - if cmd.Flags().Changed("language") { - req = req.Language(language) + mode := apiutil.GetOutputMode(cmd) + res, r, err := seerrclient.New().MovieGet(int(movieId), language) + if err != nil { + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "MovieMovieIdGet") } - res, r, err := req.Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "MovieMovieIdGet") + if viper.GetBool("verbose") && r != nil { + cmd.Printf("HTTP Status: %s\n", r.Status) + } + return apiutil.PrintOutput(cmd, res, mode) }, } func init() { getCmd.Flags().String("language", "en", "Language code") + apiutil.AddOutputFlag(getCmd) Cmd.AddCommand(getCmd) } diff --git a/cmd/movies/ratings.go b/cmd/movies/ratings.go index 2eb22d7..47b3d44 100644 --- a/cmd/movies/ratings.go +++ b/cmd/movies/ratings.go @@ -4,8 +4,10 @@ import ( "strconv" "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var ratingsCmd = &cobra.Command{ @@ -15,15 +17,13 @@ var ratingsCmd = &cobra.Command{ Example: ` # Get ratings for The Matrix (ID 603) seerr-cli movies ratings 603`, RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := apiutil.NewAPIClient() - movieId, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return err } - res, r, err := apiClient.MoviesAPI.MovieMovieIdRatingsGet(ctx, float32(movieId)).Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "MovieMovieIdRatingsGet") + res, r, err := seerrclient.New().MovieRatings(int(movieId)) + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "MovieMovieIdRatingsGet") }, } diff --git a/cmd/movies/ratings_combined.go b/cmd/movies/ratings_combined.go index 071831a..d3ec519 100644 --- a/cmd/movies/ratings_combined.go +++ b/cmd/movies/ratings_combined.go @@ -4,8 +4,10 @@ import ( "strconv" "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var ratingsCombinedCmd = &cobra.Command{ @@ -15,15 +17,13 @@ var ratingsCombinedCmd = &cobra.Command{ Example: ` # Get combined ratings for The Matrix (ID 603) seerr-cli movies ratings-combined 603`, RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := apiutil.NewAPIClient() - movieId, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return err } - res, r, err := apiClient.MoviesAPI.MovieMovieIdRatingscombinedGet(ctx, float32(movieId)).Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "MovieMovieIdRatingscombinedGet") + res, r, err := seerrclient.New().MovieRatingsCombined(int(movieId)) + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "MovieMovieIdRatingscombinedGet") }, } diff --git a/cmd/movies/recommendations.go b/cmd/movies/recommendations.go index c29569f..ed6889e 100644 --- a/cmd/movies/recommendations.go +++ b/cmd/movies/recommendations.go @@ -4,8 +4,10 @@ import ( "strconv" "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var recommendationsCmd = &cobra.Command{ @@ -18,26 +20,22 @@ var recommendationsCmd = &cobra.Command{ # Get second page of recommendations seerr-cli movies recommendations 603 --page 2`, RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := apiutil.NewAPIClient() - movieId, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return err } page, _ := cmd.Flags().GetInt("page") - language, _ := cmd.Flags().GetString("language") - - req := apiClient.MoviesAPI.MovieMovieIdRecommendationsGet(ctx, float32(movieId)) - if cmd.Flags().Changed("page") { - req = req.Page(float32(page)) + if !cmd.Flags().Changed("page") { + page = 0 } - if cmd.Flags().Changed("language") { - req = req.Language(language) + language, _ := cmd.Flags().GetString("language") + if !cmd.Flags().Changed("language") { + language = "" } - res, r, err := req.Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "MovieMovieIdRecommendationsGet") + res, r, err := seerrclient.New().MovieRecommendations(int(movieId), page, language) + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "MovieMovieIdRecommendationsGet") }, } diff --git a/cmd/movies/similar.go b/cmd/movies/similar.go index 5da201a..2940f03 100644 --- a/cmd/movies/similar.go +++ b/cmd/movies/similar.go @@ -4,8 +4,10 @@ import ( "strconv" "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var similarCmd = &cobra.Command{ @@ -15,26 +17,22 @@ var similarCmd = &cobra.Command{ Example: ` # Get similar movies to The Matrix (ID 603) seerr-cli movies similar 603`, RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := apiutil.NewAPIClient() - movieId, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return err } page, _ := cmd.Flags().GetInt("page") - language, _ := cmd.Flags().GetString("language") - - req := apiClient.MoviesAPI.MovieMovieIdSimilarGet(ctx, float32(movieId)) - if cmd.Flags().Changed("page") { - req = req.Page(float32(page)) + if !cmd.Flags().Changed("page") { + page = 0 } - if cmd.Flags().Changed("language") { - req = req.Language(language) + language, _ := cmd.Flags().GetString("language") + if !cmd.Flags().Changed("language") { + language = "" } - res, r, err := req.Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "MovieMovieIdSimilarGet") + res, r, err := seerrclient.New().MovieSimilar(int(movieId), page, language) + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "MovieMovieIdSimilarGet") }, } diff --git a/cmd/root.go b/cmd/root.go index 8dc43f6..0bfc577 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,8 @@ import ( "seerr-cli/cmd/blocklist" "seerr-cli/cmd/collection" "seerr-cli/cmd/config" + "seerr-cli/cmd/docs" + "seerr-cli/cmd/doctor" "seerr-cli/cmd/issue" "seerr-cli/cmd/mcp" "seerr-cli/cmd/media" @@ -53,7 +55,7 @@ var RootCmd = &cobra.Command{ Short: "A CLI to interact with the Seerr API", Long: `A command line interface to call endpoints defined in the Seerr OpenAPI specification.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if cmd.Root() != cmd && (cmd.Name() == "config" || cmd.Parent().Name() == "config" || cmd.Name() == "help" || cmd.Name() == "completion" || cmd.Parent().Name() == "completion" || cmd.Name() == "mcp" || cmd.Parent().Name() == "mcp") { + if cmd.Root() != cmd && (cmd.Name() == "config" || cmd.Parent().Name() == "config" || cmd.Name() == "help" || cmd.Name() == "completion" || cmd.Parent().Name() == "completion" || cmd.Name() == "mcp" || cmd.Parent().Name() == "mcp" || cmd.Name() == "doctor" || cmd.Parent().Name() == "doctor" || cmd.Name() == "docs" || cmd.Parent().Name() == "docs") { return nil } if viper.GetString("seerr.server") == "" { @@ -86,6 +88,8 @@ func init() { viper.BindPFlag("verbose", RootCmd.PersistentFlags().Lookup("verbose")) RootCmd.AddCommand(config.Cmd) + RootCmd.AddCommand(doctor.Cmd) + RootCmd.AddCommand(docs.Cmd) RootCmd.AddCommand(mcp.Cmd) RootCmd.AddCommand(status.Cmd) RootCmd.AddCommand(users.Cmd) diff --git a/cmd/search/company.go b/cmd/search/company.go index 779260b..3699059 100644 --- a/cmd/search/company.go +++ b/cmd/search/company.go @@ -2,8 +2,10 @@ package search import ( "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var companyCmd = &cobra.Command{ @@ -12,18 +14,17 @@ var companyCmd = &cobra.Command{ Example: ` # Search for "Warner Bros." seerr-cli search company -q "Warner Bros."`, RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := newAPIClient() - query, _ := cmd.Flags().GetString("query") page, _ := cmd.Flags().GetInt("page") - req := apiClient.SearchAPI.SearchCompanyGet(ctx).Query(query) + sc := seerrclient.New() + req := sc.Unwrap().SearchAPI.SearchCompanyGet(sc.Ctx()).Query(query) if cmd.Flags().Changed("page") { req = req.Page(float32(page)) } res, r, err := req.Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "SearchCompanyGet") + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "SearchCompanyGet") }, } diff --git a/cmd/search/keyword.go b/cmd/search/keyword.go index 5dac9c6..337d494 100644 --- a/cmd/search/keyword.go +++ b/cmd/search/keyword.go @@ -2,8 +2,10 @@ package search import ( "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var keywordCmd = &cobra.Command{ @@ -12,18 +14,17 @@ var keywordCmd = &cobra.Command{ Example: ` # Search for the "sci-fi" keyword seerr-cli search keyword -q "sci-fi"`, RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := newAPIClient() - query, _ := cmd.Flags().GetString("query") page, _ := cmd.Flags().GetInt("page") - req := apiClient.SearchAPI.SearchKeywordGet(ctx).Query(query) + sc := seerrclient.New() + req := sc.Unwrap().SearchAPI.SearchKeywordGet(sc.Ctx()).Query(query) if cmd.Flags().Changed("page") { req = req.Page(float32(page)) } res, r, err := req.Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "SearchKeywordGet") + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "SearchKeywordGet") }, } diff --git a/cmd/search/movies.go b/cmd/search/movies.go index 390eea5..7390b9e 100644 --- a/cmd/search/movies.go +++ b/cmd/search/movies.go @@ -1,9 +1,15 @@ package search import ( - "seerr-cli/cmd/apiutil" + "encoding/json" + "fmt" + "net/url" + "strconv" + + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var moviesCmd = &cobra.Command{ @@ -18,8 +24,6 @@ var moviesCmd = &cobra.Command{ # Discover movies sorted by release date seerr-cli search movies --sort-by primary_release_date.desc`, RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := newAPIClient() - page, _ := cmd.Flags().GetInt("page") language, _ := cmd.Flags().GetString("language") genre, _ := cmd.Flags().GetString("genre") @@ -30,37 +34,54 @@ var moviesCmd = &cobra.Command{ releaseGte, _ := cmd.Flags().GetString("release-date-gte") releaseLte, _ := cmd.Flags().GetString("release-date-lte") - req := apiClient.SearchAPI.DiscoverMoviesGet(ctx) + params := url.Values{} if cmd.Flags().Changed("page") { - req = req.Page(float32(page)) + params.Set("page", strconv.Itoa(page)) } if cmd.Flags().Changed("language") { - req = req.Language(language) + params.Set("language", language) } if cmd.Flags().Changed("genre") { - req = req.Genre(genre) + params.Set("genre", genre) } if cmd.Flags().Changed("studio") { - req = req.Studio(float32(studio)) + params.Set("studio", strconv.Itoa(studio)) } if cmd.Flags().Changed("keywords") { - req = req.Keywords(keywords) + params.Set("keywords", keywords) } if cmd.Flags().Changed("exclude-keywords") { - req = req.ExcludeKeywords(excludeKeywords) + params.Set("excludeKeywords", excludeKeywords) } if cmd.Flags().Changed("sort-by") { - req = req.SortBy(sortBy) + params.Set("sortBy", sortBy) } if cmd.Flags().Changed("release-date-gte") { - req = req.PrimaryReleaseDateGte(releaseGte) + params.Set("primaryReleaseDateGte", releaseGte) } if cmd.Flags().Changed("release-date-lte") { - req = req.PrimaryReleaseDateLte(releaseLte) + params.Set("primaryReleaseDateLte", releaseLte) + } + + b, err := seerrclient.New().DiscoverMovies(params) + if err != nil { + return err } - res, r, err := req.Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "DiscoverMoviesGet") + if viper.GetBool("verbose") { + cmd.Printf("GET /api/v1/discover/movies\n") + } + + var out interface{} + if err := json.Unmarshal(b, &out); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + formatted, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + cmd.Println(string(formatted)) + return nil }, } diff --git a/cmd/search/multi.go b/cmd/search/multi.go index dafbb01..69f7e0b 100644 --- a/cmd/search/multi.go +++ b/cmd/search/multi.go @@ -1,12 +1,11 @@ package search import ( - "net/url" - "strconv" - "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var multiCmd = &cobra.Command{ @@ -16,36 +15,34 @@ var multiCmd = &cobra.Command{ seerr-cli search multi -q "The Matrix" # Search for "Christopher Nolan" on the second page - seerr-cli search multi -q "Christopher Nolan" --page 2`, - RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := newAPIClient() + seerr-cli search multi -q "Christopher Nolan" --page 2 + # Output as a table + seerr-cli search multi -q "matrix" --output table`, + RunE: func(cmd *cobra.Command, args []string) error { query, _ := cmd.Flags().GetString("query") page, _ := cmd.Flags().GetInt("page") language, _ := cmd.Flags().GetString("language") - params := url.Values{} - params.Set("query", query) - if cmd.Flags().Changed("page") { - params.Set("page", strconv.Itoa(page)) + // Zero out defaults so SearchMulti only includes explicitly-set params. + if !cmd.Flags().Changed("page") { + page = 0 } - if cmd.Flags().Changed("language") { - params.Set("language", language) + if !cmd.Flags().Changed("language") { + language = "" } - // Use a raw HTTP request to avoid the broken union-type unmarshal in the - // generated client (TV results are incorrectly parsed as PersonResult) and - // to ensure spaces are encoded as %20 rather than + in the query string. - b, err := apiutil.RawGet(ctx, apiClient, "/search", params) + b, err := seerrclient.New().SearchMulti(query, page, language) if err != nil { return err } - if isVerbose { + if viper.GetBool("verbose") { cmd.Printf("GET /api/v1/search\n") } - cmd.Println(string(b)) - return nil + + mode := apiutil.GetOutputMode(cmd) + return apiutil.PrintRawOutput(cmd, b, mode) }, } @@ -54,5 +51,6 @@ func init() { multiCmd.MarkFlagRequired("query") multiCmd.Flags().Int("page", 1, "Page number") multiCmd.Flags().String("language", "en", "Language code") + apiutil.AddOutputFlag(multiCmd) Cmd.AddCommand(multiCmd) } diff --git a/cmd/search/search.go b/cmd/search/search.go index 0416b21..f611ca0 100644 --- a/cmd/search/search.go +++ b/cmd/search/search.go @@ -1,54 +1,15 @@ package search import ( - "context" - "net/http" - "strings" - - "seerr-cli/cmd/apiutil" - api "seerr-cli/pkg/api" - "github.com/spf13/cobra" - "github.com/spf13/viper" ) -// encodingRoundTripper is a custom RoundTripper that replaces '+' with '%20' in query parameters. -// It also removes problematic parameters from certain endpoints if needed. -type encodingRoundTripper struct { - Proxied http.RoundTripper -} - -func (ert *encodingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - // Special handling for /discover/trending: remove mediaType and timeWindow if they are defaults - // because some server versions don't support them and fail with 400. - if strings.Contains(req.URL.Path, "/discover/trending") { - q := req.URL.Query() - if q.Get("mediaType") == "all" { - q.Del("mediaType") - } - if q.Get("timeWindow") == "day" { - q.Del("timeWindow") - } - req.URL.RawQuery = q.Encode() - } - - // Replace '+' with '%20' in the RawQuery - req.URL.RawQuery = strings.ReplaceAll(req.URL.RawQuery, "+", "%20") - - return ert.Proxied.RoundTrip(req) -} - var Cmd = &cobra.Command{ Use: "search", Short: "Search for movies, TV shows, people, and more", Long: `Search for various resources from the Seerr API including movies, TV shows, people, keywords, and companies.`, } -func newAPIClient() (*api.APIClient, context.Context, bool) { - return apiutil.NewAPIClientWithTransport(&encodingRoundTripper{Proxied: http.DefaultTransport}), context.Background(), viper.GetBool("verbose") -} - func init() { - // Subcommands will be added in their respective files' init() functions - // but we can also do it here if we want to be explicit. + // Subcommands are added in their respective files' init() functions. } diff --git a/cmd/search/trending.go b/cmd/search/trending.go index a0971fb..6411e29 100644 --- a/cmd/search/trending.go +++ b/cmd/search/trending.go @@ -1,9 +1,15 @@ package search import ( - "seerr-cli/cmd/apiutil" + "encoding/json" + "fmt" + "net/url" + "strconv" + + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var trendingCmd = &cobra.Command{ @@ -15,29 +21,44 @@ var trendingCmd = &cobra.Command{ # Get trending items for the week seerr-cli search trending --time-window week`, RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := newAPIClient() - page, _ := cmd.Flags().GetInt("page") language, _ := cmd.Flags().GetString("language") mediaType, _ := cmd.Flags().GetString("media-type") timeWindow, _ := cmd.Flags().GetString("time-window") - req := apiClient.SearchAPI.DiscoverTrendingGet(ctx) + params := url.Values{} if cmd.Flags().Changed("page") { - req = req.Page(float32(page)) + params.Set("page", strconv.Itoa(page)) } if cmd.Flags().Changed("language") { - req = req.Language(language) + params.Set("language", language) } if cmd.Flags().Changed("media-type") { - req = req.MediaType(mediaType) + params.Set("mediaType", mediaType) } if cmd.Flags().Changed("time-window") { - req = req.TimeWindow(timeWindow) + params.Set("timeWindow", timeWindow) + } + + b, err := seerrclient.New().DiscoverTrending(params) + if err != nil { + return err } - res, r, err := req.Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "DiscoverTrendingGet") + if viper.GetBool("verbose") { + cmd.Printf("GET /api/v1/discover/trending\n") + } + + var out interface{} + if err := json.Unmarshal(b, &out); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + formatted, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + cmd.Println(string(formatted)) + return nil }, } diff --git a/cmd/search/tv.go b/cmd/search/tv.go index 0bbd308..5394a38 100644 --- a/cmd/search/tv.go +++ b/cmd/search/tv.go @@ -1,9 +1,15 @@ package search import ( - "seerr-cli/cmd/apiutil" + "encoding/json" + "fmt" + "net/url" + "strconv" + + "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var tvCmd = &cobra.Command{ @@ -15,8 +21,6 @@ var tvCmd = &cobra.Command{ # Discover TV shows on Netflix (Network ID 213) seerr-cli search tv --network 213`, RunE: func(cmd *cobra.Command, args []string) error { - apiClient, ctx, isVerbose := newAPIClient() - page, _ := cmd.Flags().GetInt("page") language, _ := cmd.Flags().GetString("language") genre, _ := cmd.Flags().GetString("genre") @@ -27,37 +31,54 @@ var tvCmd = &cobra.Command{ firstAirGte, _ := cmd.Flags().GetString("first-air-date-gte") firstAirLte, _ := cmd.Flags().GetString("first-air-date-lte") - req := apiClient.SearchAPI.DiscoverTvGet(ctx) + params := url.Values{} if cmd.Flags().Changed("page") { - req = req.Page(float32(page)) + params.Set("page", strconv.Itoa(page)) } if cmd.Flags().Changed("language") { - req = req.Language(language) + params.Set("language", language) } if cmd.Flags().Changed("genre") { - req = req.Genre(genre) + params.Set("genre", genre) } if cmd.Flags().Changed("network") { - req = req.Network(float32(network)) + params.Set("network", strconv.Itoa(network)) } if cmd.Flags().Changed("keywords") { - req = req.Keywords(keywords) + params.Set("keywords", keywords) } if cmd.Flags().Changed("exclude-keywords") { - req = req.ExcludeKeywords(excludeKeywords) + params.Set("excludeKeywords", excludeKeywords) } if cmd.Flags().Changed("sort-by") { - req = req.SortBy(sortBy) + params.Set("sortBy", sortBy) } if cmd.Flags().Changed("first-air-date-gte") { - req = req.FirstAirDateGte(firstAirGte) + params.Set("firstAirDateGte", firstAirGte) } if cmd.Flags().Changed("first-air-date-lte") { - req = req.FirstAirDateLte(firstAirLte) + params.Set("firstAirDateLte", firstAirLte) + } + + b, err := seerrclient.New().DiscoverTV(params) + if err != nil { + return err } - res, r, err := req.Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "DiscoverTvGet") + if viper.GetBool("verbose") { + cmd.Printf("GET /api/v1/discover/tv\n") + } + + var out interface{} + if err := json.Unmarshal(b, &out); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + formatted, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + cmd.Println(string(formatted)) + return nil }, } diff --git a/go.mod b/go.mod index b821f54..744927f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.1 require ( github.com/mark3labs/mcp-go v0.45.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 @@ -13,6 +14,7 @@ require ( require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -22,15 +24,16 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.28.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index ca019c7..b454457 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -20,8 +21,11 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -34,6 +38,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= @@ -65,7 +70,6 @@ golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.work.sum b/go.work.sum index 6eb94b7..e933959 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,18 +1,6 @@ -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= -github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= -github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= diff --git a/internal/seerrclient/client.go b/internal/seerrclient/client.go new file mode 100644 index 0000000..5dc86f0 --- /dev/null +++ b/internal/seerrclient/client.go @@ -0,0 +1,75 @@ +// Package seerrclient provides a clean wrapper around the auto-generated +// pkg/api client, hiding generated-code quirks like float32 IDs, broken +// union-type deserialization, and Seerr-specific URL encoding requirements. +package seerrclient + +import ( + "context" + "net/http" + "net/url" + "strings" + + "seerr-cli/cmd/apiutil" + api "seerr-cli/pkg/api" + + "github.com/spf13/viper" +) + +// Client wraps the generated API client and exposes clean Go types to callers. +type Client struct { + api *api.APIClient + ctx context.Context +} + +// encodingRoundTripper replaces '+' with '%20' in query parameters. This is +// required because Seerr rejects the standard application/x-www-form-urlencoded +// plus-sign encoding for spaces. +type encodingRoundTripper struct { + proxied http.RoundTripper +} + +func (ert *encodingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req.URL.RawQuery = strings.ReplaceAll(req.URL.RawQuery, "+", "%20") + return ert.proxied.RoundTrip(req) +} + +// New creates a Client using Viper configuration (server URL and API key). +func New() *Client { + return NewWithKey("") +} + +// NewWithKey creates a Client with an explicit API key, falling back to Viper +// when key is empty. Used by MCP handlers that receive per-request API keys. +func NewWithKey(apiKey string) *Client { + transport := &encodingRoundTripper{proxied: http.DefaultTransport} + client := apiutil.NewAPIClientWithKeyAndTransport(apiKey, transport) + return &Client{api: client, ctx: context.Background()} +} + +// Ctx returns the context attached to this client. +func (c *Client) Ctx() context.Context { + return c.ctx +} + +// Unwrap returns the underlying generated API client for endpoints that have +// no wrapper method yet. Callers are responsible for any float32 casts. +func (c *Client) Unwrap() *api.APIClient { + return c.api +} + +// RawGet performs an authenticated GET request and returns the raw response +// body. Spaces in parameter values are encoded as %20 (not +) to satisfy +// Seerr's strict URL-encoding requirement. +func (c *Client) RawGet(path string, params url.Values) ([]byte, error) { + return apiutil.RawGet(c.ctx, c.api, path, params) +} + +// RawGetCtx performs a context-aware authenticated GET request. +func (c *Client) RawGetCtx(ctx context.Context, path string, params url.Values) ([]byte, error) { + return apiutil.RawGet(ctx, c.api, path, params) +} + +// Verbose returns whether verbose mode is enabled via Viper configuration. +func Verbose() bool { + return viper.GetBool("verbose") +} diff --git a/internal/seerrclient/movies.go b/internal/seerrclient/movies.go new file mode 100644 index 0000000..61318ee --- /dev/null +++ b/internal/seerrclient/movies.go @@ -0,0 +1,62 @@ +package seerrclient + +import ( + "context" + "net/http" + + api "seerr-cli/pkg/api" +) + +// MovieGet returns details for a single movie. Pass an empty language string +// to use the server default. +func (c *Client) MovieGet(id int, language string) (*api.MovieDetails, *http.Response, error) { + req := c.api.MoviesAPI.MovieMovieIdGet(c.ctx, float32(id)) + if language != "" { + req = req.Language(language) + } + return req.Execute() +} + +// MovieGetCtx is like MovieGet but accepts an explicit context. +func (c *Client) MovieGetCtx(ctx context.Context, id int, language string) (*api.MovieDetails, *http.Response, error) { + req := c.api.MoviesAPI.MovieMovieIdGet(ctx, float32(id)) + if language != "" { + req = req.Language(language) + } + return req.Execute() +} + +// MovieRecommendations returns recommended movies for the given movie ID. +// Pass page <= 0 to use the server default pagination. +func (c *Client) MovieRecommendations(id int, page int, language string) (*api.DiscoverMoviesGet200Response, *http.Response, error) { + req := c.api.MoviesAPI.MovieMovieIdRecommendationsGet(c.ctx, float32(id)) + if page > 0 { + req = req.Page(float32(page)) + } + if language != "" { + req = req.Language(language) + } + return req.Execute() +} + +// MovieSimilar returns movies similar to the given movie ID. +func (c *Client) MovieSimilar(id int, page int, language string) (*api.DiscoverMoviesGet200Response, *http.Response, error) { + req := c.api.MoviesAPI.MovieMovieIdSimilarGet(c.ctx, float32(id)) + if page > 0 { + req = req.Page(float32(page)) + } + if language != "" { + req = req.Language(language) + } + return req.Execute() +} + +// MovieRatings returns ratings data for the given movie ID. +func (c *Client) MovieRatings(id int) (*api.MovieMovieIdRatingsGet200Response, *http.Response, error) { + return c.api.MoviesAPI.MovieMovieIdRatingsGet(c.ctx, float32(id)).Execute() +} + +// MovieRatingsCombined returns combined RT and IMDB ratings for the given movie ID. +func (c *Client) MovieRatingsCombined(id int) (*api.MovieMovieIdRatingscombinedGet200Response, *http.Response, error) { + return c.api.MoviesAPI.MovieMovieIdRatingscombinedGet(c.ctx, float32(id)).Execute() +} diff --git a/internal/seerrclient/search.go b/internal/seerrclient/search.go new file mode 100644 index 0000000..bd1bc1d --- /dev/null +++ b/internal/seerrclient/search.go @@ -0,0 +1,79 @@ +package seerrclient + +import ( + "context" + "net/url" + "strconv" +) + +// SearchMulti searches for movies, TV shows, and people. It uses a raw HTTP +// request to work around the generated client's broken union-type unmarshal +// where TV results are incorrectly deserialised as PersonResult. +func (c *Client) SearchMulti(query string, page int, language string) ([]byte, error) { + params := url.Values{} + params.Set("query", query) + if page > 0 { + params.Set("page", strconv.Itoa(page)) + } + if language != "" { + params.Set("language", language) + } + return c.RawGet("/search", params) +} + +// SearchMultiCtx is like SearchMulti but accepts an explicit context. +func (c *Client) SearchMultiCtx(ctx context.Context, query string, page int, language string) ([]byte, error) { + params := url.Values{} + params.Set("query", query) + if page > 0 { + params.Set("page", strconv.Itoa(page)) + } + if language != "" { + params.Set("language", language) + } + return c.RawGetCtx(ctx, "/search", params) +} + +// DiscoverMovies runs the /discover/movies endpoint with arbitrary filter +// parameters. All numeric values should be pre-formatted as strings in params. +func (c *Client) DiscoverMovies(params url.Values) ([]byte, error) { + return c.RawGet("/discover/movies", params) +} + +// DiscoverTV runs the /discover/tv endpoint with arbitrary filter parameters. +func (c *Client) DiscoverTV(params url.Values) ([]byte, error) { + return c.RawGet("/discover/tv", params) +} + +// DiscoverTrending runs the /discover/trending endpoint. Default values for +// mediaType ("all") and timeWindow ("day") are stripped from params before the +// request is sent because some Seerr server versions reject them with HTTP 400. +func (c *Client) DiscoverTrending(params url.Values) ([]byte, error) { + // Clone params so we don't mutate the caller's map. + p := url.Values{} + for k, vs := range params { + p[k] = vs + } + if p.Get("mediaType") == "all" { + p.Del("mediaType") + } + if p.Get("timeWindow") == "day" { + p.Del("timeWindow") + } + return c.RawGet("/discover/trending", p) +} + +// DiscoverTrendingCtx is like DiscoverTrending but accepts an explicit context. +func (c *Client) DiscoverTrendingCtx(ctx context.Context, params url.Values) ([]byte, error) { + p := url.Values{} + for k, vs := range params { + p[k] = vs + } + if p.Get("mediaType") == "all" { + p.Del("mediaType") + } + if p.Get("timeWindow") == "day" { + p.Del("timeWindow") + } + return c.RawGetCtx(ctx, "/discover/trending", p) +} diff --git a/pkg/api/go.mod b/pkg/api/go.mod index 4b13a2f..bdfe70a 100644 --- a/pkg/api/go.mod +++ b/pkg/api/go.mod @@ -3,5 +3,13 @@ module seerr-cli/pkg/api go 1.23 require ( + github.com/stretchr/testify v1.11.1 gopkg.in/validator.v2 v2.0.1 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/api/go.sum b/pkg/api/go.sum index 525afa0..8db4dd8 100644 --- a/pkg/api/go.sum +++ b/pkg/api/go.sum @@ -1,13 +1,10 @@ -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/tests/doctor_test.go b/tests/doctor_test.go new file mode 100644 index 0000000..4216196 --- /dev/null +++ b/tests/doctor_test.go @@ -0,0 +1,108 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "seerr-cli/cmd" + "seerr-cli/cmd/apiutil" + "seerr-cli/cmd/doctor" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDoctorAllChecksPass(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"version":"2.0.0"}`) + })) + defer ts.Close() + + results := doctor.Run(ts.URL, "test-key") + + statusByName := map[string]string{} + for _, r := range results { + statusByName[r.Name] = r.Status + } + + assert.Equal(t, "ok", statusByName["server_configured"]) + assert.Equal(t, "ok", statusByName["server_reachable"]) + assert.Equal(t, "ok", statusByName["api_key_configured"]) + assert.Equal(t, "ok", statusByName["api_key_valid"]) + assert.Equal(t, "info", statusByName["server_version"]) +} + +func TestDoctorServerUnreachable(t *testing.T) { + // Use a URL that nothing is listening on. + results := doctor.Run("http://127.0.0.1:19999", "test-key") + + statusByName := map[string]string{} + for _, r := range results { + statusByName[r.Name] = r.Status + } + + assert.Equal(t, "ok", statusByName["server_configured"]) + assert.Equal(t, "fail", statusByName["server_reachable"]) + // Subsequent checks are skipped when the server is unreachable. + assert.Equal(t, "skip", statusByName["api_key_configured"]) + assert.Equal(t, "skip", statusByName["api_key_valid"]) + assert.Equal(t, "skip", statusByName["server_version"]) +} + +func TestDoctorAPIKeyInvalid(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Api-Key") == "" { + // Unauthenticated request returns 200 for reachability check. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{}`) + return + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer ts.Close() + + results := doctor.Run(ts.URL, "bad-key") + + statusByName := map[string]string{} + for _, r := range results { + statusByName[r.Name] = r.Status + } + + assert.Equal(t, "ok", statusByName["server_reachable"]) + assert.Equal(t, "fail", statusByName["api_key_valid"]) +} + +func TestDoctorCommandOutputJSON(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"version":"2.0.0"}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + b := bytes.NewBufferString("") + command := cmd.RootCmd + command.SetOut(b) + command.SetArgs([]string{"doctor", "--output", "json"}) + err := command.Execute() + require.NoError(t, err) + + var results []map[string]string + err = json.Unmarshal([]byte(b.String()), &results) + require.NoError(t, err, "output should be a parseable JSON array") + assert.NotEmpty(t, results) +} diff --git a/tests/output_mode_test.go b/tests/output_mode_test.go new file mode 100644 index 0000000..b53c64a --- /dev/null +++ b/tests/output_mode_test.go @@ -0,0 +1,113 @@ +package tests + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "seerr-cli/cmd" + "seerr-cli/cmd/apiutil" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetOutputFlag finds the named subcommand through the root command tree +// and resets its "output" flag to the default value "json". This is necessary +// because Cobra flag values persist across Execute() calls in the same process. +func resetOutputFlag(args []string) { + sub, _, _ := cmd.RootCmd.Find(args) + if sub != nil { + if f := sub.Flags().Lookup("output"); f != nil { + _ = f.Value.Set("json") + } + } +} + +func TestMoviesGetJSONOutputDefault(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"id":603,"title":"The Matrix"}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + b := bytes.NewBufferString("") + command := cmd.RootCmd + command.SetOut(b) + command.SetArgs([]string{"movies", "get", "603"}) + err := command.Execute() + require.NoError(t, err) + + // Default output is JSON. + assert.Contains(t, b.String(), `"title"`) +} + +func TestMoviesGetYAMLOutput(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"id":603,"title":"The Matrix"}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + // Reset the --output flag after this test so subsequent tests that omit + // --output see the default "json" instead of "yaml". + t.Cleanup(func() { resetOutputFlag([]string{"movies", "get"}) }) + + b := bytes.NewBufferString("") + command := cmd.RootCmd + command.SetOut(b) + command.SetArgs([]string{"movies", "get", "603", "--output", "yaml"}) + err := command.Execute() + require.NoError(t, err) + + // YAML uses "key: value" syntax, not "key": "value". + assert.Contains(t, b.String(), "title:") + assert.NotContains(t, b.String(), `"title":`) +} + +func TestSearchMultiTableOutput(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"results":[{"id":1,"title":"The Matrix","mediaType":"movie"},{"id":2,"name":"Lost","mediaType":"tv"}]}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + // Reset the --output flag after this test so subsequent tests see the + // default "json" value instead of "table". + t.Cleanup(func() { resetOutputFlag([]string{"search", "multi"}) }) + + b := bytes.NewBufferString("") + command := cmd.RootCmd + command.SetOut(b) + command.SetArgs([]string{"search", "multi", "-q", "matrix", "--output", "table"}) + err := command.Execute() + require.NoError(t, err) + + // Table output includes results as tab-separated rows. + assert.Contains(t, b.String(), "movie") + assert.Contains(t, b.String(), "The Matrix") +} diff --git a/tests/search_multi_test.go b/tests/search_multi_test.go index 0bec8f8..3586c24 100644 --- a/tests/search_multi_test.go +++ b/tests/search_multi_test.go @@ -46,9 +46,10 @@ func TestSearchMultiMixedResults(t *testing.T) { assert.NoError(t, err) out := buf.String() - assert.Contains(t, out, `"mediaType":"movie"`) - assert.Contains(t, out, `"mediaType":"tv"`) - assert.Contains(t, out, `"mediaType":"person"`) + // Output is pretty-printed JSON; check for mediaType values as JSON strings. + assert.Contains(t, out, `"mediaType": "movie"`) + assert.Contains(t, out, `"mediaType": "tv"`) + assert.Contains(t, out, `"mediaType": "person"`) } // TestSearchMultiQueryEncoding verifies that spaces in the query are encoded diff --git a/tests/seerrclient_test.go b/tests/seerrclient_test.go new file mode 100644 index 0000000..212a728 --- /dev/null +++ b/tests/seerrclient_test.go @@ -0,0 +1,141 @@ +package tests + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "seerr-cli/cmd/apiutil" + "seerr-cli/internal/seerrclient" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientRawGetSpaceEncoding(t *testing.T) { + var rawQuery string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"results":[]}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + c := seerrclient.New() + params := url.Values{} + params.Set("query", "the matrix") + _, err := c.RawGet("/search", params) + require.NoError(t, err) + + // Seerr API requires %20 for spaces, not +. + assert.Contains(t, rawQuery, "%20", "spaces should be encoded as %%20") + assert.NotContains(t, rawQuery, "+", "spaces should not be encoded as +") +} + +func TestClientSearchMultiPreservesAllMediaTypes(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/search", r.URL.Path) + w.WriteHeader(http.StatusOK) + // Return a mix of movie and TV results to guard against the union-type + // unmarshal bug in the generated client where TV results become PersonResult. + fmt.Fprintln(w, `{"results":[{"id":1,"mediaType":"movie","title":"Inception"},{"id":2,"mediaType":"tv","name":"Lost"}]}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + c := seerrclient.New() + b, err := c.SearchMulti("inception", 1, "en") + require.NoError(t, err) + + assert.Contains(t, string(b), `"movie"`) + assert.Contains(t, string(b), `"tv"`) +} + +func TestClientDiscoverTrendingStripsDefaultParams(t *testing.T) { + var rawQuery string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"results":[]}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + // Pass the default values explicitly; DiscoverTrending must strip them + // because some Seerr server versions reject them with HTTP 400. + c := seerrclient.New() + params := url.Values{} + params.Set("mediaType", "all") + params.Set("timeWindow", "day") + _, err := c.DiscoverTrending(params) + require.NoError(t, err) + + assert.NotContains(t, rawQuery, "mediaType=all") + assert.NotContains(t, rawQuery, "timeWindow=day") +} + +func TestClientDiscoverTrendingPassesThroughExplicitParams(t *testing.T) { + var rawQuery string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"results":[]}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + c := seerrclient.New() + params := url.Values{} + params.Set("page", "2") + _, err := c.DiscoverTrending(params) + require.NoError(t, err) + + assert.Contains(t, rawQuery, "page=2") +} + +func TestClientMovieGetPassesIntID(t *testing.T) { + var requestPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"id":550,"title":"Fight Club"}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + c := seerrclient.New() + _, _, err := c.MovieGet(550, "") + require.NoError(t, err) + + assert.Equal(t, "/api/v1/movie/550", requestPath) +}