1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-06-27 16:36:00 +00:00

feat: optionally fetch watch time from YouTube API instead of website

This commit is contained in:
telnet23 2024-11-19 00:57:59 +00:00 committed by Frédéric Guillot
parent b61ee15c1b
commit 7e2b50efee
5 changed files with 99 additions and 0 deletions

View file

@ -2116,6 +2116,24 @@ func TestFetchYouTubeWatchTime(t *testing.T) {
} }
} }
func TestYouTubeApiKey(t *testing.T) {
os.Clearenv()
os.Setenv("YOUTUBE_API_KEY", "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000"
result := opts.YouTubeApiKey()
if result != expected {
t.Fatalf(`Unexpected YOUTUBE_API_KEY value, got %v instead of %v`, result, expected)
}
}
func TestYouTubeEmbedUrlOverride(t *testing.T) { func TestYouTubeEmbedUrlOverride(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/") os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")

View file

@ -60,6 +60,7 @@ const (
defaultFetchNebulaWatchTime = false defaultFetchNebulaWatchTime = false
defaultFetchOdyseeWatchTime = false defaultFetchOdyseeWatchTime = false
defaultFetchYouTubeWatchTime = false defaultFetchYouTubeWatchTime = false
defaultYouTubeApiKey = ""
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/" defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
defaultCreateAdmin = false defaultCreateAdmin = false
defaultAdminUsername = "" defaultAdminUsername = ""
@ -149,6 +150,7 @@ type Options struct {
fetchOdyseeWatchTime bool fetchOdyseeWatchTime bool
fetchYouTubeWatchTime bool fetchYouTubeWatchTime bool
filterEntryMaxAgeDays int filterEntryMaxAgeDays int
youTubeApiKey string
youTubeEmbedUrlOverride string youTubeEmbedUrlOverride string
oauth2UserCreationAllowed bool oauth2UserCreationAllowed bool
oauth2ClientID string oauth2ClientID string
@ -228,6 +230,7 @@ func NewOptions() *Options {
fetchNebulaWatchTime: defaultFetchNebulaWatchTime, fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime, fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime, fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
youTubeApiKey: defaultYouTubeApiKey,
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride, youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
oauth2UserCreationAllowed: defaultOAuth2UserCreation, oauth2UserCreationAllowed: defaultOAuth2UserCreation,
oauth2ClientID: defaultOAuth2ClientID, oauth2ClientID: defaultOAuth2ClientID,
@ -503,6 +506,11 @@ func (o *Options) FetchYouTubeWatchTime() bool {
return o.fetchYouTubeWatchTime return o.fetchYouTubeWatchTime
} }
// YouTubeApiKey returns the YouTube API key if defined.
func (o *Options) YouTubeApiKey() string {
return o.youTubeApiKey
}
// YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds // YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
func (o *Options) YouTubeEmbedUrlOverride() string { func (o *Options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride return o.youTubeEmbedUrlOverride
@ -733,6 +741,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"SERVER_TIMING_HEADER": o.serverTimingHeader, "SERVER_TIMING_HEADER": o.serverTimingHeader,
"WATCHDOG": o.watchdog, "WATCHDOG": o.watchdog,
"WORKER_POOL_SIZE": o.workerPoolSize, "WORKER_POOL_SIZE": o.workerPoolSize,
"YOUTUBE_API_KEY": redactSecretValue(o.youTubeApiKey, redactSecret),
"YOUTUBE_EMBED_URL_OVERRIDE": o.youTubeEmbedUrlOverride, "YOUTUBE_EMBED_URL_OVERRIDE": o.youTubeEmbedUrlOverride,
"WEBAUTHN": o.webAuthn, "WEBAUTHN": o.webAuthn,
} }

View file

@ -271,6 +271,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime) p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
case "FETCH_YOUTUBE_WATCH_TIME": case "FETCH_YOUTUBE_WATCH_TIME":
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime) p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
case "YOUTUBE_API_KEY":
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
case "YOUTUBE_EMBED_URL_OVERRIDE": case "YOUTUBE_EMBED_URL_OVERRIDE":
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride) p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
case "WATCHDOG": case "WATCHDOG":

View file

@ -4,9 +4,11 @@
package processor package processor
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"time" "time"
@ -33,6 +35,14 @@ func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
} }
func fetchYouTubeWatchTime(websiteURL string) (int, error) { func fetchYouTubeWatchTime(websiteURL string) (int, error) {
if config.Opts.YouTubeApiKey() == "" {
return fetchYouTubeWatchTimeFromWebsite(websiteURL)
} else {
return fetchYouTubeWatchTimeFromApi(websiteURL)
}
}
func fetchYouTubeWatchTimeFromWebsite(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder() requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout()) requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy()) requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
@ -63,6 +73,61 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
return int(dur.Minutes()), nil return int(dur.Minutes()), nil
} }
func fetchYouTubeWatchTimeFromApi(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
parsedWebsiteURL, err := url.Parse(websiteURL)
if err != nil {
return 0, fmt.Errorf("unable to parse URL: %v", err)
}
apiQuery := url.Values{}
apiQuery.Set("id", parsedWebsiteURL.Query().Get("v"))
apiQuery.Set("key", config.Opts.YouTubeApiKey())
apiQuery.Set("part", "contentDetails")
apiURL := url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "youtube/v3/videos",
RawQuery: apiQuery.Encode(),
}
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch contentDetails from YouTube API", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
var videos struct {
Items []struct {
ContentDetails struct {
Duration string `json:"duration"`
} `json:"contentDetails"`
} `json:"items"`
}
if err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {
return 0, fmt.Errorf("unable to decode JSON: %v", err)
}
if n := len(videos.Items); n != 1 {
return 0, fmt.Errorf("invalid items length: %d", n)
}
durs := videos.Items[0].ContentDetails.Duration
dur, err := parseISO8601(durs)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur.Minutes()), nil
}
func parseISO8601(from string) (time.Duration, error) { func parseISO8601(from string) (time.Duration, error) {
var match []string var match []string
var d time.Duration var d time.Duration

View file

@ -555,6 +555,11 @@ Number of background workers\&.
.br .br
Default is 16 workers\&. Default is 16 workers\&.
.TP .TP
.B YOUTUBE_API_KEY
YouTube API key for use with FETCH_YOUTUBE_WATCH_TIME. If nonempty, the duration will be fetched from the YouTube API. Otherwise, the duration will be fetched from the YouTube website\&.
.br
Default is empty\&.
.TP
.B YOUTUBE_EMBED_URL_OVERRIDE .B YOUTUBE_EMBED_URL_OVERRIDE
YouTube URL which will be used for embeds\&. YouTube URL which will be used for embeds\&.
.br .br