cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 316 lines 9.1 kB view raw
1// Movies & TV: Rotten Tomatoes with colly 2// 3// Music: Album of the Year with chromedp 4// 5// Books: OpenLibrary API 6package services 7 8import ( 9 "context" 10 "encoding/json" 11 "fmt" 12 "net/http" 13 "net/url" 14 "strconv" 15 "strings" 16 "time" 17 18 "github.com/stormlightlabs/noteleaf/internal/models" 19 "github.com/stormlightlabs/noteleaf/internal/version" 20 "golang.org/x/time/rate" 21) 22 23const ( 24 // Open Library API endpoints 25 OpenLibraryBaseURL string = "https://openlibrary.org" 26 openLibrarySearch string = OpenLibraryBaseURL + "/search.json" 27 28 // Rate limiting: 180 requests per minute = 3 requests per second 29 requestsPerSecond int = 3 30 burstLimit int = 5 31) 32 33var ( 34 // User agent for HTTP requests - uses version information set at build time 35 userAgent = version.UserAgent("Noteleaf", "info@stormlightlabs.org") 36) 37 38// APIService defines the contract for API interactions 39type APIService interface { 40 Get(ctx context.Context, id string) (*models.Model, error) 41 Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) 42 Check(ctx context.Context) error 43 Close() error 44} 45 46// BookService implements APIService for Open Library 47type BookService struct { 48 client *http.Client 49 limiter *rate.Limiter 50 baseURL string 51} 52 53// NewBookService creates a new book service with rate limiting 54func NewBookService(baseURL string) *BookService { 55 return &BookService{ 56 client: &http.Client{Timeout: 30 * time.Second}, 57 limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit), 58 baseURL: baseURL, 59 } 60} 61 62// OpenLibrarySearchResponse represents the search response from Open Library 63type OpenLibrarySearchResponse struct { 64 NumFound int `json:"numFound"` 65 Start int `json:"start"` 66 NumFoundExact bool `json:"numFoundExact"` 67 Docs []OpenLibrarySearchDoc `json:"docs"` 68} 69 70// OpenLibrarySearchDoc represents a book document in search results 71type OpenLibrarySearchDoc struct { 72 Key string `json:"key"` 73 Title string `json:"title"` 74 AuthorName []string `json:"author_name"` 75 FirstPublishYear int `json:"first_publish_year"` 76 PublishYear []int `json:"publish_year"` 77 Edition_count int `json:"edition_count"` 78 ISBN []string `json:"isbn"` 79 PublisherName []string `json:"publisher"` 80 Subject []string `json:"subject"` 81 CoverI int `json:"cover_i"` 82 HasFulltext bool `json:"has_fulltext"` 83 PublicScanB bool `json:"public_scan_b"` 84 ReadinglogCount int `json:"readinglog_count"` 85 WantToReadCount int `json:"want_to_read_count"` 86 CurrentlyReading int `json:"currently_reading_count"` 87 AlreadyReadCount int `json:"already_read_count"` 88} 89 90// OpenLibraryWork represents a work details from Open Library 91type OpenLibraryWork struct { 92 Key string `json:"key"` 93 Title string `json:"title"` 94 Authors []OpenLibraryAuthorRef `json:"authors"` 95 Description any `json:"description"` // Can be string or object 96 Subjects []string `json:"subjects"` 97 Covers []int `json:"covers"` 98 FirstPublishDate string `json:"first_publish_date"` 99} 100 101// OpenLibraryAuthorRef represents an author reference in a work 102type OpenLibraryAuthorRef struct { 103 Author OpenLibraryAuthorKey `json:"author"` 104 Type OpenLibraryType `json:"type"` 105} 106 107// OpenLibraryAuthorKey represents an author key 108type OpenLibraryAuthorKey struct { 109 Key string `json:"key"` 110} 111 112// OpenLibraryType represents a type reference 113type OpenLibraryType struct { 114 Key string `json:"key"` 115} 116 117func (bs *BookService) buildSearchURL(query string, page, limit int) string { 118 params := url.Values{} 119 params.Add("q", query) 120 params.Add("offset", strconv.Itoa((page-1)*limit)) 121 params.Add("limit", strconv.Itoa(limit)) 122 params.Add("fields", "key,title,author_name,first_publish_year,edition_count,isbn,publisher,subject,cover_i,has_fulltext") 123 return bs.baseURL + "/search.json?" + params.Encode() 124} 125 126// Search searches for books using the Open Library API 127func (bs *BookService) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) { 128 if err := bs.limiter.Wait(ctx); err != nil { 129 return nil, fmt.Errorf("rate limit wait failed: %w", err) 130 } 131 132 searchURL := bs.buildSearchURL(query, page, limit) 133 134 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) 135 if err != nil { 136 return nil, fmt.Errorf("failed to create request: %w", err) 137 } 138 139 req.Header.Set("User-Agent", userAgent) 140 req.Header.Set("Accept", "application/json") 141 142 resp, err := bs.client.Do(req) 143 if err != nil { 144 return nil, fmt.Errorf("failed to make request: %w", err) 145 } 146 defer resp.Body.Close() 147 148 if resp.StatusCode != http.StatusOK { 149 return nil, fmt.Errorf("API returned status %d", resp.StatusCode) 150 } 151 152 var searchResp OpenLibrarySearchResponse 153 if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { 154 return nil, fmt.Errorf("failed to decode response: %w", err) 155 } 156 157 var books []*models.Model 158 for _, doc := range searchResp.Docs { 159 book := bs.searchDocToBook(doc) 160 var model models.Model = book 161 books = append(books, &model) 162 } 163 164 return books, nil 165} 166 167// Get retrieves a specific book by Open Library work key 168func (bs *BookService) Get(ctx context.Context, id string) (*models.Model, error) { 169 if err := bs.limiter.Wait(ctx); err != nil { 170 return nil, fmt.Errorf("rate limit wait failed: %w", err) 171 } 172 173 workKey := id 174 if !strings.HasPrefix(workKey, "/works/") { 175 workKey = "/works/" + id 176 } 177 178 workURL := bs.baseURL + workKey + ".json" 179 180 req, err := http.NewRequestWithContext(ctx, "GET", workURL, nil) 181 if err != nil { 182 return nil, fmt.Errorf("failed to create request: %w", err) 183 } 184 185 req.Header.Set("User-Agent", userAgent) 186 req.Header.Set("Accept", "application/json") 187 188 resp, err := bs.client.Do(req) 189 if err != nil { 190 return nil, fmt.Errorf("failed to make request: %w", err) 191 } 192 defer resp.Body.Close() 193 194 if resp.StatusCode == http.StatusNotFound { 195 return nil, fmt.Errorf("book not found: %s", id) 196 } 197 198 if resp.StatusCode != http.StatusOK { 199 return nil, fmt.Errorf("API returned status %d", resp.StatusCode) 200 } 201 202 var work OpenLibraryWork 203 if err := json.NewDecoder(resp.Body).Decode(&work); err != nil { 204 return nil, fmt.Errorf("failed to decode response: %w", err) 205 } 206 207 book := bs.workToBook(work) 208 var model models.Model = book 209 return &model, nil 210} 211 212// Check verifies the API connection 213func (bs *BookService) Check(ctx context.Context) error { 214 if err := bs.limiter.Wait(ctx); err != nil { 215 return fmt.Errorf("rate limit wait failed: %w", err) 216 } 217 218 req, err := http.NewRequestWithContext(ctx, "GET", bs.baseURL+"/search.json?q=test&limit=1", nil) 219 if err != nil { 220 return fmt.Errorf("failed to create request: %w", err) 221 } 222 223 req.Header.Set("User-Agent", userAgent) 224 225 resp, err := bs.client.Do(req) 226 if err != nil { 227 return fmt.Errorf("failed to connect to Open Library: %w", err) 228 } 229 defer resp.Body.Close() 230 231 if resp.StatusCode != http.StatusOK { 232 return fmt.Errorf("open Library API returned status %d", resp.StatusCode) 233 } 234 235 return nil 236} 237 238// Close cleans up the service resources 239// 240// HTTP client doesn't need explicit cleanup 241func (bs *BookService) Close() error { 242 return nil 243} 244 245func (bs *BookService) searchDocToBook(doc OpenLibrarySearchDoc) *models.Book { 246 book := &models.Book{ 247 Title: doc.Title, 248 Status: "queued", 249 Added: time.Now(), 250 } 251 252 if len(doc.AuthorName) > 0 { 253 book.Author = strings.Join(doc.AuthorName, ", ") 254 } 255 256 if doc.FirstPublishYear > 0 { 257 // We don't have page count, so we'll leave it as 0 258 // TODO: Could potentially estimate based on edition count or other factors 259 } 260 261 var notes []string 262 if doc.Edition_count > 0 { 263 notes = append(notes, fmt.Sprintf("%d editions", doc.Edition_count)) 264 } 265 if len(doc.PublisherName) > 0 { 266 notes = append(notes, "Publishers: "+strings.Join(doc.PublisherName, ", ")) 267 } 268 if doc.CoverI > 0 { 269 notes = append(notes, fmt.Sprintf("Cover ID: %d", doc.CoverI)) 270 } 271 272 if len(notes) > 0 { 273 book.Notes = strings.Join(notes, " | ") 274 } 275 276 return book 277} 278 279func (bs *BookService) workToBook(work OpenLibraryWork) *models.Book { 280 book := &models.Book{ 281 Title: work.Title, 282 Status: "queued", 283 Added: time.Now(), 284 } 285 286 // TODO: Extract author names (would need additional API calls to get full names) 287 if len(work.Authors) > 0 { 288 var authorKeys []string 289 for _, author := range work.Authors { 290 key := strings.TrimPrefix(author.Author.Key, "/authors/") 291 authorKeys = append(authorKeys, key) 292 } 293 book.Author = strings.Join(authorKeys, ", ") 294 } 295 296 if work.Description != nil { 297 switch desc := work.Description.(type) { 298 case string: 299 book.Notes = desc 300 case map[string]any: 301 if value, ok := desc["value"].(string); ok { 302 book.Notes = value 303 } 304 } 305 } 306 307 if book.Notes == "" && len(work.Subjects) > 0 { 308 subjects := work.Subjects 309 if len(subjects) > 5 { 310 subjects = subjects[:5] 311 } 312 book.Notes = "Subjects: " + strings.Join(subjects, ", ") 313 } 314 315 return book 316}