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"}, }) } 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"` }