Uses the official YouTube Data API v3. Requires YOUTUBE_API_KEY environment variable (free from Google Cloud Console). Returns video results with title, description, channel, publish date, and thumbnail URL. Falls back gracefully if no API key.
182 lines
4.5 KiB
Go
182 lines
4.5 KiB
Go
package engines
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
|
)
|
|
|
|
type YouTubeEngine struct {
|
|
client *http.Client
|
|
apiKey string
|
|
baseURL string
|
|
}
|
|
|
|
func (e *YouTubeEngine) Name() string { return "youtube" }
|
|
|
|
func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) {
|
|
if strings.TrimSpace(req.Query) == "" {
|
|
return contracts.SearchResponse{Query: req.Query}, nil
|
|
}
|
|
|
|
if e.apiKey == "" {
|
|
e.apiKey = os.Getenv("YOUTUBE_API_KEY")
|
|
}
|
|
|
|
maxResults := 10
|
|
if req.Pageno > 1 {
|
|
maxResults = 20
|
|
}
|
|
|
|
u := e.baseURL + "/youtube/v3/search?" + url.Values{
|
|
"part": {"snippet"},
|
|
"q": {req.Query},
|
|
"type": {"video"},
|
|
"maxResults": {fmt.Sprintf("%d", maxResults)},
|
|
"key": {e.apiKey},
|
|
}.Encode()
|
|
|
|
if req.Language != "" && req.Language != "auto" {
|
|
lang := strings.Split(strings.ToLower(req.Language), "-")[0]
|
|
u += "&relevanceLanguage=" + lang
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return contracts.SearchResponse{}, err
|
|
}
|
|
|
|
resp, err := e.client.Do(httpReq)
|
|
if err != nil {
|
|
return contracts.SearchResponse{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return contracts.SearchResponse{}, fmt.Errorf("youtube api error: status=%d body=%q", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var apiResp youtubeSearchResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
|
return contracts.SearchResponse{}, err
|
|
}
|
|
|
|
if apiResp.Error != nil {
|
|
return contracts.SearchResponse{}, fmt.Errorf("youtube api error: %s", apiResp.Error.Message)
|
|
}
|
|
|
|
results := make([]contracts.MainResult, 0, len(apiResp.Items))
|
|
for _, item := range apiResp.Items {
|
|
if item.ID.VideoID == "" {
|
|
continue
|
|
}
|
|
|
|
videoURL := "https://www.youtube.com/watch?v=" + item.ID.VideoID
|
|
urlPtr := videoURL
|
|
|
|
published := ""
|
|
if item.Snippet.PublishedAt != "" {
|
|
if t, err := time.Parse(time.RFC3339, item.Snippet.PublishedAt); err == nil {
|
|
published = t.Format("Jan 2, 2006")
|
|
}
|
|
}
|
|
|
|
content := item.Snippet.Description
|
|
if len(content) > 300 {
|
|
content = content[:300] + "..."
|
|
}
|
|
if published != "" {
|
|
content = "Published " + published + " · " + content
|
|
}
|
|
|
|
thumbnail := ""
|
|
if item.Snippet.Thumbnails.High.URL != "" {
|
|
thumbnail = item.Snippet.Thumbnails.High.URL
|
|
} else if item.Snippet.Thumbnails.Medium.URL != "" {
|
|
thumbnail = item.Snippet.Thumbnails.Medium.URL
|
|
}
|
|
|
|
results = append(results, contracts.MainResult{
|
|
Template: "videos.html",
|
|
Title: item.Snippet.Title,
|
|
URL: &urlPtr,
|
|
Content: content,
|
|
Thumbnail: thumbnail,
|
|
Engine: "youtube",
|
|
Score: 1.0,
|
|
Category: "videos",
|
|
Engines: []string{"youtube"},
|
|
Metadata: map[string]any{
|
|
"channel": item.Snippet.ChannelTitle,
|
|
"video_id": item.Snippet.ResourceID.VideoID,
|
|
},
|
|
})
|
|
}
|
|
|
|
return contracts.SearchResponse{
|
|
Query: req.Query,
|
|
NumberOfResults: len(results),
|
|
Results: results,
|
|
Answers: []map[string]any{},
|
|
Corrections: []string{},
|
|
Infoboxes: []map[string]any{},
|
|
Suggestions: []string{},
|
|
UnresponsiveEngines: [][2]string{},
|
|
}, nil
|
|
}
|
|
|
|
// YouTube API response types.
|
|
|
|
type youtubeSearchResponse struct {
|
|
Items []youtubeSearchItem `json:"items"`
|
|
PageInfo struct {
|
|
TotalResults int `json:"totalResults"`
|
|
ResultsPerPage int `json:"resultsPerPage"`
|
|
} `json:"pageInfo"`
|
|
NextPageToken string `json:"nextPageToken"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Errors []struct {
|
|
Domain string `json:"domain"`
|
|
Reason string `json:"reason"`
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
type youtubeSearchItem struct {
|
|
ID struct {
|
|
VideoID string `json:"videoId"`
|
|
} `json:"id"`
|
|
Snippet struct {
|
|
PublishedAt string `json:"publishedAt"`
|
|
ChannelID string `json:"channelId"`
|
|
ChannelTitle string `json:"channelTitle"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Thumbnails struct {
|
|
Default struct {
|
|
URL string `json:"url"`
|
|
} `json:"default"`
|
|
Medium struct {
|
|
URL string `json:"url"`
|
|
} `json:"medium"`
|
|
High struct {
|
|
URL string `json:"url"`
|
|
} `json:"high"`
|
|
} `json:"thumbnails"`
|
|
ResourceID struct {
|
|
VideoID string `json:"videoId"`
|
|
} `json:"resourceId"`
|
|
} `json:"snippet"`
|
|
}
|