Add GIPHY image search feature
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
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 {
|
||||
response.Warning = "Query expansion failed, using fallback search terms."
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user