cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
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}