package providers import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" ) // OpenLibrary matches books by title and author. No API key, no registration. type OpenLibrary struct { // BaseURL is overridable so tests can point at a local server. BaseURL string Client *http.Client } // NewOpenLibrary builds the provider with a bounded HTTP client. func NewOpenLibrary() *OpenLibrary { return &OpenLibrary{ BaseURL: "https://openlibrary.org", Client: &http.Client{Timeout: 10 * time.Second}, } } func (p *OpenLibrary) Name() string { return "openlibrary" } func (p *OpenLibrary) Kinds() []string { return []string{"ebooks", "audiobooks"} } // olSearchResponse is the subset of the search API this provider reads. type olSearchResponse struct { Docs []struct { Key string `json:"key"` Title string `json:"title"` AuthorName []string `json:"author_name"` FirstPublish int `json:"first_publish_year"` Publisher []string `json:"publisher"` Language []string `json:"language"` CoverID int `json:"cover_i"` ISBN []string `json:"isbn"` } `json:"docs"` } func (p *OpenLibrary) Search(ctx context.Context, q Query) ([]Candidate, error) { params := url.Values{} params.Set("limit", "5") params.Set("fields", "key,title,author_name,first_publish_year,publisher,language,cover_i,isbn") if q.ISBN != "" { params.Set("isbn", q.ISBN) } else { if q.Title != "" { params.Set("title", q.Title) } if q.Author != "" { params.Set("author", q.Author) } } if params.Get("isbn") == "" && params.Get("title") == "" { return nil, nil } endpoint := strings.TrimRight(p.BaseURL, "/") + "/search.json?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } // Open Library asks callers to identify themselves. req.Header.Set("User-Agent", "Vellum/0.1 (self-hosted media server)") resp, err := p.Client.Do(req) if err != nil { return nil, fmt.Errorf("openlibrary: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("openlibrary: status %d", resp.StatusCode) } var parsed olSearchResponse if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { return nil, fmt.Errorf("openlibrary: decode: %w", err) } out := make([]Candidate, 0, len(parsed.Docs)) for _, d := range parsed.Docs { if d.Title == "" { continue } c := Candidate{ Provider: p.Name(), ExternalID: d.Key, Title: d.Title, Authors: d.AuthorName, Year: d.FirstPublish, Language: first(d.Language), } if len(d.Publisher) > 0 { c.Publisher = d.Publisher[0] } if len(d.ISBN) > 0 { c.ISBN = d.ISBN[0] } if d.CoverID > 0 { c.CoverURL = "https://covers.openlibrary.org/b/id/" + strconv.Itoa(d.CoverID) + "-L.jpg" } out = append(out, c) } return out, nil } func first(s []string) string { if len(s) == 0 { return "" } return strings.TrimSpace(s[0]) }