Files
ai-media-hub/backend/services/giphy.go
T
GHStaK 3c6df2e777
build-push / docker (push) Successful in 4m23s
Tolerate Gemini image expansion drift
2026-03-24 16:29:36 +09:00

618 lines
19 KiB
Go

package services
import (
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
neturl "net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
const (
defaultGiphyAPIBaseURL = "https://api.giphy.com"
defaultGiphyMaxResults = 100
giphyBatchSize = 20
giphyDownloadSizeLimit = 50 * 1024 * 1024
)
type GiphyConfig struct {
Enabled bool
APIKey string
MaxResults int
Rating string
Lang string
DownloadDir string
BaseURL string
}
type GiphyService struct {
Config GiphyConfig
Client *http.Client
Gemini *GeminiService
Debug func(message string, data any)
BaseURL string
}
type GiphyResult struct {
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
Link string `json:"link,omitempty"`
Title string `json:"title"`
SearchQuery string `json:"searchQuery"`
OriginalQuery string `json:"originalQuery,omitempty"`
PreviewURL string `json:"previewUrl"`
PreviewStillURL string `json:"previewStillUrl"`
FullURL string `json:"fullUrl"`
DownloadURL string `json:"downloadUrl"`
Width int `json:"width"`
Height int `json:"height"`
Rating string `json:"rating"`
SourcePageURL string `json:"sourcePageUrl"`
OpenURL string `json:"openUrl,omitempty"`
Source string `json:"source,omitempty"`
ActionLabel string `json:"actionLabel,omitempty"`
ActionType string `json:"actionType,omitempty"`
SecondaryActionLabel string `json:"secondaryActionLabel,omitempty"`
PreviewBlockedReason string `json:"previewBlockedReason,omitempty"`
Raw map[string]any `json:"raw,omitempty"`
}
type GiphySearchResponse struct {
Provider string `json:"provider"`
OriginalQuery string `json:"originalQuery"`
ExpandedQueries []string `json:"expandedQueries"`
Total int `json:"total"`
Items []GiphyResult `json:"items"`
Warning string `json:"warning,omitempty"`
}
type GiphyDownloadRequest struct {
ProviderID string `json:"providerId"`
Title string `json:"title"`
DownloadURL string `json:"downloadUrl"`
OriginalQuery string `json:"originalQuery"`
SelectedExpansionQuery string `json:"selectedExpansionQuery"`
}
type GiphyDownloadResponse struct {
OK bool `json:"ok"`
FileName string `json:"fileName,omitempty"`
SavedPath string `json:"savedPath,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
type giphySearchAPIResponse struct {
Data []giphyAPIItem `json:"data"`
}
type giphyAPIItem struct {
ID string `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Rating string `json:"rating"`
URL string `json:"url"`
Images struct {
Original giphyRendition `json:"original"`
OriginalStill giphyRendition `json:"original_still"`
FixedWidth giphyRendition `json:"fixed_width"`
FixedWidthStill giphyRendition `json:"fixed_width_still"`
FixedWidthDownsample giphyRendition `json:"fixed_width_downsampled"`
FixedHeight giphyRendition `json:"fixed_height"`
PreviewGIF giphyRendition `json:"preview_gif"`
Downsized giphyRendition `json:"downsized"`
DownsizedLarge giphyRendition `json:"downsized_large"`
} `json:"images"`
}
type giphyRendition struct {
URL string `json:"url"`
MP4 string `json:"mp4"`
WebP string `json:"webp"`
Width string `json:"width"`
Height string `json:"height"`
}
func NewGiphyService(config GiphyConfig, gemini *GeminiService) *GiphyService {
if config.MaxResults <= 0 {
config.MaxResults = defaultGiphyMaxResults
}
if strings.TrimSpace(config.Rating) == "" {
config.Rating = "g"
}
if strings.TrimSpace(config.Lang) == "" {
config.Lang = "en"
}
if strings.TrimSpace(config.BaseURL) == "" {
config.BaseURL = defaultGiphyAPIBaseURL
}
return &GiphyService{
Config: config,
BaseURL: strings.TrimRight(config.BaseURL, "/"),
Client: &http.Client{Timeout: 20 * time.Second},
Gemini: gemini,
}
}
func (s *GiphyService) SearchImages(query string, requestedMax int) (GiphySearchResponse, error) {
response := GiphySearchResponse{
Provider: "giphy",
OriginalQuery: strings.TrimSpace(query),
}
if !s.Config.Enabled {
return response, fmt.Errorf("giphy is disabled")
}
if response.OriginalQuery == "" {
return response, fmt.Errorf("query is required")
}
if strings.TrimSpace(s.Config.APIKey) == "" {
return response, fmt.Errorf("giphy api key is not configured")
}
target := s.Config.MaxResults
if requestedMax > 0 {
target = minInt(requestedMax, s.Config.MaxResults)
}
if target <= 0 {
target = minInt(defaultGiphyMaxResults, s.Config.MaxResults)
}
expandedQueries, expansionErr := s.expandQueries(response.OriginalQuery)
response.ExpandedQueries = expandedQueries
if expansionErr != nil {
s.debug("giphy:query_expansion_fallback", map[string]any{
"query": response.OriginalQuery,
"queries": expandedQueries,
"error": expansionErr.Error(),
})
} else {
s.debug("giphy:query_expansion", map[string]any{
"query": response.OriginalQuery,
"queries": expandedQueries,
})
}
type queryState struct {
query string
offset int
enabled bool
}
states := make([]queryState, 0, len(expandedQueries))
for _, item := range expandedQueries {
states = append(states, queryState{query: item, enabled: true})
}
items := make([]GiphyResult, 0, target)
seen := map[string]bool{}
var allErrs []string
var successfulCalls int
fetchRound := func(limit int) bool {
progress := false
for idx := range states {
if !states[idx].enabled || len(items) >= target {
continue
}
batchLimit := minInt(limit, target-len(items))
found, err := s.searchQuery(states[idx].query, batchLimit, states[idx].offset, response.OriginalQuery)
if err != nil {
allErrs = append(allErrs, err.Error())
s.debug("giphy:query_error", map[string]any{
"query": states[idx].query,
"offset": states[idx].offset,
"error": err.Error(),
})
continue
}
successfulCalls++
s.debug("giphy:query_results", map[string]any{
"query": states[idx].query,
"offset": states[idx].offset,
"count": len(found),
})
if len(found) == 0 {
states[idx].enabled = false
continue
}
states[idx].offset += len(found)
before := len(items)
for _, item := range found {
if len(items) >= target {
break
}
if mergeUniqueGiphyResult(&items, item, seen) {
progress = true
}
}
if len(found) < batchLimit && len(items) == before {
states[idx].enabled = false
}
}
return progress
}
progress := fetchRound(minInt(giphyBatchSize, target))
for round := 0; len(items) < target && progress && round < 3; round++ {
progress = fetchRound(minInt(giphyBatchSize, target-len(items)))
}
response.Items = items
response.Total = len(items)
s.debug("giphy:search_complete", map[string]any{
"query": response.OriginalQuery,
"expanded": response.ExpandedQueries,
"total": response.Total,
"target": target,
"warning": response.Warning,
"successCalls": successfulCalls,
})
if response.Total == 0 && len(allErrs) > 0 {
return response, fmt.Errorf("giphy search failed: %s", strings.Join(uniqueStrings(allErrs, 3), "; "))
}
if len(allErrs) > 0 && response.Warning == "" {
response.Warning = "Some GIPHY requests failed, showing partial results."
}
return response, nil
}
func (s *GiphyService) DownloadMedia(req GiphyDownloadRequest) (GiphyDownloadResponse, error) {
if !s.Config.Enabled {
return GiphyDownloadResponse{OK: false, Error: "GIPHY_DISABLED", Message: "GIPHY is disabled"}, fmt.Errorf("giphy is disabled")
}
if strings.TrimSpace(req.ProviderID) == "" || strings.TrimSpace(req.DownloadURL) == "" {
return GiphyDownloadResponse{OK: false, Error: "INVALID_REQUEST", Message: "providerId and downloadUrl are required"}, fmt.Errorf("providerId and downloadUrl are required")
}
if !isAllowedGiphyDownloadURL(req.DownloadURL) {
return GiphyDownloadResponse{OK: false, Error: "INVALID_DOWNLOAD_URL", Message: "Only approved GIPHY media URLs are allowed"}, fmt.Errorf("download url is not on an approved giphy host")
}
if err := os.MkdirAll(s.Config.DownloadDir, 0o755); err != nil {
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_DIR_FAILED", Message: "Failed to prepare GIPHY download directory"}, err
}
httpReq, err := http.NewRequest(http.MethodGet, req.DownloadURL, nil)
if err != nil {
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to build GIPHY download request"}, err
}
httpReq.Header.Set("User-Agent", "AI-Media-Hub/1.0")
resp, err := s.Client.Do(httpReq)
if err != nil {
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to download media from GIPHY"}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to download media from GIPHY"}, fmt.Errorf("giphy download returned status %d", resp.StatusCode)
}
if resp.ContentLength > giphyDownloadSizeLimit {
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_TOO_LARGE", Message: "GIPHY media exceeds the configured size limit"}, fmt.Errorf("giphy download too large: %d", resp.ContentLength)
}
contentType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
extension := determineGiphyExtension(req.DownloadURL, contentType)
fileName := buildGiphyFilename(req.ProviderID, req.Title, extension, time.Now())
targetPath := filepath.Join(s.Config.DownloadDir, fileName)
cleanTargetPath := filepath.Clean(targetPath)
cleanBaseDir := filepath.Clean(s.Config.DownloadDir)
if !strings.HasPrefix(cleanTargetPath, cleanBaseDir) {
return GiphyDownloadResponse{OK: false, Error: "INVALID_PATH", Message: "Resolved download path is invalid"}, fmt.Errorf("resolved giphy download path escaped base directory")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, giphyDownloadSizeLimit+1))
if err != nil {
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to read media from GIPHY"}, err
}
if int64(len(data)) > giphyDownloadSizeLimit {
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_TOO_LARGE", Message: "GIPHY media exceeds the configured size limit"}, fmt.Errorf("giphy download exceeded size limit during read")
}
if err := os.WriteFile(cleanTargetPath, data, 0o644); err != nil {
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to save media from GIPHY"}, err
}
s.debug("giphy:download_success", map[string]any{
"providerId": req.ProviderID,
"fileName": fileName,
"savedPath": cleanTargetPath,
})
return GiphyDownloadResponse{
OK: true,
FileName: fileName,
SavedPath: cleanTargetPath,
}, nil
}
func (s *GiphyService) expandQueries(query string) ([]string, error) {
if s.Gemini == nil {
return buildFallbackImageQueries(query, strings.TrimSpace(query)), fmt.Errorf("gemini service is not configured")
}
return s.Gemini.ExpandImageQueries(query)
}
func (s *GiphyService) searchQuery(query string, limit, offset int, originalQuery string) ([]GiphyResult, error) {
endpoint, err := neturl.Parse(s.BaseURL + "/v1/gifs/search")
if err != nil {
return nil, err
}
params := endpoint.Query()
params.Set("api_key", s.Config.APIKey)
params.Set("q", query)
params.Set("limit", strconv.Itoa(limit))
params.Set("offset", strconv.Itoa(max(offset, 0)))
params.Set("rating", s.Config.Rating)
params.Set("lang", s.Config.Lang)
endpoint.RawQuery = params.Encode()
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := s.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("giphy returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
var payload giphySearchAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
items := make([]GiphyResult, 0, len(payload.Data))
for _, item := range payload.Data {
mapped := mapGiphyItem(item, query, originalQuery)
if mapped.ProviderID == "" || mapped.FullURL == "" {
continue
}
items = append(items, mapped)
}
return items, nil
}
func mapGiphyItem(item giphyAPIItem, searchQuery, originalQuery string) GiphyResult {
previewURL := firstNonEmpty(
item.Images.FixedWidthDownsample.URL,
item.Images.FixedWidth.URL,
item.Images.FixedHeight.URL,
item.Images.PreviewGIF.URL,
item.Images.Downsized.URL,
item.Images.Original.URL,
)
stillURL := firstNonEmpty(
item.Images.FixedWidthStill.URL,
item.Images.OriginalStill.URL,
item.Images.FixedWidth.URL,
item.Images.Downsized.URL,
item.Images.Original.URL,
)
fullURL := firstNonEmpty(
item.Images.Original.URL,
item.Images.DownsizedLarge.URL,
item.Images.Downsized.URL,
item.Images.FixedWidth.URL,
)
downloadURL := firstNonEmpty(
item.Images.Original.URL,
item.Images.DownsizedLarge.URL,
item.Images.FixedWidth.URL,
item.Images.Original.MP4,
)
width := atoiOrZero(firstNonEmpty(item.Images.Original.Width, item.Images.FixedWidth.Width, item.Images.DownsizedLarge.Width))
height := atoiOrZero(firstNonEmpty(item.Images.Original.Height, item.Images.FixedWidth.Height, item.Images.DownsizedLarge.Height))
title := strings.TrimSpace(item.Title)
if title == "" {
title = strings.ReplaceAll(strings.TrimSpace(item.Slug), "-", " ")
}
if title == "" {
title = "Untitled GIPHY"
}
return GiphyResult{
Provider: "giphy",
ProviderID: strings.TrimSpace(item.ID),
Link: strings.TrimSpace(item.URL),
Title: title,
SearchQuery: searchQuery,
OriginalQuery: originalQuery,
PreviewURL: previewURL,
PreviewStillURL: stillURL,
FullURL: fullURL,
DownloadURL: downloadURL,
Width: width,
Height: height,
Rating: strings.TrimSpace(item.Rating),
SourcePageURL: strings.TrimSpace(item.URL),
OpenURL: strings.TrimSpace(item.URL),
Source: "GIPHY",
ActionLabel: "Download",
ActionType: "giphy_download",
SecondaryActionLabel: "Open Original",
}
}
func mergeUniqueGiphyResult(items *[]GiphyResult, candidate GiphyResult, seen map[string]bool) bool {
for _, key := range giphyDedupKeys(candidate) {
if key == "" {
continue
}
if seen[key] {
return false
}
}
for _, key := range giphyDedupKeys(candidate) {
if key != "" {
seen[key] = true
}
}
*items = append(*items, candidate)
return true
}
func giphyDedupKeys(item GiphyResult) []string {
keys := []string{}
if item.ProviderID != "" {
keys = append(keys, "id:"+strings.ToLower(item.ProviderID))
}
if item.FullURL != "" {
keys = append(keys, "full:"+strings.ToLower(strings.TrimSpace(item.FullURL)))
}
if item.SourcePageURL != "" {
keys = append(keys, "source:"+strings.ToLower(strings.TrimSpace(item.SourcePageURL)))
}
return keys
}
func buildFallbackImageQueries(originalQuery, englishBase string) []string {
base := strings.TrimSpace(englishBase)
if base == "" {
base = strings.TrimSpace(originalQuery)
}
candidates := []string{
base,
base + " gif",
base + " reaction gif",
base + " meme gif",
base + " animated gif",
base + " reaction image",
base + " sticker",
}
queries := normalizeImageExpansionQueries(candidates)
if len(queries) > 5 {
return queries[:5]
}
for len(queries) < 5 {
queries = append(queries, fmt.Sprintf("%s gif %d", base, len(queries)+1))
}
return queries[:5]
}
func normalizeImageExpansionQueries(items []string) []string {
seen := map[string]bool{}
queries := make([]string, 0, 5)
for _, item := range items {
normalized := sanitizePlainEnglishLine(item)
normalized = strings.Join(strings.Fields(normalized), " ")
if normalized == "" {
continue
}
if !looksMostlyASCII(normalized) {
continue
}
key := strings.ToLower(normalized)
if seen[key] {
continue
}
seen[key] = true
queries = append(queries, normalized)
}
return queries
}
func isAllowedGiphyDownloadURL(rawURL string) bool {
parsed, err := neturl.Parse(strings.TrimSpace(rawURL))
if err != nil {
return false
}
host := strings.ToLower(parsed.Hostname())
if host == "" {
return false
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return false
}
return host == "giphy.com" || strings.HasSuffix(host, ".giphy.com")
}
func buildGiphyFilename(providerID, title, extension string, now time.Time) string {
slug := sanitizeGiphyFilenameComponent(title)
if slug == "" {
slug = "untitled"
}
providerID = sanitizeGiphyFilenameComponent(providerID)
if providerID == "" {
providerID = "unknown"
}
ext := extension
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return fmt.Sprintf("giphy_%s_%s_%s%s", providerID, slug, now.Format("20060102_150405"), ext)
}
func sanitizeGiphyFilenameComponent(value string) string {
re := regexp.MustCompile(`[^a-z0-9]+`)
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = re.ReplaceAllString(normalized, "-")
return strings.Trim(normalized, "-")
}
func determineGiphyExtension(rawURL, contentType string) string {
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "image/gif":
return ".gif"
case "video/mp4":
return ".mp4"
case "image/webp":
return ".webp"
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
}
parsed, err := neturl.Parse(rawURL)
if err == nil {
ext := strings.ToLower(path.Ext(parsed.Path))
switch ext {
case ".gif", ".mp4", ".webp", ".png", ".jpg", ".jpeg":
if ext == ".jpeg" {
return ".jpg"
}
return ext
}
}
return ".gif"
}
func atoiOrZero(value string) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0
}
return parsed
}
func uniqueStrings(items []string, limit int) []string {
seen := map[string]bool{}
unique := make([]string, 0, minInt(len(items), limit))
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
unique = append(unique, trimmed)
if len(unique) >= limit {
break
}
}
return unique
}
func (s *GiphyService) debug(message string, data any) {
if s != nil && s.Debug != nil {
s.Debug(message, data)
}
}