diff --git a/docs/plugins.md b/docs/plugins.md
index 5976e4e..0af63b3 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -433,9 +433,13 @@ short link:
The response is labeled `[YouTube]`, includes the title, channel when available,
and a `https://youtu.be/...` link. When `BOT_YOUTUBE_API_KEY` is configured, GoBot
also adds the video's public view and like totals when YouTube exposes them.
+The same yellow `👁` views and green `👍` likes formatting is used for API,
+public-page, and indexed fallback results, including age-confirmation searches.
The statistics are best-effort: a missing like count, an API limitation, or a
temporary statistics lookup failure does not prevent the search result from
-being returned. The command searches video results, which includes music
+being returned. This lookup is limited to one second within the command's
+overall timeout; unavailable counts are omitted rather than invented.
+The command searches video results, which includes music
videos and other YouTube video content. GoBot uses the key for the official
Data API search and statistics lookup, then falls back to YouTube's public
results page when the key is unavailable or the API cannot be used. If that
diff --git a/plugins/youtube.go b/plugins/youtube.go
index edb64af..730917a 100644
--- a/plugins/youtube.go
+++ b/plugins/youtube.go
@@ -91,6 +91,18 @@ func (p *YouTube) Handle(b *bot.Bot, m bot.Message) bool {
}
func (p *YouTube) search(ctx context.Context, query string) (youtubeSearchResult, error) {
+ result, err := p.searchVideo(ctx, query)
+ if err == nil && p.apiKey != "" && ctx.Err() == nil {
+ // Enrich every successful route, including page/index fallbacks. A
+ // statistics failure must never discard a video we already found.
+ step, cancel := context.WithTimeout(ctx, time.Second)
+ p.addStatistics(step, result.VideoID, &result)
+ cancel()
+ }
+ return result, err
+}
+
+func (p *YouTube) searchVideo(ctx context.Context, query string) (youtubeSearchResult, error) {
if p.apiKey != "" {
step, cancel := youtubeStepContext(ctx, 2*time.Second)
result, err := p.searchAPI(step, query)
@@ -147,9 +159,6 @@ func (p *YouTube) searchAPI(ctx context.Context, query string) (youtubeSearchRes
for _, item := range response.Items {
result := youtubeSearchResult{VideoID: item.ID.VideoID, Title: cleanYouTubeText(item.Snippet.Title), ChannelName: cleanYouTubeText(item.Snippet.ChannelName)}
if validYouTubeSearchResult(result) {
- step, cancel := context.WithTimeout(ctx, time.Second)
- p.addStatistics(step, result.VideoID, &result)
- cancel()
return result, nil
}
}
diff --git a/plugins/youtube_test.go b/plugins/youtube_test.go
index ca6f5e3..248362b 100644
--- a/plugins/youtube_test.go
+++ b/plugins/youtube_test.go
@@ -158,6 +158,159 @@ func TestYouTubeStatisticsAreBestEffort(t *testing.T) {
}
}
+func TestYouTubeSearchRoutesUseIdenticalStatisticsFormatting(t *testing.T) {
+ oldClient := youtubeHTTPClient
+ t.Cleanup(func() { youtubeHTTPClient = oldClient })
+ for _, route := range []string{"api", "page", "index"} {
+ t.Run(route, func(t *testing.T) {
+ statisticsRequests := 0
+ requests := 0
+ youtubeHTTPClient = &http.Client{Transport: youtubeRoundTripper(func(r *http.Request) (*http.Response, error) {
+ requests++
+ switch r.URL.Host + r.URL.Path {
+ case "www.googleapis.com/youtube/v3/search":
+ if route == "api" {
+ return youtubeTestResponse(200, "application/json", `{"items":[{"id":{"videoId":"first123456"},"snippet":{"title":"Example music","channelTitle":"Example artist"}}]}`), nil
+ }
+ return youtubeTestResponse(200, "application/json", `{"items":[]}`), nil
+ case "www.youtube.com/results":
+ if route == "page" {
+ return youtubeTestResponse(200, "text/html", `var ytInitialData={"videoRenderer":{"videoId":"first123456","title":{"simpleText":"Example music"},"ownerText":{"simpleText":"Example artist"}}};`), nil
+ }
+ return youtubeTestResponse(200, "text/html", `Verify your age`), nil
+ case "www.bing.com/search":
+ return youtubeTestResponse(200, "text/html", `
`), nil
+ case "www.youtube.com/oembed":
+ return youtubeTestResponse(200, "application/json", `{"title":"Example music","author_name":"Example artist"}`), nil
+ case "www.googleapis.com/youtube/v3/videos":
+ statisticsRequests++
+ if r.URL.Query().Get("id") != "first123456" || r.URL.Query().Get("part") != "statistics" || r.URL.Query().Get("key") != "test-key" {
+ t.Fatal("statistics lookup lost the selected video or API configuration")
+ }
+ return youtubeTestResponse(200, "application/json", `{"items":[{"statistics":{"viewCount":"1234567","likeCount":"42000"}}]}`), nil
+ default:
+ t.Fatalf("unexpected request: %s%s", r.URL.Host, r.URL.Path)
+ return nil, nil
+ }
+ })}
+ result, err := (&YouTube{apiKey: "test-key"}).search(t.Context(), "example music")
+ if err != nil || statisticsRequests != 1 || requests != map[string]int{"api": 2, "page": 3, "index": 5}[route] {
+ t.Fatalf("route=%s error=%v statistics=%d requests=%d", route, err, statisticsRequests, requests)
+ }
+ want := ircColor(ircRed, "[YouTube]") + " " + ircColor(ircYellow, "Example artist") + " — " +
+ ircColor(ircCyan, "Example music") + " | " + ircColor(ircYellow, "👁 1,234,567 views") + " | " +
+ ircColor(ircGreen, "👍 42,000 likes") + " | " + ircColor(ircCyan, "https://youtu.be/first123456")
+ got := formatYouTubeSearchResultForTarget(result, 320, "#test")
+ if got != want || strings.ContainsAny(got, "\uFE0F\uFE0E") {
+ t.Fatalf("route %s has inconsistent colors/emojis: %q", route, got)
+ }
+ })
+ }
+}
+
+func TestYouTubeFallbackStatisticsNeverDiscardVideo(t *testing.T) {
+ oldClient := youtubeHTTPClient
+ t.Cleanup(func() { youtubeHTTPClient = oldClient })
+ for _, tc := range []struct {
+ name string
+ status int
+ body string
+ views bool
+ likes bool
+ }{
+ {"unavailable", 503, `{}`, false, false},
+ {"quota", 403, `{}`, false, false},
+ {"deleted", 200, `{"items":[]}`, false, false},
+ {"malformed-json", 200, `{`, false, false},
+ {"missing-counts", 200, `{"items":[{"statistics":{}}]}`, false, false},
+ {"missing-likes", 200, `{"items":[{"statistics":{"viewCount":"1234"}}]}`, true, false},
+ {"invalid-counts", 200, `{"items":[{"statistics":{"viewCount":"-1","likeCount":"unknown"}}]}`, false, false},
+ {"overflow", 200, `{"items":[{"statistics":{"viewCount":"99999999999999999999","likeCount":"-42"}}]}`, false, false},
+ {"real-zero", 200, `{"items":[{"statistics":{"viewCount":"0","likeCount":"0"}}]}`, true, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ youtubeHTTPClient = &http.Client{Transport: youtubeRoundTripper(func(r *http.Request) (*http.Response, error) {
+ switch r.URL.Host + r.URL.Path {
+ case "www.googleapis.com/youtube/v3/search":
+ return youtubeTestResponse(200, "application/json", `{"items":[]}`), nil
+ case "www.youtube.com/results":
+ return youtubeTestResponse(200, "text/html", `var ytInitialData={"videoRenderer":{"videoId":"first123456","title":{"simpleText":"Example music"}}};`), nil
+ case "www.googleapis.com/youtube/v3/videos":
+ return youtubeTestResponse(tc.status, "application/json", tc.body), nil
+ default:
+ t.Fatal("unexpected provider request")
+ return nil, nil
+ }
+ })}
+ result, err := (&YouTube{apiKey: "test-key"}).search(t.Context(), "example music")
+ if err != nil || result.VideoID != "first123456" || result.Title != "Example music" || result.HasViewCount != tc.views || result.HasLikeCount != tc.likes {
+ t.Fatalf("statistics failure altered a usable result: %+v, %v", result, err)
+ }
+ got := stripYouTubeIRC(formatYouTubeSearchResult(result, 320))
+ if !strings.HasSuffix(got, "https://youtu.be/first123456") || strings.Contains(got, "👁") != tc.views || strings.Contains(got, "👍") != tc.likes {
+ t.Fatalf("missing counts were invented or valid counts/link lost: %q", got)
+ }
+ })
+ }
+}
+
+func TestYouTubeStatisticsTimeoutPreservesSuccessfulSearch(t *testing.T) {
+ oldClient := youtubeHTTPClient
+ t.Cleanup(func() { youtubeHTTPClient = oldClient })
+ for _, budget := range []time.Duration{80 * time.Millisecond, 3 * time.Second} {
+ t.Run(budget.String(), func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(t.Context(), budget)
+ defer cancel()
+ statisticsRequests := 0
+ youtubeHTTPClient = &http.Client{Transport: youtubeRoundTripper(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path == "/youtube/v3/search" {
+ return youtubeTestResponse(200, "application/json", `{"items":[{"id":{"videoId":"first123456"},"snippet":{"title":"Example music"}}]}`), nil
+ }
+ statisticsRequests++
+ deadline, ok := r.Context().Deadline()
+ parentDeadline, _ := ctx.Deadline()
+ if !ok || deadline.After(parentDeadline) || time.Until(deadline) > time.Second {
+ t.Fatal("statistics request exceeded its one-second or parent budget")
+ }
+ <-r.Context().Done()
+ return nil, r.Context().Err()
+ })}
+ result, err := (&YouTube{apiKey: "test-key"}).search(ctx, "example music")
+ if err != nil || !validYouTubeSearchResult(result) || result.HasViewCount || result.HasLikeCount || statisticsRequests != 1 {
+ t.Fatalf("timed-out statistics discarded video: %+v, %v", result, err)
+ }
+ if budget == 3*time.Second && ctx.Err() != nil {
+ t.Fatal("statistics timeout canceled the whole command")
+ }
+ })
+ }
+}
+
+func TestYouTubeStatisticsSkippedWithoutKeyOrSuccessfulSearch(t *testing.T) {
+ oldClient := youtubeHTTPClient
+ t.Cleanup(func() { youtubeHTTPClient = oldClient })
+ for _, apiKey := range []string{"", "test-key"} {
+ t.Run(map[bool]string{true: "no-key", false: "no-video"}[apiKey == ""], func(t *testing.T) {
+ youtubeHTTPClient = &http.Client{Transport: youtubeRoundTripper(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path == "/youtube/v3/videos" || (apiKey == "" && r.URL.Host == "www.googleapis.com") {
+ t.Fatal("unnecessary statistics/API request")
+ }
+ if apiKey == "" {
+ return youtubeTestResponse(200, "text/html", `var ytInitialData={"videoRenderer":{"videoId":"first123456","title":{"simpleText":"Example music"}}};`), nil
+ }
+ return youtubeTestResponse(503, "application/json", `{}`), nil
+ })}
+ result, err := (&YouTube{apiKey: apiKey}).search(t.Context(), "example music")
+ if apiKey == "" && (err != nil || !validYouTubeSearchResult(result)) {
+ t.Fatal("no-key search regressed")
+ }
+ if apiKey != "" && err == nil {
+ t.Fatal("unavailable providers unexpectedly succeeded")
+ }
+ })
+ }
+}
+
func TestFormatYouTubeCount(t *testing.T) {
for input, want := range map[int64]string{
0: "0",
@@ -261,6 +414,8 @@ func TestYouTubeRecoveryFromAPIAndPageFailures(t *testing.T) {
return youtubeTestResponse(200, "text/html", ``), nil
case "www.youtube.com/oembed":
return youtubeTestResponse(200, "application/json", `{"title":"Example music","author_name":"Example artist"}`), nil
+ case "www.googleapis.com/youtube/v3/videos":
+ return youtubeTestResponse(403, "application/json", `{}`), nil
default:
t.Fatalf("unexpected fallback request host/path: %s", r.URL.Host+r.URL.Path)
return nil, nil
@@ -269,7 +424,7 @@ func TestYouTubeRecoveryFromAPIAndPageFailures(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond)
defer cancel()
got, err := (&YouTube{apiKey: "test-key"}).search(ctx, "example music")
- if err != nil || got.VideoID != "first123456" || got.ChannelName != "Example artist" || len(requests) != 4 {
+ if err != nil || got.VideoID != "first123456" || got.ChannelName != "Example artist" || len(requests) != 5 {
t.Fatalf("recovery: %+v, %v, requests=%v", got, err, requests)
}
})
@@ -389,7 +544,10 @@ func TestLiveYouTubeSearch(t *testing.T) {
if err != nil || !validYouTubeSearchResult(result) {
t.Fatalf("live lookup failed: %v", err)
}
- t.Logf("title=%s video=%s", result.Title, result.VideoID)
+ if os.Getenv("GOBOT_LIVE_YOUTUBE_REQUIRE_STATS") == "1" && (!result.HasViewCount || !result.HasLikeCount) {
+ t.Fatal("live lookup did not return public view and like counts")
+ }
+ t.Logf("video=%s has_views=%t has_likes=%t", result.VideoID, result.HasViewCount, result.HasLikeCount)
})
}
}