From 65c71288203ac149f49216f5025eb11132a0d1b0 Mon Sep 17 00:00:00 2001 From: Omid Astaraki Date: Mon, 16 Mar 2026 17:39:35 +0000 Subject: [PATCH 1/3] feat(mcp): add discover filters, new tools, and fix prompt parity - Add genre, studio/network, language, sortBy, date range, vote average, and runtime filters to search_discover_movies and search_discover_tv - Fix report_issue prompt to remove non-existent mediaType arg on issue_create - Fix issue_create tool description to list all 5 issue types correctly - Add search_company and search_keyword tools - Add movies_ratings_combined tool - Add settings_jobs_cancel and settings_jobs_schedule tools - Remove orphaned WatchProviders resource handlers that were never registered - Add inventory.go with MCPToolCount/MCPResourceCount/MCPPromptCount constants - Update serve.go log lines to use inventory constants instead of stale literals - Update README tool count (43 -> 52) and add missing tools and resources table --- README.md | 51 ++++++--- cmd/mcp/inventory.go | 10 ++ cmd/mcp/prompts.go | 3 +- cmd/mcp/resources.go | 32 ------ cmd/mcp/serve.go | 4 +- cmd/mcp/tools_issues.go | 2 +- cmd/mcp/tools_movies.go | 30 +++++ cmd/mcp/tools_search.go | 113 +++++++++++++++++++ cmd/mcp/tools_settings.go | 70 ++++++++++++ tests/mcp_discover_test.go | 130 ++++++++++++++++++++++ tests/mcp_inventory_test.go | 21 ++++ tests/mcp_movies_ratings_combined_test.go | 32 ++++++ tests/mcp_prompts_test.go | 10 ++ tests/mcp_search_company_test.go | 50 +++++++++ tests/mcp_settings_jobs_test.go | 51 +++++++++ 15 files changed, 554 insertions(+), 55 deletions(-) create mode 100644 cmd/mcp/inventory.go create mode 100644 tests/mcp_discover_test.go create mode 100644 tests/mcp_inventory_test.go create mode 100644 tests/mcp_movies_ratings_combined_test.go create mode 100644 tests/mcp_search_company_test.go create mode 100644 tests/mcp_settings_jobs_test.go diff --git a/README.md b/README.md index 37810ec..e1f83bd 100644 --- a/README.md +++ b/README.md @@ -545,24 +545,39 @@ docker run -d \ ghcr.io/electather/seerr-cli:latest ``` -### Available tools (43) - -| Category | Tools | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------ | -| Search & Discovery | `search_multi`, `search_discover_movies`, `search_discover_tv`, `search_trending` | -| Movies | `movies_get`, `movies_recommendations`, `movies_similar`, `movies_ratings` | -| TV Shows | `tv_get`, `tv_season`, `tv_recommendations`, `tv_similar`, `tv_ratings` | -| Requests | `request_list`, `request_get`, `request_create`, `request_approve`, `request_decline`, `request_delete`, `request_count` | -| Media | `media_list`, `media_status_update` | -| Issues | `issue_list`, `issue_get`, `issue_create`, `issue_status_update`, `issue_count` | -| Users | `users_list`, `users_get`, `users_quota` | -| People | `person_get`, `person_credits` | -| Collections | `collection_get` | -| Services | `service_radarr_list`, `service_sonarr_list` | -| Settings | `settings_about`, `settings_jobs_list`, `settings_jobs_run` | -| Watchlist | `watchlist_add`, `watchlist_remove` | -| Blocklist | `blocklist_list`, `blocklist_add`, `blocklist_remove` | -| System | `status_system` | +### Available tools (52) + +| Category | Tools | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| Search & Discovery | `search_multi`, `search_discover_movies`, `search_discover_tv`, `search_trending`, `search_company`, `search_keyword` | +| Movies | `movies_get`, `movies_recommendations`, `movies_similar`, `movies_ratings`, `movies_ratings_combined` | +| TV Shows | `tv_get`, `tv_season`, `tv_recommendations`, `tv_similar`, `tv_ratings` | +| Requests | `request_list`, `request_get`, `request_create`, `request_approve`, `request_decline`, `request_delete`, `request_count`, `request_retry` | +| Media | `media_list`, `media_status_update` | +| Issues | `issue_list`, `issue_get`, `issue_create`, `issue_status_update`, `issue_count` | +| Auth | `auth_me` | +| Users | `users_list`, `users_get`, `users_quota`, `users_update` | +| People | `person_get`, `person_credits` | +| Collections | `collection_get` | +| Services | `service_radarr_list`, `service_sonarr_list` | +| Settings | `settings_about`, `settings_jobs_list`, `settings_jobs_run`, `settings_jobs_cancel`, `settings_jobs_schedule` | +| Watchlist | `watchlist_add`, `watchlist_remove` | +| Blocklist | `blocklist_list`, `blocklist_add`, `blocklist_remove` | +| System | `status_system` | + +### Available resources (9) + +| URI | Description | +| ---------------------------- | -------------------------------------------------------------- | +| `seerr://genres/movies` | TMDB movie genre ID to name map | +| `seerr://genres/tv` | TMDB TV genre ID to name map | +| `seerr://languages` | ISO language codes and English names supported by TMDB | +| `seerr://regions` | ISO region codes and English names supported by TMDB | +| `seerr://certifications/movies` | Content ratings by country for movies (G, PG, R, etc.) | +| `seerr://certifications/tv` | Content ratings by country for TV shows | +| `seerr://services/radarr` | Configured Radarr instances with quality profiles and root folders | +| `seerr://services/sonarr` | Configured Sonarr instances with quality profiles and root folders | +| `seerr://system/about` | Seerr version, commit hash, and update availability | ## Supported Platforms diff --git a/cmd/mcp/inventory.go b/cmd/mcp/inventory.go new file mode 100644 index 0000000..da4d5a4 --- /dev/null +++ b/cmd/mcp/inventory.go @@ -0,0 +1,10 @@ +package mcp + +// Inventory counts must match the registrations in registerXxx functions. +// serve.go references these so log lines stay accurate. Tests assert them +// to catch drift when tools are added or removed. +const ( + MCPToolCount = 52 + MCPResourceCount = 9 + MCPPromptCount = 6 +) diff --git a/cmd/mcp/prompts.go b/cmd/mcp/prompts.go index 6a5457c..5a1608d 100644 --- a/cmd/mcp/prompts.go +++ b/cmd/mcp/prompts.go @@ -136,7 +136,7 @@ The user's preferences: %q Follow these steps: 1. Read the seerr://genres/movies and seerr://genres/tv resources to find genre IDs that match the user's preferences. -2. Based on the media type preference (%q), call search_discover_movies or search_discover_tv with a relevant genre filter. +2. Based on the media type preference (%q), call search_discover_movies or search_discover_tv with relevant filters (e.g. genre, language, sortBy, or date range) using the genre IDs from the resources above. 3. Also call search_trending to supplement with trending content. 4. Present a curated list of 5-8 recommendations, including title, year, genre, and whether each is already Available in Seerr (check mediaInfo.status == 5). 5. Ask the user if they would like to request any of the shown titles.`, @@ -225,7 +225,6 @@ Follow these steps: - 5: Other 4. If no description was provided, ask the user to describe the problem briefly. 5. Call issue_create with: - - mediaType: the type from search results - mediaId: the TMDB ID - issueType: the number selected above (1-5) - message: the problem description diff --git a/cmd/mcp/resources.go b/cmd/mcp/resources.go index b09d947..306c316 100644 --- a/cmd/mcp/resources.go +++ b/cmd/mcp/resources.go @@ -190,38 +190,6 @@ func CertificationsTVResourceHandler() server.ResourceHandlerFunc { } } -// WatchProvidersMoviesResourceHandler returns a resource handler for movie streaming providers. -func WatchProvidersMoviesResourceHandler() server.ResourceHandlerFunc { - return func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - client := newAPIClientWithKey(apiKeyFromContext(ctx)) - res, _, err := client.OtherAPI.WatchprovidersMoviesGet(ctx).Execute() - if err != nil { - return nil, err - } - data, err := json.MarshalIndent(res, "", " ") - if err != nil { - return nil, err - } - return textResource(req.Params.URI, data), nil - } -} - -// WatchProvidersTVResourceHandler returns a resource handler for TV streaming providers. -func WatchProvidersTVResourceHandler() server.ResourceHandlerFunc { - return func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - client := newAPIClientWithKey(apiKeyFromContext(ctx)) - res, _, err := client.OtherAPI.WatchprovidersTvGet(ctx).Execute() - if err != nil { - return nil, err - } - data, err := json.MarshalIndent(res, "", " ") - if err != nil { - return nil, err - } - return textResource(req.Params.URI, data), nil - } -} - // RadarrServicesResourceHandler returns a resource handler that lists all configured // Radarr instances enriched with per-instance quality profiles and root folders. func RadarrServicesResourceHandler() server.ResourceHandlerFunc { diff --git a/cmd/mcp/serve.go b/cmd/mcp/serve.go index c4c7653..685f08a 100644 --- a/cmd/mcp/serve.go +++ b/cmd/mcp/serve.go @@ -130,7 +130,7 @@ func runServe(_ *cobra.Command, args []string) error { mcpLog.Info("starting MCP server", "transport", "stdio", "seerr_api", seerServer, - "tools", 46, "resources", 9, "prompts", 6, + "tools", MCPToolCount, "resources", MCPResourceCount, "prompts", MCPPromptCount, ) mcpLog.Debug("stdio transport ready, waiting for MCP client on stdin") return server.ServeStdio(s) @@ -150,7 +150,7 @@ func runServe(_ *cobra.Command, args []string) error { "transport", "http", "endpoint", endpoint, "seerr_api", seerServer, - "tools", 46, "resources", 9, "prompts", 6, + "tools", MCPToolCount, "resources", MCPResourceCount, "prompts", MCPPromptCount, "tls", tlsCert != "", "auth_token", authToken != "", "cors", cors, diff --git a/cmd/mcp/tools_issues.go b/cmd/mcp/tools_issues.go index ed324d9..a9f723b 100644 --- a/cmd/mcp/tools_issues.go +++ b/cmd/mcp/tools_issues.go @@ -40,7 +40,7 @@ func registerIssueTools(s *server.MCPServer) { mcp.WithDestructiveHintAnnotation(false), mcp.WithReadOnlyHintAnnotation(false), mcp.WithIdempotentHintAnnotation(false), - mcp.WithNumber("issueType", mcp.Required(), mcp.Description("Issue type (1=Video, 2=Audio, 3=Subtitle, 4=Other)")), + mcp.WithNumber("issueType", mcp.Required(), mcp.Description("Issue type (1=Video, 2=Audio, 3=Subtitle, 4=Wrong content, 5=Other)")), mcp.WithString("message", mcp.Required(), mcp.Description("Issue message")), mcp.WithNumber("mediaId", mcp.Required(), mcp.Description("Media ID")), ), diff --git a/cmd/mcp/tools_movies.go b/cmd/mcp/tools_movies.go index a80ab08..44712e0 100644 --- a/cmd/mcp/tools_movies.go +++ b/cmd/mcp/tools_movies.go @@ -54,6 +54,17 @@ func registerMoviesTools(s *server.MCPServer) { ), MoviesRatingsHandler(), ) + + s.AddTool( + mcp.NewTool("movies_ratings_combined", + mcp.WithDescription("Get combined RT and IMDB ratings for a given movie"), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithNumber("movieId", mcp.Required(), mcp.Description("TMDB movie ID")), + ), + MoviesRatingsCombinedHandler(), + ) } func MoviesGetHandler() server.ToolHandlerFunc { @@ -121,6 +132,25 @@ func MoviesSimilarHandler() server.ToolHandlerFunc { } } +func MoviesRatingsCombinedHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + movieId, err := req.RequireInt("movieId") + if err != nil { + return nil, err + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + res, _, err := client.MoviesAPI.MovieMovieIdRatingscombinedGet(callCtx, float32(movieId)).Execute() + if err != nil { + return apiToolError("MovieMovieIdRatingscombinedGet failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + func MoviesRatingsHandler() server.ToolHandlerFunc { return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { movieId, err := req.RequireInt("movieId") diff --git a/cmd/mcp/tools_search.go b/cmd/mcp/tools_search.go index 6dd7d8a..82174a2 100644 --- a/cmd/mcp/tools_search.go +++ b/cmd/mcp/tools_search.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "fmt" "net/url" "strconv" @@ -34,6 +35,16 @@ func registerSearchTools(s *server.MCPServer) { mcp.WithDestructiveHintAnnotation(false), mcp.WithIdempotentHintAnnotation(true), mcp.WithNumber("page", mcp.Description("Page number")), + mcp.WithString("genre", mcp.Description("Genre ID (use seerr://genres/movies resource for IDs)")), + mcp.WithString("studio", mcp.Description("Studio/production company ID")), + mcp.WithString("language", mcp.Description("ISO 639-1 language code, e.g. 'en'")), + mcp.WithString("sortBy", mcp.Description("Sort order, e.g. 'popularity.desc', 'vote_average.desc'")), + mcp.WithString("primaryReleaseDateGte", mcp.Description("Minimum release date (YYYY-MM-DD)")), + mcp.WithString("primaryReleaseDateLte", mcp.Description("Maximum release date (YYYY-MM-DD)")), + mcp.WithNumber("voteAverageGte", mcp.Description("Minimum vote average (0-10)")), + mcp.WithNumber("voteAverageLte", mcp.Description("Maximum vote average (0-10)")), + mcp.WithNumber("withRuntimeGte", mcp.Description("Minimum runtime in minutes")), + mcp.WithNumber("withRuntimeLte", mcp.Description("Maximum runtime in minutes")), ), SearchDiscoverMoviesHandler(), ) @@ -46,6 +57,16 @@ func registerSearchTools(s *server.MCPServer) { mcp.WithDestructiveHintAnnotation(false), mcp.WithIdempotentHintAnnotation(true), mcp.WithNumber("page", mcp.Description("Page number")), + mcp.WithString("genre", mcp.Description("Genre ID (use seerr://genres/tv resource for IDs)")), + mcp.WithString("network", mcp.Description("Network ID")), + mcp.WithString("language", mcp.Description("ISO 639-1 language code, e.g. 'en'")), + mcp.WithString("sortBy", mcp.Description("Sort order, e.g. 'popularity.desc', 'vote_average.desc'")), + mcp.WithString("firstAirDateGte", mcp.Description("Minimum first air date (YYYY-MM-DD)")), + mcp.WithString("firstAirDateLte", mcp.Description("Maximum first air date (YYYY-MM-DD)")), + mcp.WithNumber("voteAverageGte", mcp.Description("Minimum vote average (0-10)")), + mcp.WithNumber("voteAverageLte", mcp.Description("Maximum vote average (0-10)")), + mcp.WithNumber("withRuntimeGte", mcp.Description("Minimum runtime in minutes")), + mcp.WithNumber("withRuntimeLte", mcp.Description("Maximum runtime in minutes")), ), SearchDiscoverTVHandler(), ) @@ -61,6 +82,32 @@ func registerSearchTools(s *server.MCPServer) { ), SearchTrendingHandler(), ) + + s.AddTool( + mcp.NewTool("search_company", + mcp.WithDescription("Search for production companies"), + mcp.WithOpenWorldHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithString("query", mcp.Required(), mcp.Description("Search query")), + mcp.WithNumber("page", mcp.Description("Page number")), + ), + SearchCompanyHandler(), + ) + + s.AddTool( + mcp.NewTool("search_keyword", + mcp.WithDescription("Search for TMDB keywords"), + mcp.WithOpenWorldHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithString("query", mcp.Required(), mcp.Description("Search query")), + mcp.WithNumber("page", mcp.Description("Page number")), + ), + SearchKeywordHandler(), + ) } func SearchMultiHandler() server.ToolHandlerFunc { @@ -107,6 +154,16 @@ func SearchDiscoverMoviesHandler() server.ToolHandlerFunc { if page := req.GetFloat("page", 0); page > 0 { params.Set("page", strconv.Itoa(int(page))) } + for _, key := range []string{"genre", "studio", "language", "sortBy", "primaryReleaseDateGte", "primaryReleaseDateLte"} { + if v := req.GetString(key, ""); v != "" { + params.Set(key, v) + } + } + for _, key := range []string{"voteAverageGte", "voteAverageLte", "withRuntimeGte", "withRuntimeLte"} { + if v := req.GetFloat(key, 0); v != 0 { + params.Set(key, strconv.FormatFloat(v, 'f', -1, 64)) + } + } b, err := apiutil.RawGet(callCtx, client, "/discover/movies", params) if err != nil { return apiToolError("DiscoverMoviesGet failed", err) @@ -131,6 +188,16 @@ func SearchDiscoverTVHandler() server.ToolHandlerFunc { if page := req.GetFloat("page", 0); page > 0 { params.Set("page", strconv.Itoa(int(page))) } + for _, key := range []string{"genre", "network", "language", "sortBy", "firstAirDateGte", "firstAirDateLte"} { + if v := req.GetString(key, ""); v != "" { + params.Set(key, v) + } + } + for _, key := range []string{"voteAverageGte", "voteAverageLte", "withRuntimeGte", "withRuntimeLte"} { + if v := req.GetFloat(key, 0); v != 0 { + params.Set(key, strconv.FormatFloat(v, 'f', -1, 64)) + } + } b, err := apiutil.RawGet(callCtx, client, "/discover/tv", params) if err != nil { return apiToolError("DiscoverTvGet failed", err) @@ -143,6 +210,52 @@ func SearchDiscoverTVHandler() server.ToolHandlerFunc { } } +func SearchCompanyHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query := req.GetString("query", "") + if query == "" { + return nil, fmt.Errorf("query is required") + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + r := client.SearchAPI.SearchCompanyGet(callCtx).Query(query) + if page := req.GetFloat("page", 0); page > 0 { + r = r.Page(float32(page)) + } + res, _, err := r.Execute() + if err != nil { + return apiToolError("SearchCompanyGet failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + +func SearchKeywordHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query := req.GetString("query", "") + if query == "" { + return nil, fmt.Errorf("query is required") + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + r := client.SearchAPI.SearchKeywordGet(callCtx).Query(query) + if page := req.GetFloat("page", 0); page > 0 { + r = r.Page(float32(page)) + } + res, _, err := r.Execute() + if err != nil { + return apiToolError("SearchKeywordGet failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + func SearchTrendingHandler() server.ToolHandlerFunc { return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { client := newAPIClientWithKey(apiKeyFromContext(callCtx)) diff --git a/cmd/mcp/tools_settings.go b/cmd/mcp/tools_settings.go index fabf909..4d02d1d 100644 --- a/cmd/mcp/tools_settings.go +++ b/cmd/mcp/tools_settings.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" + api "seerr-cli/pkg/api" + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -39,6 +41,29 @@ func registerSettingsTools(s *server.MCPServer) { ), SettingsJobsRunHandler(), ) + + s.AddTool( + mcp.NewTool("settings_jobs_cancel", + mcp.WithDescription("Cancel a running scheduled job"), + mcp.WithString("jobId", mcp.Required(), mcp.Description("Job ID")), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + ), + SettingsJobsCancelHandler(), + ) + + s.AddTool( + mcp.NewTool("settings_jobs_schedule", + mcp.WithDescription("Update the cron schedule for a scheduled job"), + mcp.WithString("jobId", mcp.Required(), mcp.Description("Job ID")), + mcp.WithString("schedule", mcp.Required(), mcp.Description("Cron expression for the new schedule")), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + ), + SettingsJobsScheduleHandler(), + ) } func SettingsAboutHandler() server.ToolHandlerFunc { @@ -71,6 +96,51 @@ func SettingsJobsListHandler() server.ToolHandlerFunc { } } +func SettingsJobsCancelHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + jobId, err := req.RequireString("jobId") + if err != nil { + return nil, err + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + res, _, err := client.SettingsAPI.SettingsJobsJobIdCancelPost(callCtx, jobId).Execute() + if err != nil { + return apiToolError("SettingsJobsJobIdCancelPost failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + +func SettingsJobsScheduleHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + jobId, err := req.RequireString("jobId") + if err != nil { + return nil, err + } + schedule, err := req.RequireString("schedule") + if err != nil { + return nil, err + } + body := api.SettingsJobsJobIdSchedulePostRequest{ + Schedule: &schedule, + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + res, _, err := client.SettingsAPI.SettingsJobsJobIdSchedulePost(callCtx, jobId).SettingsJobsJobIdSchedulePostRequest(body).Execute() + if err != nil { + return apiToolError("SettingsJobsJobIdSchedulePost failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + func SettingsJobsRunHandler() server.ToolHandlerFunc { return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { jobId, err := req.RequireString("jobId") diff --git a/tests/mcp_discover_test.go b/tests/mcp_discover_test.go new file mode 100644 index 0000000..fff3192 --- /dev/null +++ b/tests/mcp_discover_test.go @@ -0,0 +1,130 @@ +package tests + +import ( + "net/http" + "testing" + + cmdmcp "seerr-cli/cmd/mcp" + + "github.com/stretchr/testify/assert" +) + +func TestSearchDiscoverMoviesGenreFilter(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/movies": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/movie": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverMoviesHandler() + callTool(t, handler, map[string]any{"genre": "18"}) + + assert.Contains(t, capturedQuery, "genre=18") +} + +func TestSearchDiscoverMoviesSortBy(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/movies": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/movie": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverMoviesHandler() + callTool(t, handler, map[string]any{"sortBy": "popularity.desc"}) + + assert.Contains(t, capturedQuery, "sortBy=popularity.desc") +} + +func TestSearchDiscoverMoviesDateRange(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/movies": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/movie": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverMoviesHandler() + callTool(t, handler, map[string]any{"primaryReleaseDateGte": "2023-01-01"}) + + assert.Contains(t, capturedQuery, "primaryReleaseDateGte=2023-01-01") +} + +func TestSearchDiscoverTVNetworkFilter(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/tv": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/tv": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverTVHandler() + callTool(t, handler, map[string]any{"network": "1"}) + + assert.Contains(t, capturedQuery, "network=1") +} + +func TestSearchDiscoverTVLanguageFilter(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/tv": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/tv": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverTVHandler() + callTool(t, handler, map[string]any{"language": "fr"}) + + assert.Contains(t, capturedQuery, "language=fr") +} diff --git a/tests/mcp_inventory_test.go b/tests/mcp_inventory_test.go new file mode 100644 index 0000000..835727e --- /dev/null +++ b/tests/mcp_inventory_test.go @@ -0,0 +1,21 @@ +package tests + +import ( + "testing" + + cmdmcp "seerr-cli/cmd/mcp" +) + +func TestMCPInventoryConstants(t *testing.T) { + // Verifies that the constants in inventory.go match the expected counts. + // When adding a new tool/resource/prompt, update inventory.go too. + if cmdmcp.MCPToolCount != 52 { + t.Errorf("MCPToolCount = %d, want 52", cmdmcp.MCPToolCount) + } + if cmdmcp.MCPResourceCount != 9 { + t.Errorf("MCPResourceCount = %d, want 9", cmdmcp.MCPResourceCount) + } + if cmdmcp.MCPPromptCount != 6 { + t.Errorf("MCPPromptCount = %d, want 6", cmdmcp.MCPPromptCount) + } +} diff --git a/tests/mcp_movies_ratings_combined_test.go b/tests/mcp_movies_ratings_combined_test.go new file mode 100644 index 0000000..de8cea8 --- /dev/null +++ b/tests/mcp_movies_ratings_combined_test.go @@ -0,0 +1,32 @@ +package tests + +import ( + "net/http" + "strings" + "testing" + + cmdmcp "seerr-cli/cmd/mcp" + + "github.com/stretchr/testify/assert" +) + +func TestMoviesRatingsCombinedHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasPrefix(r.URL.Path, "/api/v1/movie/") && strings.HasSuffix(r.URL.Path, "/ratingscombined") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"rt":{"criticsScore":94,"criticsRating":"Certified Fresh"},"imdb":{"id":"tt0816692","rating":8.7}}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.MoviesRatingsCombinedHandler() + result := callTool(t, handler, map[string]any{"movieId": float64(157336)}) + text := resultText(t, result) + + assert.Contains(t, text, "rt") + assert.Contains(t, text, "imdb") +} diff --git a/tests/mcp_prompts_test.go b/tests/mcp_prompts_test.go index 50710d9..70d7eee 100644 --- a/tests/mcp_prompts_test.go +++ b/tests/mcp_prompts_test.go @@ -135,3 +135,13 @@ func TestMCPAdminOverviewPrompt(t *testing.T) { assert.Contains(t, text, "issue_count") assert.Contains(t, text, "settings_jobs_list") } + +func TestReportIssuePromptNoMediaTypeArg(t *testing.T) { + result := callPrompt(t, cmdmcp.ReportIssuePromptHandler(), map[string]string{ + "media_title": "Inception", + }) + + text := promptText(t, result) + // issue_create has no mediaType parameter; the prompt must not instruct the AI to pass it. + assert.NotContains(t, text, "- mediaType: the type from search results") +} diff --git a/tests/mcp_search_company_test.go b/tests/mcp_search_company_test.go new file mode 100644 index 0000000..8edd57d --- /dev/null +++ b/tests/mcp_search_company_test.go @@ -0,0 +1,50 @@ +package tests + +import ( + "net/http" + "testing" + + cmdmcp "seerr-cli/cmd/mcp" + + "github.com/stretchr/testify/assert" +) + +func TestSearchCompanyHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/search/company" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":1,"results":[{"id":1,"name":"Warner Bros."}]}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchCompanyHandler() + result := callTool(t, handler, map[string]any{"query": "Warner"}) + text := resultText(t, result) + + assert.Contains(t, text, "Warner Bros.") +} + +func TestSearchKeywordHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/search/keyword" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":1,"results":[{"id":878,"name":"science fiction"}]}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchKeywordHandler() + result := callTool(t, handler, map[string]any{"query": "sci-fi"}) + text := resultText(t, result) + + assert.Contains(t, text, "science fiction") +} diff --git a/tests/mcp_settings_jobs_test.go b/tests/mcp_settings_jobs_test.go new file mode 100644 index 0000000..b9250c5 --- /dev/null +++ b/tests/mcp_settings_jobs_test.go @@ -0,0 +1,51 @@ +package tests + +import ( + "net/http" + "strings" + "testing" + + cmdmcp "seerr-cli/cmd/mcp" + + "github.com/stretchr/testify/assert" +) + +func TestSettingsJobsCancelHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cancel") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"plex-sync","name":"Plex Sync","type":"process","running":false}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SettingsJobsCancelHandler() + result := callTool(t, handler, map[string]any{"jobId": "plex-sync"}) + text := resultText(t, result) + + assert.Contains(t, text, "plex-sync") +} + +func TestSettingsJobsScheduleHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/schedule") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"plex-sync","name":"Plex Sync","type":"process","running":false}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SettingsJobsScheduleHandler() + result := callTool(t, handler, map[string]any{"jobId": "plex-sync", "schedule": "0 */5 * * *"}) + text := resultText(t, result) + + assert.Contains(t, text, "plex-sync") +} From 23d25f78977986d8056cdba4163b6c108ba1d965 Mon Sep 17 00:00:00 2001 From: Omid Astaraki Date: Mon, 16 Mar 2026 18:16:53 +0000 Subject: [PATCH 2/3] refactor(seerrclient): add internal client layer to hide generated API quirks Introduce internal/seerrclient with a concrete Client type that wraps the auto-generated pkg/api client and hides three generated-code quirks: - float32 IDs: MovieGet/Recommendations/Similar/Ratings methods accept int and convert internally so callers never see float32 casts. - Union-type unmarshal bug: SearchMulti and Discover* use RawGet to bypass the generated client, which deserialises TV results as PersonResult. - URL encoding: an encodingRoundTripper on the client replaces + with %20 for generated-client calls (company/keyword queries); RawGet already handles this for raw requests. - Trending defaults: DiscoverTrending strips mediaType=all and timeWindow=day before sending to avoid HTTP 400 on some server versions. Migrate cmd/search, cmd/movies, and cmd/mcp to use the new client. Delete the now-redundant encodingRoundTripper and newAPIClient from cmd/search/search.go. Add tests/seerrclient_test.go covering all five new behaviours (TDD). --- cmd/mcp/tools_movies.go | 32 ++++---- cmd/mcp/tools_search.go | 43 +++++----- cmd/movies/get.go | 14 ++-- cmd/movies/ratings.go | 8 +- cmd/movies/ratings_combined.go | 8 +- cmd/movies/recommendations.go | 20 +++-- cmd/movies/similar.go | 20 +++-- cmd/search/company.go | 9 ++- cmd/search/keyword.go | 9 ++- cmd/search/movies.go | 51 ++++++++---- cmd/search/multi.go | 26 +++--- cmd/search/search.go | 41 +--------- cmd/search/trending.go | 41 +++++++--- cmd/search/tv.go | 51 ++++++++---- internal/seerrclient/client.go | 75 ++++++++++++++++++ internal/seerrclient/movies.go | 62 +++++++++++++++ internal/seerrclient/search.go | 79 ++++++++++++++++++ tests/seerrclient_test.go | 141 +++++++++++++++++++++++++++++++++ 18 files changed, 544 insertions(+), 186 deletions(-) create mode 100644 internal/seerrclient/client.go create mode 100644 internal/seerrclient/movies.go create mode 100644 internal/seerrclient/search.go create mode 100644 tests/seerrclient_test.go 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..9e7ee6e 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{ @@ -18,22 +20,18 @@ var getCmd = &cobra.Command{ # Get details in Spanish seerr-cli movies get 603 --language es`, 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 } language, _ := cmd.Flags().GetString("language") - - req := apiClient.MoviesAPI.MovieMovieIdGet(ctx, float32(movieId)) - if cmd.Flags().Changed("language") { - req = req.Language(language) + if !cmd.Flags().Changed("language") { + language = "" } - res, r, err := req.Execute() - return apiutil.HandleResponse(cmd, r, err, res, isVerbose, "MovieMovieIdGet") + res, r, err := seerrclient.New().MovieGet(int(movieId), language) + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "MovieMovieIdGet") }, } 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/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..8d5b7ae 100644 --- a/cmd/search/multi.go +++ b/cmd/search/multi.go @@ -1,12 +1,10 @@ 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{ @@ -18,30 +16,24 @@ var multiCmd = &cobra.Command{ # 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() - 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)) 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/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/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) +} From 929f38f6fdc702e0a0db78466854915435f5f5ef Mon Sep 17 00:00:00 2001 From: Omid Astaraki <33129195+electather@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:29:39 +0000 Subject: [PATCH 3/3] feat(cli): improve ergonomics with output modes, doctor command, and generated docs (#91) --- CLAUDE.md | 13 ++- cmd/apiutil/output.go | 167 ++++++++++++++++++++++++++++++ cmd/docs/docs.go | 16 +++ cmd/docs/generate.go | 36 +++++++ cmd/doctor/check.go | 202 +++++++++++++++++++++++++++++++++++++ cmd/doctor/doctor.go | 23 +++++ cmd/movies/get.go | 16 ++- cmd/root.go | 6 +- cmd/search/multi.go | 12 ++- tests/doctor_test.go | 108 ++++++++++++++++++++ tests/output_mode_test.go | 113 +++++++++++++++++++++ tests/search_multi_test.go | 7 +- 12 files changed, 706 insertions(+), 13 deletions(-) create mode 100644 cmd/apiutil/output.go create mode 100644 cmd/docs/docs.go create mode 100644 cmd/docs/generate.go create mode 100644 cmd/doctor/check.go create mode 100644 cmd/doctor/doctor.go create mode 100644 tests/doctor_test.go create mode 100644 tests/output_mode_test.go 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/movies/get.go b/cmd/movies/get.go index 9e7ee6e..dfc3bb6 100644 --- a/cmd/movies/get.go +++ b/cmd/movies/get.go @@ -18,7 +18,10 @@ var getCmd = &cobra.Command{ seerr-cli movies get 603 # Get details in Spanish - seerr-cli movies get 603 --language es`, + 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 { @@ -30,12 +33,21 @@ var getCmd = &cobra.Command{ language = "" } + mode := apiutil.GetOutputMode(cmd) res, r, err := seerrclient.New().MovieGet(int(movieId), language) - return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "MovieMovieIdGet") + if err != nil { + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "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/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/multi.go b/cmd/search/multi.go index 8d5b7ae..69f7e0b 100644 --- a/cmd/search/multi.go +++ b/cmd/search/multi.go @@ -1,6 +1,7 @@ package search import ( + "seerr-cli/cmd/apiutil" "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" @@ -14,7 +15,10 @@ 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`, + 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") @@ -36,8 +40,9 @@ var multiCmd = &cobra.Command{ 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) }, } @@ -46,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/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