package shelfmark import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime" "net/http" "net/http/cookiejar" "net/url" "strconv" "strings" "sync" "time" ) // closeBody closes an HTTP response body and wraps any error. func closeBody(body io.ReadCloser) error { if err := body.Close(); err != nil { return fmt.Errorf("failed to close response body: %w", err) } return nil } // Client is an HTTP client for interacting with the Shelfmark server API. type Client struct { baseURL string username string password string httpClient *http.Client mu sync.Mutex authenticated bool } // NewClient creates a new Shelfmark API client. func NewClient(baseURL, username, password string) *Client { jar, _ := cookiejar.New(nil) return &Client{ baseURL: strings.TrimRight(baseURL, "/"), username: username, password: password, httpClient: &http.Client{ Jar: jar, Timeout: 30 * time.Second, }, } } // UpdateCredentials updates the client's base URL and credentials. // This is called when the plugin configuration changes. func (c *Client) UpdateCredentials(baseURL, username, password string) { c.mu.Lock() defer c.mu.Unlock() c.baseURL = strings.TrimRight(baseURL, "/") c.username = username c.password = password c.authenticated = false // Reset cookie jar on credential change. jar, _ := cookiejar.New(nil) c.httpClient.Jar = jar } // ensureAuthenticated logs in to the Shelfmark server if not already authenticated. func (c *Client) ensureAuthenticated() error { c.mu.Lock() defer c.mu.Unlock() if c.authenticated { return nil } return c.loginLocked() } // loginLocked performs authentication. Must be called with c.mu held. func (c *Client) loginLocked() (err error) { // First check if auth is required. authCheck, err := c.checkAuthLocked() if err != nil { return fmt.Errorf("failed to check auth status: %w", err) } if !authCheck.AuthRequired { // No authentication needed; mark as authenticated. c.authenticated = true return nil } if c.username == "" || c.password == "" { return fmt.Errorf("shelfmark requires authentication but no credentials are configured") } body, err := json.Marshal(map[string]any{ "username": c.username, "password": c.password, "remember_me": true, }) if err != nil { return fmt.Errorf("failed to marshal login request: %w", err) } resp, err := c.httpClient.Post(c.baseURL+"/api/auth/login", "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("login request failed: %w", err) } defer func() { if cerr := resp.Body.Close(); err == nil && cerr != nil { err = fmt.Errorf("failed to close response body: %w", cerr) } }() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("login failed (status %d): %s", resp.StatusCode, string(respBody)) } c.authenticated = true return nil } // checkAuthLocked checks if the Shelfmark server requires authentication. Must be called with c.mu held. func (c *Client) checkAuthLocked() (_ *AuthCheckResponse, err error) { resp, err := c.httpClient.Get(c.baseURL + "/api/auth/check") if err != nil { return nil, fmt.Errorf("auth check request failed: %w", err) } defer func() { if cerr := resp.Body.Close(); err == nil && cerr != nil { err = fmt.Errorf("failed to close response body: %w", cerr) } }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("auth check returned status %d", resp.StatusCode) } var result AuthCheckResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode auth check response: %w", err) } return &result, nil } // doRequest performs an HTTP request with authentication handling. // If a 401 is received, it re-authenticates and retries once. // The body parameter is a byte slice so it can be replayed on retry. func (c *Client) doRequest(ctx context.Context, method, path string, body []byte) (*http.Response, error) { if err := c.ensureAuthenticated(); err != nil { return nil, err } // Read baseURL under lock to avoid race with UpdateCredentials. c.mu.Lock() baseURL := c.baseURL c.mu.Unlock() reqURL := baseURL + path var bodyReader io.Reader if body != nil { bodyReader = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } // On 401, attempt re-authentication and retry. if resp.StatusCode == http.StatusUnauthorized { if err := resp.Body.Close(); err != nil { return nil, fmt.Errorf("failed to close response body: %w", err) } c.mu.Lock() c.authenticated = false err := c.loginLocked() c.mu.Unlock() if err != nil { return nil, fmt.Errorf("re-authentication failed: %w", err) } // Create a fresh body reader for the retry. var retryBody io.Reader if body != nil { retryBody = bytes.NewReader(body) } // Retry the request. req, err = http.NewRequestWithContext(ctx, method, reqURL, retryBody) if err != nil { return nil, fmt.Errorf("failed to create retry request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err = c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("retry request failed: %w", err) } } return resp, nil } // Ping checks that the Shelfmark server is reachable by hitting the // lightweight /api/auth/check endpoint. It does not require authentication. func (c *Client) Ping() error { c.mu.Lock() baseURL := c.baseURL c.mu.Unlock() resp, err := c.httpClient.Get(baseURL + "/api/auth/check") if err != nil { return fmt.Errorf("shelfmark server unreachable: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("shelfmark server returned status %d", resp.StatusCode) } return nil } // SearchBooks searches for books using the Shelfmark metadata search API. // It returns at most one result; use SearchBooksWithLimit for multiple results. func (c *Client) SearchBooks(query string) (*SearchResponse, error) { return c.SearchBooksWithLimit(query, 1) } // SearchBooksWithLimit searches for books with a configurable result limit. func (c *Client) SearchBooksWithLimit(query string, limit int) (_ *SearchResponse, err error) { params := url.Values{} params.Set("query", query) params.Set("limit", strconv.Itoa(limit)) resp, err := c.doRequest(context.Background(), "GET", "/api/metadata/search?"+params.Encode(), nil) if err != nil { return nil, fmt.Errorf("search request failed: %w", err) } defer func() { err = errors.Join(err, closeBody(resp.Body)) }() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("search failed (status %d): %s", resp.StatusCode, string(respBody)) } var result SearchResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode search response: %w", err) } return &result, nil } // GetReleases gets available releases (downloadable files) for a book. // If languages is non-empty, it is passed as a filter (e.g., "en" or "en,es"). func (c *Client) GetReleases(provider, bookID, languages string) (_ *ReleasesResponse, err error) { params := url.Values{} params.Set("provider", provider) params.Set("book_id", bookID) if languages != "" { params.Set("languages", languages) } resp, err := c.doRequest(context.Background(), "GET", "/api/releases?"+params.Encode(), nil) if err != nil { return nil, fmt.Errorf("releases request failed: %w", err) } defer func() { err = errors.Join(err, closeBody(resp.Body)) }() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get releases failed (status %d): %s", resp.StatusCode, string(respBody)) } var result ReleasesResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode releases response: %w", err) } return &result, nil } // QueueDownload queues a release for download on the Shelfmark server. func (c *Client) QueueDownload(release *Release) (_ *QueueResponse, err error) { body, err := json.Marshal(release) if err != nil { return nil, fmt.Errorf("failed to marshal release: %w", err) } resp, err := c.doRequest(context.Background(), "POST", "/api/releases/download", body) if err != nil { return nil, fmt.Errorf("queue download request failed: %w", err) } defer func() { err = errors.Join(err, closeBody(resp.Body)) }() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("queue download failed (status %d): %s", resp.StatusCode, string(respBody)) } var result QueueResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode queue response: %w", err) } return &result, nil } // StatusResponse represents the Shelfmark /api/status response. // The response is grouped by status category: // // { // "complete": { "": { ...task... }, ... }, // "queued": { "": { ...task... }, ... }, // "downloading": { "": { ...task... }, ... }, // "error": { "": { ...task... }, ... }, // "cancelled": { "": { ...task... }, ... }, // "locating": { "": { ...task... }, ... }, // "resolving": { "": { ...task... }, ... }, // } type statusResponse map[string]map[string]json.RawMessage // GetStatus retrieves the current download queue status. func (c *Client) GetStatus() (_ statusResponse, err error) { resp, err := c.doRequest(context.Background(), "GET", "/api/status", nil) if err != nil { return nil, fmt.Errorf("status request failed: %w", err) } defer func() { err = errors.Join(err, closeBody(resp.Body)) }() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get status failed (status %d): %s", resp.StatusCode, string(respBody)) } var result statusResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode status response: %w", err) } return result, nil } // GetTaskStatus searches for a task by its source_id across all status categories. // Returns the category name (e.g. "complete", "error", "queued", "downloading") // and whether the task was found. func GetTaskStatus(status statusResponse, taskID string) (string, bool) { for category, tasks := range status { if _, exists := tasks[taskID]; exists { return category, true } } return "", false } // DownloadFile downloads a completed book file from the Shelfmark server. // Returns the file data, filename, and any error. func (c *Client) DownloadFile(taskID string) (_ []byte, _ string, err error) { params := url.Values{} params.Set("id", taskID) // Use a per-request timeout instead of mutating the shared httpClient.Timeout. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() resp, err := c.doRequest(ctx, "GET", "/api/localdownload?"+params.Encode(), nil) if err != nil { return nil, "", fmt.Errorf("download request failed: %w", err) } defer func() { err = errors.Join(err, closeBody(resp.Body)) }() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, "", fmt.Errorf("download failed (status %d): %s", resp.StatusCode, string(respBody)) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, "", fmt.Errorf("failed to read download response: %w", err) } // Extract filename from Content-Disposition header. filename := "" if cd := resp.Header.Get("Content-Disposition"); cd != "" { _, params, err := mime.ParseMediaType(cd) if err == nil { filename = params["filename"] } } if filename == "" { filename = taskID } return data, filename, nil } // DownloadCover downloads a cover image from the Shelfmark server. // The coverPath should be the path portion of the cover URL (e.g., /api/covers/...). func (c *Client) DownloadCover(coverPath string) (_ []byte, _ string, err error) { // The cover URL from search results is relative to the Shelfmark base URL. resp, err := c.doRequest(context.Background(), "GET", coverPath, nil) if err != nil { return nil, "", fmt.Errorf("cover download failed: %w", err) } defer func() { err = errors.Join(err, closeBody(resp.Body)) }() if resp.StatusCode != http.StatusOK { return nil, "", fmt.Errorf("cover download returned status %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, "", fmt.Errorf("failed to read cover data: %w", err) } // Determine a filename from the content type. contentType := resp.Header.Get("Content-Type") ext := ".jpg" switch { case strings.Contains(contentType, "png"): ext = ".png" case strings.Contains(contentType, "gif"): ext = ".gif" case strings.Contains(contentType, "webp"): ext = ".webp" } return data, "cover" + ext, nil }