kafka/internal/engines/youtube.go
Franz Kafka 7be03b4017 license: change from MIT to AGPLv3
Update LICENSE file and add AGPL header to all source files.

AGPLv3 ensures that if someone runs Kafka as a network service and
modifies it, they must release their source code under the same license.
2026-03-22 08:27:23 +00:00

194 lines
5.1 KiB
Go

// kafka — a privacy-respecting metasearch engine
// Copyright (C) 2026-present metamorphosis-dev
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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"`
}