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 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}