cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1package services
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/PuerkitoBio/goquery"
14 "github.com/stormlightlabs/noteleaf/internal/models"
15 "golang.org/x/time/rate"
16)
17
18type MediaKind string
19
20const (
21 TVKind MediaKind = "tv"
22 MovieKind MediaKind = "movie"
23)
24
25type Media struct {
26 Title string
27 Link string
28 Type MediaKind
29 CriticScore string
30 CertifiedFresh bool
31}
32
33type Person struct {
34 Name string `json:"name"`
35 SameAs string `json:"sameAs"`
36 Image string `json:"image"`
37}
38
39type AggregateRating struct {
40 RatingValue string `json:"ratingValue"`
41 RatingCount int `json:"ratingCount"`
42 ReviewCount int `json:"reviewCount"`
43}
44
45type Season struct {
46 Name string `json:"name"`
47 URL string `json:"url"`
48}
49
50type PartOfSeries struct {
51 Name string `json:"name"`
52 URL string `json:"url"`
53}
54
55type TVSeries struct {
56 Context string `json:"@context"`
57 Type string `json:"@type"`
58 Name string `json:"name"`
59 URL string `json:"url"`
60 Description string `json:"description"`
61 Image string `json:"image"`
62 Genre []string `json:"genre"`
63 ContentRating string `json:"contentRating"`
64 DateCreated string `json:"dateCreated"`
65 NumberOfSeasons int `json:"numberOfSeasons"`
66 Actors []Person `json:"actor"`
67 Producers []Person `json:"producer"`
68 AggregateRating AggregateRating `json:"aggregateRating"`
69 Seasons []Season `json:"containsSeason"`
70}
71
72type Movie struct {
73 Context string `json:"@context"`
74 Type string `json:"@type"`
75 Name string `json:"name"`
76 URL string `json:"url"`
77 Description string `json:"description"`
78 Image string `json:"image"`
79 Genre []string `json:"genre"`
80 ContentRating string `json:"contentRating"`
81 DateCreated string `json:"dateCreated"`
82 Actors []Person `json:"actor"`
83 Directors []Person `json:"director"`
84 Producers []Person `json:"producer"`
85 AggregateRating AggregateRating `json:"aggregateRating"`
86}
87
88type TVSeason struct {
89 Context string `json:"@context"`
90 Type string `json:"@type"`
91 Name string `json:"name"`
92 URL string `json:"url"`
93 Description string `json:"description"`
94 Image string `json:"image"`
95 SeasonNumber int `json:"seasonNumber"`
96 DatePublished string `json:"datePublished"`
97 PartOfSeries PartOfSeries `json:"partOfSeries"`
98 AggregateRating AggregateRating `json:"aggregateRating"`
99}
100
101type MovieService struct {
102 client *http.Client
103 limiter *rate.Limiter
104 fetcher Fetchable
105 searcher Searchable
106 baseURL string
107}
108
109type TVService struct {
110 client *http.Client
111 limiter *rate.Limiter
112 fetcher Fetchable
113 searcher Searchable
114 baseURL string
115}
116
117// ParseSearch parses Rotten Tomatoes search results HTML into Media entries.
118var ParseSearch = func(r io.Reader) ([]Media, error) {
119 doc, err := goquery.NewDocumentFromReader(r)
120 if err != nil {
121 return nil, err
122 }
123
124 var results []Media
125 doc.Find("search-page-result").Each(func(i int, resultBlock *goquery.Selection) {
126 mediaType, _ := resultBlock.Attr("type")
127
128 resultBlock.Find("search-page-media-row").Each(func(j int, s *goquery.Selection) {
129 link, _ := s.Find("a[slot='thumbnail']").Attr("href")
130 if link == "" {
131 link, _ = s.Find("a[slot='title']").Attr("href")
132 if link == "" {
133 return
134 }
135 }
136
137 title := s.Find("a[slot='title']").Text()
138
139 var itemKind MediaKind
140 switch mediaType {
141 case "movie":
142 itemKind = MovieKind
143 case "tvSeries":
144 itemKind = TVKind
145 default:
146 if strings.HasPrefix(link, "/m/") {
147 itemKind = MovieKind
148 } else if strings.HasPrefix(link, "/tv/") {
149 itemKind = TVKind
150 }
151 }
152
153 score, _ := s.Attr("tomatometerscore")
154 if score == "" {
155 score = "--"
156 }
157
158 certified := false
159 if v, ok := s.Attr("tomatometeriscertified"); ok && v == "true" {
160 certified = true
161 }
162
163 results = append(results, Media{
164 Title: strings.TrimSpace(title),
165 Link: link,
166 Type: itemKind,
167 CriticScore: score,
168 CertifiedFresh: certified,
169 })
170 })
171 })
172
173 return results, nil
174}
175
176var ExtractTVSeriesMetadata = func(r io.Reader) (*TVSeries, error) {
177 doc, err := goquery.NewDocumentFromReader(r)
178 if err != nil {
179 return nil, err
180 }
181 var series TVSeries
182 found := false
183 doc.Find("script[type='application/ld+json']").Each(func(i int, s *goquery.Selection) {
184 var tmp map[string]any
185 if err := json.Unmarshal([]byte(s.Text()), &tmp); err == nil {
186 if t, ok := tmp["@type"].(string); ok && t == "TVSeries" {
187 if err := json.Unmarshal([]byte(s.Text()), &series); err == nil {
188 found = true
189 }
190 }
191 }
192 })
193 if !found {
194 return nil, fmt.Errorf("no TVSeries JSON-LD found")
195 }
196 return &series, nil
197}
198
199var ExtractMovieMetadata = func(r io.Reader) (*Movie, error) {
200 doc, err := goquery.NewDocumentFromReader(r)
201 if err != nil {
202 return nil, err
203 }
204 var movie Movie
205 found := false
206 doc.Find("script[type='application/ld+json']").Each(func(i int, s *goquery.Selection) {
207 var tmp map[string]any
208 if err := json.Unmarshal([]byte(s.Text()), &tmp); err == nil {
209 if t, ok := tmp["@type"].(string); ok && t == "Movie" {
210 if err := json.Unmarshal([]byte(s.Text()), &movie); err == nil {
211 found = true
212 }
213 }
214 }
215 })
216 if !found {
217 return nil, fmt.Errorf("no Movie JSON-LD found")
218 }
219 return &movie, nil
220}
221
222var ExtractTVSeasonMetadata = func(r io.Reader) (*TVSeason, error) {
223 doc, err := goquery.NewDocumentFromReader(r)
224 if err != nil {
225 return nil, err
226 }
227 var season TVSeason
228 found := false
229 doc.Find("script[type='application/ld+json']").Each(func(i int, s *goquery.Selection) {
230 var tmp map[string]any
231 if err := json.Unmarshal([]byte(s.Text()), &tmp); err == nil {
232 if t, ok := tmp["@type"].(string); ok && t == "TVSeason" {
233 if err := json.Unmarshal([]byte(s.Text()), &season); err == nil {
234 found = true
235 }
236 }
237 }
238 })
239 if !found {
240 return nil, fmt.Errorf("no TVSeason JSON-LD found")
241 }
242
243 if season.SeasonNumber == 0 {
244 if season.URL != "" {
245 parts := strings.SplitSeq(season.URL, "/")
246 for part := range parts {
247 if strings.HasPrefix(part, "s") && len(part) > 1 {
248 if num, err := strconv.Atoi(part[1:]); err == nil {
249 season.SeasonNumber = num
250 break
251 }
252 }
253 }
254 }
255
256 if season.SeasonNumber == 0 && season.Name != "" {
257 parts := strings.Fields(season.Name)
258 for i, part := range parts {
259 if strings.ToLower(part) == "season" && i+1 < len(parts) {
260 if num, err := strconv.Atoi(parts[i+1]); err == nil {
261 season.SeasonNumber = num
262 break
263 }
264 }
265 }
266 }
267 }
268
269 return &season, nil
270}
271
272// NewMovieService creates a new movie service with rate limiting
273func NewMovieService() *MovieService {
274 return NewMovieSrvWithOpts("https://www.rottentomatoes.com", &DefaultFetcher{}, &DefaultFetcher{})
275}
276
277// NewMovieSrvWithOpts creates a new movie service with custom dependencies (for testing)
278func NewMovieSrvWithOpts(baseURL string, fetcher Fetchable, searcher Searchable) *MovieService {
279 return &MovieService{
280 client: &http.Client{Timeout: 30 * time.Second},
281 limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit),
282 baseURL: baseURL,
283 fetcher: fetcher,
284 searcher: searcher,
285 }
286}
287
288// Search searches for movies on Rotten Tomatoes
289func (s *MovieService) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) {
290 if err := s.limiter.Wait(ctx); err != nil {
291 return nil, fmt.Errorf("rate limit wait failed: %w", err)
292 }
293
294 results, err := s.searcher.Search(query)
295 if err != nil {
296 return nil, fmt.Errorf("failed to search rotten tomatoes: %w", err)
297 }
298
299 var movies []*models.Model
300 for _, media := range results {
301 if media.Type == "movie" {
302 movie := &models.Movie{
303 Title: media.Title,
304 Status: "queued",
305 Added: time.Now(),
306 Notes: fmt.Sprintf("Critic Score: %s, Certified: %v, URL: %s", media.CriticScore, media.CertifiedFresh, media.Link),
307 }
308 var m models.Model = movie
309 movies = append(movies, &m)
310 }
311 }
312
313 start := (page - 1) * limit
314 end := start + limit
315 if start > len(movies) {
316 return []*models.Model{}, nil
317 }
318 if end > len(movies) {
319 end = len(movies)
320 }
321
322 return movies[start:end], nil
323}
324
325// Get retrieves a specific movie by its Rotten Tomatoes URL
326func (s *MovieService) Get(ctx context.Context, id string) (*models.Model, error) {
327 if err := s.limiter.Wait(ctx); err != nil {
328 return nil, fmt.Errorf("rate limit wait failed: %w", err)
329 }
330
331 data, err := s.fetcher.MovieRequest(id)
332 if err != nil {
333 return nil, fmt.Errorf("failed to fetch movie: %w", err)
334 }
335
336 movie := &models.Movie{
337 Title: data.Name,
338 Status: "queued",
339 Added: time.Now(),
340 Notes: data.Description,
341 }
342
343 if data.DateCreated != "" {
344 if year, err := strconv.Atoi(strings.Split(data.DateCreated, "-")[0]); err == nil {
345 movie.Year = year
346 }
347 }
348
349 var model models.Model = movie
350 return &model, nil
351}
352
353// Check verifies the API connection to Rotten Tomatoes
354func (s *MovieService) Check(ctx context.Context) error {
355 if err := s.limiter.Wait(ctx); err != nil {
356 return fmt.Errorf("rate limit wait failed: %w", err)
357 }
358
359 _, err := s.fetcher.MakeRequest(s.baseURL)
360 return err
361}
362
363// Close cleans up the service resources
364func (s *MovieService) Close() error {
365 return nil
366}
367
368// NewTVService creates a new TV service with rate limiting
369func NewTVService() *TVService {
370 return NewTVServiceWithDeps("https://www.rottentomatoes.com", &DefaultFetcher{}, &DefaultFetcher{})
371}
372
373// NewTVServiceWithDeps creates a new TV service with custom dependencies (for testing)
374func NewTVServiceWithDeps(baseURL string, fetcher Fetchable, searcher Searchable) *TVService {
375 return &TVService{
376 client: &http.Client{Timeout: 30 * time.Second},
377 limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit),
378 baseURL: baseURL,
379 fetcher: fetcher,
380 searcher: searcher,
381 }
382}
383
384// Search searches for TV shows on Rotten Tomatoes
385func (s *TVService) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) {
386 if err := s.limiter.Wait(ctx); err != nil {
387 return nil, fmt.Errorf("rate limit wait failed: %w", err)
388 }
389
390 results, err := s.searcher.Search(query)
391 if err != nil {
392 return nil, fmt.Errorf("failed to search rotten tomatoes: %w", err)
393 }
394
395 var shows []*models.Model
396 for _, media := range results {
397 if media.Type == "tv" {
398 show := &models.TVShow{
399 Title: media.Title,
400 Status: "queued",
401 Added: time.Now(),
402 Notes: fmt.Sprintf("Critic Score: %s, Certified: %v, URL: %s", media.CriticScore, media.CertifiedFresh, media.Link),
403 }
404 var m models.Model = show
405 shows = append(shows, &m)
406 }
407 }
408
409 start := (page - 1) * limit
410 end := start + limit
411 if start > len(shows) {
412 return []*models.Model{}, nil
413 }
414 if end > len(shows) {
415 end = len(shows)
416 }
417
418 return shows[start:end], nil
419}
420
421// Get retrieves a specific TV show by its Rotten Tomatoes URL
422func (s *TVService) Get(ctx context.Context, id string) (*models.Model, error) {
423 if err := s.limiter.Wait(ctx); err != nil {
424 return nil, fmt.Errorf("rate limit wait failed: %w", err)
425 }
426
427 seriesData, err := s.fetcher.TVRequest(id)
428 if err != nil {
429 return nil, fmt.Errorf("failed to fetch tv series: %w", err)
430 }
431
432 show := &models.TVShow{
433 Title: seriesData.Name,
434 Status: "queued",
435 Added: time.Now(),
436 Notes: seriesData.Description,
437 }
438
439 if seriesData.NumberOfSeasons > 0 {
440 show.Notes = fmt.Sprintf("%s\nSeasons: %d", show.Notes, seriesData.NumberOfSeasons)
441 }
442
443 var model models.Model = show
444 return &model, nil
445}
446
447// Check verifies the API connection to Rotten Tomatoes
448func (s *TVService) Check(ctx context.Context) error {
449 if err := s.limiter.Wait(ctx); err != nil {
450 return fmt.Errorf("rate limit wait failed: %w", err)
451 }
452
453 _, err := s.fetcher.MakeRequest(s.baseURL)
454 return err
455}
456
457// Close cleans up the service resources
458func (s *TVService) Close() error {
459 return nil
460}