feat: add YouTube engine via Data API v3

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.
This commit is contained in:
Franz Kafka 2026-03-22 01:53:19 +00:00
parent fc6e6ada68
commit 38122385bd
3 changed files with 191 additions and 3 deletions

182
internal/engines/youtube.go Normal file
View file

@ -0,0 +1,182 @@
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"`
}