Something went wrong. Try again.
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm leaflet readability golang
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317// Movies & TV: Rotten Tomatoes with colly//// Music: Album of the Year with chromedp//// Books: OpenLibrary APIpackage services
import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time"
"github.com/stormlightlabs/noteleaf/internal/models" "github.com/stormlightlabs/noteleaf/internal/version" "golang.org/x/time/rate")
const ( // Open Library API endpoints OpenLibraryBaseURL string = "https://openlibrary.org" openLibrarySearch string = OpenLibraryBaseURL + "/search.json"
// Rate limiting: 180 requests per minute = 3 requests per second requestsPerSecond int = 3 burstLimit int = 5)
var ( // User agent for HTTP requests - uses version information set at build time userAgent = version.UserAgent("Noteleaf", "info@stormlightlabs.org"))
// APIService defines the contract for API interactionstype APIService interface { Get(ctx context.Context, id string) (*models.Model, error) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) Check(ctx context.Context) error Close() error}
// BookService implements APIService for Open Librarytype BookService struct { client *http.Client limiter *rate.Limiter baseURL string}
// NewBookService creates a new book service with rate limitingfunc NewBookService(baseURL string) *BookService { return &BookService{ client: &http.Client{Timeout: 30 * time.Second}, limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit), baseURL: baseURL, }}
// OpenLibrarySearchResponse represents the search response from Open Librarytype OpenLibrarySearchResponse struct { NumFound int `json:"numFound"` Start int `json:"start"` NumFoundExact bool `json:"numFoundExact"` Docs []OpenLibrarySearchDoc `json:"docs"`}
// OpenLibrarySearchDoc represents a book document in search resultstype OpenLibrarySearchDoc struct { Key string `json:"key"` Title string `json:"title"` AuthorName []string `json:"author_name"` FirstPublishYear int `json:"first_publish_year"` PublishYear []int `json:"publish_year"` Edition_count int `json:"edition_count"` ISBN []string `json:"isbn"` PublisherName []string `json:"publisher"` Subject []string `json:"subject"` CoverI int `json:"cover_i"` HasFulltext bool `json:"has_fulltext"` PublicScanB bool `json:"public_scan_b"` ReadinglogCount int `json:"readinglog_count"` WantToReadCount int `json:"want_to_read_count"` CurrentlyReading int `json:"currently_reading_count"` AlreadyReadCount int `json:"already_read_count"`}
// OpenLibraryWork represents a work details from Open Librarytype OpenLibraryWork struct { Key string `json:"key"` Title string `json:"title"` Authors []OpenLibraryAuthorRef `json:"authors"` Description any `json:"description"` // Can be string or object Subjects []string `json:"subjects"` Covers []int `json:"covers"` FirstPublishDate string `json:"first_publish_date"`}
// OpenLibraryAuthorRef represents an author reference in a worktype OpenLibraryAuthorRef struct { Author OpenLibraryAuthorKey `json:"author"` Type OpenLibraryType `json:"type"`}
// OpenLibraryAuthorKey represents an author keytype OpenLibraryAuthorKey struct { Key string `json:"key"`}
// OpenLibraryType represents a type referencetype OpenLibraryType struct { Key string `json:"key"`}
func (bs *BookService) buildSearchURL(query string, page, limit int) string { params := url.Values{} params.Add("q", query) params.Add("offset", strconv.Itoa((page-1)*limit)) params.Add("limit", strconv.Itoa(limit)) params.Add("fields", "key,title,author_name,first_publish_year,edition_count,isbn,publisher,subject,cover_i,has_fulltext") return bs.baseURL + "/search.json?" + params.Encode()}
// Search searches for books using the Open Library APIfunc (bs *BookService) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) { if err := bs.limiter.Wait(ctx); err != nil { return nil, fmt.Errorf("rate limit wait failed: %w", err) }
searchURL := bs.buildSearchURL(query, page, limit)
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) }
req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "application/json")
resp, err := bs.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned status %d", resp.StatusCode) }
var searchResp OpenLibrarySearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) }
var books []*models.Model for _, doc := range searchResp.Docs { book := bs.searchDocToBook(doc) var model models.Model = book books = append(books, &model) }
return books, nil}
// Get retrieves a specific book by Open Library work keyfunc (bs *BookService) Get(ctx context.Context, id string) (*models.Model, error) { if err := bs.limiter.Wait(ctx); err != nil { return nil, fmt.Errorf("rate limit wait failed: %w", err) }
workKey := id if !strings.HasPrefix(workKey, "/works/") { workKey = "/works/" + id }
workURL := bs.baseURL + workKey + ".json"
req, err := http.NewRequestWithContext(ctx, "GET", workURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) }
req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "application/json")
resp, err := bs.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("book not found: %s", id) }
if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned status %d", resp.StatusCode) }
var work OpenLibraryWork if err := json.NewDecoder(resp.Body).Decode(&work); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) }
book := bs.workToBook(work) var model models.Model = book return &model, nil}
// Check verifies the API connectionfunc (bs *BookService) Check(ctx context.Context) error { if err := bs.limiter.Wait(ctx); err != nil { return fmt.Errorf("rate limit wait failed: %w", err) }
req, err := http.NewRequestWithContext(ctx, "GET", bs.baseURL+"/search.json?q=test&limit=1", nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) }
req.Header.Set("User-Agent", userAgent)
resp, err := bs.client.Do(req) if err != nil { return fmt.Errorf("failed to connect to Open Library: %w", err) } defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return fmt.Errorf("open Library API returned status %d", resp.StatusCode) }
return nil}
// Close cleans up the service resources//// HTTP client doesn't need explicit cleanupfunc (bs *BookService) Close() error { return nil}
func (bs *BookService) searchDocToBook(doc OpenLibrarySearchDoc) *models.Book { book := &models.Book{ Title: doc.Title, Status: "queued", Added: time.Now(), }
if len(doc.AuthorName) > 0 { book.Author = strings.Join(doc.AuthorName, ", ") }
if doc.FirstPublishYear > 0 { // We don't have page count, so we'll leave it as 0 // TODO: Could potentially estimate based on edition count or other factors }
var notes []string if doc.Edition_count > 0 { notes = append(notes, fmt.Sprintf("%d editions", doc.Edition_count)) } if len(doc.PublisherName) > 0 { notes = append(notes, "Publishers: "+strings.Join(doc.PublisherName, ", ")) } if doc.CoverI > 0 { notes = append(notes, fmt.Sprintf("Cover ID: %d", doc.CoverI)) }
if len(notes) > 0 { book.Notes = strings.Join(notes, " | ") }
return book}
func (bs *BookService) workToBook(work OpenLibraryWork) *models.Book { book := &models.Book{ Title: work.Title, Status: "queued", Added: time.Now(), }
// TODO: Extract author names (would need additional API calls to get full names) if len(work.Authors) > 0 { var authorKeys []string for _, author := range work.Authors { key := strings.TrimPrefix(author.Author.Key, "/authors/") authorKeys = append(authorKeys, key) } book.Author = strings.Join(authorKeys, ", ") }
if work.Description != nil { switch desc := work.Description.(type) { case string: book.Notes = desc case map[string]any: if value, ok := desc["value"].(string); ok { book.Notes = value } } }
if book.Notes == "" && len(work.Subjects) > 0 { subjects := work.Subjects if len(subjects) > 5 { subjects = subjects[:5] } book.Notes = "Subjects: " + strings.Join(subjects, ", ") }
return book}