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