cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}