youtube.go 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package processor // import "miniflux.app/v2/internal/reader/processor"
  4. import (
  5. "encoding/json"
  6. "fmt"
  7. "log/slog"
  8. "net/url"
  9. "strings"
  10. "time"
  11. "miniflux.app/v2/internal/config"
  12. "miniflux.app/v2/internal/model"
  13. "miniflux.app/v2/internal/proxyrotator"
  14. "miniflux.app/v2/internal/reader/fetcher"
  15. )
  16. func isYouTubeVideoURL(websiteURL string) bool {
  17. return strings.Contains(websiteURL, "youtube.com/watch?v=")
  18. }
  19. func getVideoIDFromYouTubeURL(websiteURL string) string {
  20. parsedWebsiteURL, err := url.Parse(websiteURL)
  21. if err != nil {
  22. return ""
  23. }
  24. return parsedWebsiteURL.Query().Get("v")
  25. }
  26. func shouldFetchYouTubeWatchTimeForSingleEntry(entry *model.Entry) bool {
  27. return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() == "" && isYouTubeVideoURL(entry.URL)
  28. }
  29. func shouldFetchYouTubeWatchTimeInBulk() bool {
  30. return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() != ""
  31. }
  32. func fetchYouTubeWatchTimeForSingleEntry(websiteURL string) (int, error) {
  33. return fetchWatchTime(websiteURL, `meta[itemprop="duration"]`, true)
  34. }
  35. func fetchYouTubeWatchTimeInBulk(entries []*model.Entry) {
  36. videosEntriesMapping := make(map[string]*model.Entry, len(entries))
  37. videoIDs := make([]string, 0, len(entries))
  38. for _, entry := range entries {
  39. if !isYouTubeVideoURL(entry.URL) {
  40. continue
  41. }
  42. youtubeVideoID := getVideoIDFromYouTubeURL(entry.URL)
  43. if youtubeVideoID == "" {
  44. continue
  45. }
  46. videosEntriesMapping[youtubeVideoID] = entry
  47. videoIDs = append(videoIDs, youtubeVideoID)
  48. }
  49. if len(videoIDs) == 0 {
  50. return
  51. }
  52. watchTimeMap, err := fetchYouTubeWatchTimeFromApiInBulk(videoIDs)
  53. if err != nil {
  54. slog.Warn("Unable to fetch YouTube watch time in bulk", slog.Any("error", err))
  55. return
  56. }
  57. for videoID, watchTime := range watchTimeMap {
  58. if entry, ok := videosEntriesMapping[videoID]; ok {
  59. entry.ReadingTime = int(watchTime.Minutes())
  60. }
  61. }
  62. }
  63. func fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Duration, error) {
  64. slog.Debug("Fetching YouTube watch time in bulk", slog.Any("video_ids", videoIDs))
  65. apiQuery := url.Values{}
  66. apiQuery.Set("id", strings.Join(videoIDs, ","))
  67. apiQuery.Set("key", config.Opts.YouTubeAPIKey())
  68. apiQuery.Set("part", "contentDetails")
  69. apiURL := url.URL{
  70. Scheme: "https",
  71. Host: "www.googleapis.com",
  72. Path: "youtube/v3/videos",
  73. RawQuery: apiQuery.Encode(),
  74. }
  75. requestBuilder := fetcher.NewRequestBuilder()
  76. requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
  77. requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
  78. responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
  79. defer responseHandler.Close()
  80. if localizedError := responseHandler.LocalizedError(); localizedError != nil {
  81. slog.Warn("Unable to fetch contentDetails from YouTube API", slog.Any("error", localizedError.Error()))
  82. return nil, localizedError.Error()
  83. }
  84. videos := struct {
  85. Items []struct {
  86. ID string `json:"id"`
  87. ContentDetails struct {
  88. Duration string `json:"duration"`
  89. } `json:"contentDetails"`
  90. } `json:"items"`
  91. }{}
  92. if err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {
  93. return nil, fmt.Errorf("youtube: unable to decode JSON: %v", err)
  94. }
  95. watchTimeMap := make(map[string]time.Duration, len(videos.Items))
  96. for _, video := range videos.Items {
  97. duration, err := parseISO8601Duration(video.ContentDetails.Duration)
  98. if err != nil {
  99. slog.Warn("Unable to parse ISO8601 duration", slog.Any("error", err))
  100. continue
  101. }
  102. watchTimeMap[video.ID] = duration
  103. }
  104. return watchTimeMap, nil
  105. }