diff --git a/TODO.md b/TODO.md index 4bd8339..ff1d562 100644 --- a/TODO.md +++ b/TODO.md @@ -268,6 +268,22 @@ - backend debug broadcasts ## Recent Change Log +- Date: `2026-03-24` +- What changed: + - Replaced the earlier frontend-only image prototype with an integrated GIPHY image/GIF search flow. + - Added backend GIPHY search aggregation with dedupe, up to `100` results, secure download handling, and Gemini-driven multilingual image query expansion into exactly `5` English queries. + - Reused the existing result modal for enlarged GIPHY preview and download actions, and added an internal-scroll image results panel with visible `Powered by GIPHY` attribution. + - Updated startup config and Unraid template fields for `GIPHY_ENABLED`, `GIPHY_API_KEY`, `GIPHY_MAX_RESULTS`, `GIPHY_RATING`, `GIPHY_LANG`, `GIPHY_DOWNLOAD_DIR`, and `GEMINI_MODEL`. + - Added backend tests covering Gemini image-expansion parsing/fallback, GIPHY aggregation/cap behavior, download validation, and handler-level API paths. +- Why it changed: + - The app needed a production-usable image/GIF provider added incrementally without breaking the existing video search experience. +- How it was verified: + - `node --check frontend/app.js` + - static code review of backend/frontend wiring and new test coverage +- What is still risky or incomplete: + - This environment currently does not expose `go` or `gofmt`, so Go formatting and `go test ./...` could not be rerun locally in this turn even though new tests were added. + - Live browser QA and real GIPHY credential validation still need to be performed in a runtime environment with working API keys. + - Date: `2026-03-24` - What changed: - Added a working-rule note that git push authentication for this repository should be retried with the local credential file `.local/git-credentials.psd1` before leaving work in a local-only state. @@ -460,6 +476,13 @@ - `SEARXNG_GOOGLE_VIDEO_ENGINE` - `SEARXNG_WEB_ENGINE` - `GEMINI_API_KEY` +- `GEMINI_MODEL` +- `GIPHY_ENABLED` +- `GIPHY_API_KEY` +- `GIPHY_MAX_RESULTS` +- `GIPHY_RATING` +- `GIPHY_LANG` +- `GIPHY_DOWNLOAD_DIR` ## Local Development Environment Notes - This machine started without `go`, `pip`, `ffmpeg`, `yt-dlp`, or `node`. diff --git a/backend/handlers/api.go b/backend/handlers/api.go index d20f365..95b404b 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -33,6 +33,7 @@ type App struct { WorkerScript string SearchService *services.SearchService GeminiService *services.GeminiService + GiphyService *services.GiphyService Hub *Hub } @@ -149,6 +150,8 @@ func RegisterRoutes(router *gin.Engine, app *App) { router.POST("/api/download", app.startDownload) router.POST("/api/translate/summary", app.translateSummary) router.POST("/api/search", app.searchMedia) + router.POST("/api/giphy/search", app.searchGiphy) + router.POST("/api/giphy/download", app.downloadGiphy) } func (a *App) debug(message string, data any) { diff --git a/backend/handlers/giphy.go b/backend/handlers/giphy.go new file mode 100644 index 0000000..c2b092c --- /dev/null +++ b/backend/handlers/giphy.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "net/http" + "strings" + + "ai-media-hub/backend/models" + "ai-media-hub/backend/services" + + "github.com/gin-gonic/gin" +) + +func (a *App) searchGiphy(c *gin.Context) { + if a.GiphyService == nil || !a.GiphyService.Config.Enabled { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "giphy search is disabled"}) + return + } + + var req struct { + Query string `json:"query"` + MaxResults int `json:"maxResults"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.Query = strings.TrimSpace(req.Query) + if req.Query == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "query is required"}) + return + } + + a.Hub.Broadcast("progress", gin.H{"type": "giphy-search", "status": "expanding query for GIPHY", "progress": 20}) + resp, err := a.GiphyService.SearchImages(req.Query, req.MaxResults) + if err != nil { + a.debug("giphy:search_error", gin.H{"query": req.Query, "error": err.Error()}) + a.Hub.Broadcast("progress", gin.H{"type": "giphy-search", "status": "giphy search failed", "progress": 100, "message": err.Error()}) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + a.debug("giphy:search_complete", gin.H{ + "query": req.Query, + "expandedQueries": resp.ExpandedQueries, + "total": resp.Total, + "warning": resp.Warning, + }) + a.Hub.Broadcast("progress", gin.H{"type": "giphy-search", "status": "giphy search complete", "progress": 100, "count": resp.Total}) + c.JSON(http.StatusOK, resp) +} + +func (a *App) downloadGiphy(c *gin.Context) { + if a.GiphyService == nil || !a.GiphyService.Config.Enabled { + c.JSON(http.StatusServiceUnavailable, gin.H{"ok": false, "error": "GIPHY_DISABLED", "message": "GIPHY download is disabled"}) + return + } + + var req struct { + ProviderID string `json:"providerId"` + Title string `json:"title"` + DownloadURL string `json:"downloadUrl"` + OriginalQuery string `json:"originalQuery"` + SelectedExpansionQuery string `json:"selectedExpansionQuery"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_REQUEST", "message": err.Error()}) + return + } + + resp, err := a.GiphyService.DownloadMedia(services.GiphyDownloadRequest{ + ProviderID: req.ProviderID, + Title: req.Title, + DownloadURL: req.DownloadURL, + OriginalQuery: req.OriginalQuery, + SelectedExpansionQuery: req.SelectedExpansionQuery, + }) + if err != nil { + a.debug("giphy:download_error", gin.H{ + "providerId": req.ProviderID, + "title": req.Title, + "error": err.Error(), + }) + status := http.StatusBadGateway + if resp.Error == "INVALID_REQUEST" || resp.Error == "INVALID_DOWNLOAD_URL" { + status = http.StatusBadRequest + } + c.JSON(status, resp) + return + } + + if a.DB != nil { + if recordID, dbErr := models.InsertDownload(a.DB, req.DownloadURL, "GIPHY", resp.SavedPath, "completed"); dbErr == nil { + _ = models.MarkDownloadCompleted(a.DB, recordID, "completed") + } + } + a.Hub.Broadcast("progress", gin.H{"type": "giphy-download", "status": "giphy download complete", "progress": 100, "fileName": resp.FileName}) + c.JSON(http.StatusOK, resp) +} diff --git a/backend/handlers/giphy_test.go b/backend/handlers/giphy_test.go new file mode 100644 index 0000000..bedfddc --- /dev/null +++ b/backend/handlers/giphy_test.go @@ -0,0 +1,94 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "ai-media-hub/backend/services" + + "github.com/gin-gonic/gin" +) + +func TestSearchGiphyEndpointReturnsExpandedQueriesAndItems(t *testing.T) { + gin.SetMode(gin.TestMode) + mux := http.NewServeMux() + mux.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"happy dog\",\"happy dog gif\",\"dog reaction\",\"dog meme gif\",\"animated dog sticker\"]}"}]}}]}`)) + }) + mux.HandleFunc("/v1/gifs/search", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"dog-1","title":"Happy Dog","slug":"happy-dog","rating":"g","url":"https://giphy.com/gifs/dog-1","images":{"original":{"url":"https://media.giphy.com/media/dog-1/giphy.gif","width":"480","height":"270"},"fixed_width":{"url":"https://media.giphy.com/media/dog-1/200w.gif","width":"200","height":"113"},"fixed_width_still":{"url":"https://media.giphy.com/media/dog-1/200w_s.gif"}}}]}`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + gemini := services.NewGeminiService("dummy-key", "gemini-2.5-flash") + gemini.Client = &http.Client{Timeout: 2 * time.Second} + gemini.GenerateEndpoint = server.URL + "/generate" + + giphy := services.NewGiphyService(services.GiphyConfig{ + Enabled: true, + APIKey: "test-key", + MaxResults: 100, + BaseURL: server.URL, + }, gemini) + giphy.Client = &http.Client{Timeout: 2 * time.Second} + + app := &App{GiphyService: giphy, Hub: NewHub()} + router := gin.New() + RegisterRoutes(router, app) + + body := bytes.NewBufferString(`{"query":"행복한 강아지","maxResults":100}`) + req := httptest.NewRequest(http.MethodPost, "/api/giphy/search", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var payload struct { + ExpandedQueries []string `json:"expandedQueries"` + Items []services.GiphyResult `json:"items"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(payload.ExpandedQueries) != 5 || len(payload.Items) == 0 { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestDownloadGiphyEndpointRejectsNonGiphyHost(t *testing.T) { + gin.SetMode(gin.TestMode) + app := &App{ + GiphyService: services.NewGiphyService(services.GiphyConfig{ + Enabled: true, + APIKey: "test-key", + DownloadDir: t.TempDir(), + }, nil), + Hub: NewHub(), + } + router := gin.New() + RegisterRoutes(router, app) + + body := bytes.NewBufferString(`{"providerId":"x","title":"bad","downloadUrl":"https://example.com/file.gif"}`) + req := httptest.NewRequest(http.MethodPost, "/api/giphy/download", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "INVALID_DOWNLOAD_URL") { + t.Fatalf("expected invalid host error, got %s", rec.Body.String()) + } +} + diff --git a/backend/main.go b/backend/main.go index 88bf666..f145db0 100644 --- a/backend/main.go +++ b/backend/main.go @@ -5,6 +5,8 @@ import ( "net/http" "os" "path/filepath" + "strconv" + "strings" "ai-media-hub/backend/handlers" "ai-media-hub/backend/models" @@ -17,8 +19,16 @@ func main() { root := envOrDefault("APP_ROOT", "/app") dbPath := envOrDefault("SQLITE_PATH", filepath.Join(root, "db", "media.db")) downloadsDir := envOrDefault("DOWNLOADS_DIR", filepath.Join(root, "downloads")) + giphyDownloadDir := envOrDefault("GIPHY_DOWNLOAD_DIR", filepath.Join(downloadsDir, "giphy")) frontendDir := envOrDefault("FRONTEND_DIR", filepath.Join(root, "frontend")) workerScript := envOrDefault("WORKER_SCRIPT", filepath.Join(root, "worker", "downloader.py")) + geminiAPIKey := os.Getenv("GEMINI_API_KEY") + geminiModel := envOrDefault("GEMINI_MODEL", "gemini-2.5-flash") + giphyEnabled := envBoolOrDefault("GIPHY_ENABLED", true) + giphyAPIKey := os.Getenv("GIPHY_API_KEY") + giphyMaxResults := envIntOrDefault("GIPHY_MAX_RESULTS", 100) + giphyRating := envOrDefault("GIPHY_RATING", "g") + giphyLang := envOrDefault("GIPHY_LANG", "en") db, err := models.InitDB(dbPath) if err != nil { @@ -29,6 +39,25 @@ func main() { if err := handlers.EnsurePaths(downloadsDir, workerScript); err != nil { log.Fatal(err) } + if geminiAPIKey == "" { + log.Printf("warning: GEMINI_API_KEY is not configured; query expansion will use fallback behavior") + } + if giphyEnabled && strings.TrimSpace(giphyAPIKey) == "" { + log.Fatal("GIPHY_ENABLED is true but GIPHY_API_KEY is not configured") + } + if err := os.MkdirAll(giphyDownloadDir, 0o755); err != nil { + log.Fatal(err) + } + + geminiService := services.NewGeminiService(geminiAPIKey, geminiModel) + giphyService := services.NewGiphyService(services.GiphyConfig{ + Enabled: giphyEnabled, + APIKey: giphyAPIKey, + MaxResults: giphyMaxResults, + Rating: giphyRating, + Lang: giphyLang, + DownloadDir: giphyDownloadDir, + }, geminiService) app := &handlers.App{ DB: db, @@ -40,7 +69,8 @@ func main() { os.Getenv("SEARXNG_GOOGLE_VIDEO_ENGINE"), os.Getenv("SEARXNG_WEB_ENGINE"), ), - GeminiService: services.NewGeminiService(os.Getenv("GEMINI_API_KEY")), + GeminiService: geminiService, + GiphyService: giphyService, Hub: handlers.NewHub(), } app.SearchService.Debug = func(message string, data any) { @@ -49,6 +79,9 @@ func main() { app.GeminiService.Debug = func(message string, data any) { app.Hub.Broadcast("debug", gin.H{"message": message, "data": data}) } + app.GiphyService.Debug = func(message string, data any) { + app.Hub.Broadcast("debug", gin.H{"message": message, "data": data}) + } router := gin.Default() handlers.RegisterRoutes(router, app) @@ -75,3 +108,29 @@ func envOrDefault(key, fallback string) string { } return fallback } + +func envBoolOrDefault(key string, fallback bool) bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + switch value { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + case "": + return fallback + default: + return fallback + } +} + +func envIntOrDefault(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/backend/services/gemini.go b/backend/services/gemini.go index c61894b..0dd6c4e 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -20,6 +20,7 @@ import ( type GeminiService struct { APIKey string + Model string Client *http.Client GenerateEndpoint string TranslateEndpoint string @@ -69,11 +70,15 @@ type QueryExpansion struct { Querywords []string `json:"querywords"` } -func NewGeminiService(apiKey string) *GeminiService { +func NewGeminiService(apiKey, model string) *GeminiService { + if strings.TrimSpace(model) == "" { + model = "gemini-2.5-flash" + } return &GeminiService{ APIKey: apiKey, + Model: model, Client: &http.Client{Timeout: 40 * time.Second}, - GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", + GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/" + model + ":generateContent", TranslateEndpoint: "https://translate.googleapis.com/translate_a/single", visualCache: map[string]cachedVisualData{}, translationCache: map[string]cachedStringValue{}, @@ -99,6 +104,75 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) { return expanded, nil } +func (g *GeminiService) ExpandImageQueries(query string) ([]string, error) { + trimmed := strings.TrimSpace(query) + if trimmed == "" { + return nil, fmt.Errorf("query is empty") + } + cacheKey := "image-expansion\n" + trimmed + if cached, ok := g.getCachedExpansion(cacheKey); ok { + g.debug("gemini:image_expand_cache_hit", map[string]any{"query": trimmed, "expanded": cached}) + return cached, nil + } + + fallback := buildFallbackImageQueries(trimmed, g.TranslateQuery(trimmed)) + if g.APIKey == "" { + g.setCachedExpansion(cacheKey, fallback, 15*time.Minute) + return fallback, fmt.Errorf("gemini api key is not configured") + } + + body := map[string]any{ + "systemInstruction": map[string]any{ + "parts": []map[string]string{{ + "text": "Return exactly 5 concise English search queries for GIPHY image or GIF search. Respond with JSON only in this shape: {\"queries\":[\"...\",\"...\",\"...\",\"...\",\"...\"]}. Keep the queries meaning-preserving, practical, deduplicated, and concise.", + }}, + }, + "contents": []map[string]any{{ + "parts": []map[string]string{{ + "text": "User query: " + trimmed + "\nGenerate exactly 5 English search queries for GIPHY image or GIF search. Include a direct translation, a common phrasing, and only relevant related variants.", + }}, + }}, + "generationConfig": map[string]any{ + "responseMimeType": "application/json", + "temperature": 0.2, + "maxOutputTokens": 160, + }, + } + + rawText, err := g.generateText(body) + if err != nil { + g.debug("gemini:image_expand_error", map[string]any{"query": trimmed, "error": err.Error()}) + g.setCachedExpansion(cacheKey, fallback, 15*time.Minute) + return fallback, err + } + jsonText, err := extractJSONObject(rawText) + if err != nil { + g.debug("gemini:image_expand_parse_error", map[string]any{"query": trimmed, "error": err.Error()}) + g.setCachedExpansion(cacheKey, fallback, 15*time.Minute) + return fallback, err + } + + var payload struct { + Queries []string `json:"queries"` + } + if err := json.Unmarshal([]byte(jsonText), &payload); err != nil { + g.debug("gemini:image_expand_json_error", map[string]any{"query": trimmed, "error": err.Error(), "raw": truncateForError(rawText, 200)}) + g.setCachedExpansion(cacheKey, fallback, 15*time.Minute) + return fallback, err + } + queries := normalizeImageExpansionQueries(payload.Queries) + if len(queries) != 5 { + err := fmt.Errorf("gemini image expansion returned %d queries", len(queries)) + g.debug("gemini:image_expand_invalid_count", map[string]any{"query": trimmed, "queries": queries, "error": err.Error()}) + g.setCachedExpansion(cacheKey, fallback, 15*time.Minute) + return fallback, err + } + + g.setCachedExpansion(cacheKey, queries, 15*time.Minute) + g.debug("gemini:image_expand_success", map[string]any{"query": trimmed, "queries": queries}) + return queries, nil +} + func (g *GeminiService) TranslateSummaryToKorean(text string) (string, error) { trimmed := strings.TrimSpace(text) if trimmed == "" { diff --git a/backend/services/gemini_test.go b/backend/services/gemini_test.go index 0004f73..84eed9c 100644 --- a/backend/services/gemini_test.go +++ b/backend/services/gemini_test.go @@ -15,7 +15,7 @@ func TestTranslateQueryFallsBackToGoogleWithoutGeminiKey(t *testing.T) { })) defer server.Close() - service := NewGeminiService("") + service := NewGeminiService("", "") service.Client = &http.Client{Timeout: 2 * time.Second} service.TranslateEndpoint = server.URL @@ -31,7 +31,7 @@ func TestTranslateQueryFallsBackToDictionaryWhenTranslateFails(t *testing.T) { })) defer server.Close() - service := NewGeminiService("") + service := NewGeminiService("", "") service.Client = &http.Client{Timeout: 2 * time.Second} service.TranslateEndpoint = server.URL @@ -50,7 +50,7 @@ func TestTranslateSummaryToKoreanUsesGoogleAndCaches(t *testing.T) { })) defer server.Close() - service := NewGeminiService("") + service := NewGeminiService("", "") service.Client = &http.Client{Timeout: 2 * time.Second} service.TranslateEndpoint = server.URL @@ -84,7 +84,7 @@ func TestBuildSupplementalQueriesReturnsGeneratedLines(t *testing.T) { })) defer server.Close() - service := NewGeminiService("dummy-key") + service := NewGeminiService("dummy-key", "") service.Client = &http.Client{Timeout: 2 * time.Second} service.GenerateEndpoint = server.URL @@ -99,6 +99,52 @@ func TestBuildSupplementalQueriesReturnsGeneratedLines(t *testing.T) { } } +func TestExpandImageQueriesReturnsExactlyFiveEnglishQueries(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"funny cat\",\"funny cat gif\",\"cute cat reaction\",\"cat meme gif\",\"animated cat sticker\"]}"}]}}]}`)) + })) + defer server.Close() + + service := NewGeminiService("dummy-key", "gemini-2.5-flash") + service.Client = &http.Client{Timeout: 2 * time.Second} + service.GenerateEndpoint = server.URL + + queries, err := service.ExpandImageQueries("웃긴 고양이") + if err != nil { + t.Fatalf("expected image query expansion to succeed, got %v", err) + } + if len(queries) != 5 { + t.Fatalf("expected exactly 5 queries, got %#v", queries) + } + if queries[0] != "funny cat" || queries[4] != "animated cat sticker" { + t.Fatalf("unexpected image queries: %#v", queries) + } +} + +func TestExpandImageQueriesFallsBackWhenGeminiFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusBadGateway) + })) + defer server.Close() + + service := NewGeminiService("dummy-key", "gemini-2.5-flash") + service.Client = &http.Client{Timeout: 2 * time.Second} + service.GenerateEndpoint = server.URL + service.TranslateEndpoint = server.URL + + queries, err := service.ExpandImageQueries("happy dog") + if err == nil { + t.Fatal("expected fallback warning error when gemini expansion fails") + } + if len(queries) != 5 { + t.Fatalf("expected fallback to still provide 5 queries, got %#v", queries) + } + if queries[0] != "happy dog" { + t.Fatalf("expected original query to be preserved in fallback, got %#v", queries) + } +} + func TestSelectUnevaluatedCandidatesSkipsReviewedLinks(t *testing.T) { ranked := []SearchResult{ {Link: "https://a.example"}, @@ -129,7 +175,7 @@ func TestRemainingGeminiCapacityShrinksWithReviewedItems(t *testing.T) { } func TestGeminiVisualCacheRoundTrip(t *testing.T) { - service := NewGeminiService("") + service := NewGeminiService("", "") service.setCachedVisual("image\nhttps://example.com/thumb.jpg", "abc", "image/jpeg", time.Minute) data, mimeType, ok := service.getCachedVisual("image\nhttps://example.com/thumb.jpg") @@ -142,7 +188,7 @@ func TestGeminiVisualCacheRoundTrip(t *testing.T) { } func TestGeminiTranslationCacheRoundTrip(t *testing.T) { - service := NewGeminiService("") + service := NewGeminiService("", "") service.setCachedTranslation("비 오는 도시", "rainy city", time.Minute) value, ok := service.getCachedTranslation("비 오는 도시") @@ -155,7 +201,7 @@ func TestGeminiTranslationCacheRoundTrip(t *testing.T) { } func TestGeminiExpansionCacheRoundTrip(t *testing.T) { - service := NewGeminiService("") + service := NewGeminiService("", "") service.setCachedExpansion("city rain", []string{"city rain", "city rain stock footage"}, time.Minute) value, ok := service.getCachedExpansion("city rain") diff --git a/backend/services/giphy.go b/backend/services/giphy.go new file mode 100644 index 0000000..8cb10dc --- /dev/null +++ b/backend/services/giphy.go @@ -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) + } +} diff --git a/backend/services/giphy_test.go b/backend/services/giphy_test.go new file mode 100644 index 0000000..dc9882e --- /dev/null +++ b/backend/services/giphy_test.go @@ -0,0 +1,170 @@ +package services + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNewGiphyServiceAppliesDefaults(t *testing.T) { + service := NewGiphyService(GiphyConfig{Enabled: true}, nil) + if service.Config.MaxResults != 100 { + t.Fatalf("expected default max results 100, got %d", service.Config.MaxResults) + } + if service.Config.Rating != "g" || service.Config.Lang != "en" { + t.Fatalf("unexpected defaults: %#v", service.Config) + } + if service.BaseURL != defaultGiphyAPIBaseURL { + t.Fatalf("expected default base url %q, got %q", defaultGiphyAPIBaseURL, service.BaseURL) + } +} + +func TestIsAllowedGiphyDownloadURL(t *testing.T) { + if !isAllowedGiphyDownloadURL("https://media2.giphy.com/media/test/giphy.gif") { + t.Fatal("expected media.giphy.com host to be allowed") + } + if isAllowedGiphyDownloadURL("https://example.com/file.gif") { + t.Fatal("expected non-giphy host to be rejected") + } +} + +func TestBuildGiphyFilenameSanitizesInput(t *testing.T) { + got := buildGiphyFilename("ABC123", "Funny Cat!!!", ".gif", time.Date(2026, 3, 24, 15, 32, 12, 0, time.UTC)) + want := "giphy_abc123_funny-cat_20260324_153212.gif" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestGiphySearchAggregatesDedupesAndCapsAt100(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"funny cat\",\"happy cat gif\",\"cat reaction\",\"cat meme\",\"animated cat sticker\"]}"}]}}]}`)) + }) + mux.HandleFunc("/v1/gifs/search", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + limit := atoiOrZero(r.URL.Query().Get("limit")) + offset := atoiOrZero(r.URL.Query().Get("offset")) + data := make([]map[string]any, 0, limit) + for idx := 0; idx < limit; idx++ { + id := fmt.Sprintf("%s-%d", strings.ReplaceAll(query, " ", "-"), offset+idx) + if idx == 0 { + id = fmt.Sprintf("shared-%d", offset) + } + data = append(data, map[string]any{ + "id": id, + "title": fmt.Sprintf("%s %d", query, offset+idx), + "slug": fmt.Sprintf("%s-%d", strings.ReplaceAll(query, " ", "-"), offset+idx), + "rating": "g", + "url": "https://giphy.com/gifs/" + id, + "images": map[string]any{ + "original": map[string]any{ + "url": fmt.Sprintf("https://media.giphy.com/media/%s/giphy.gif", id), + "width": "480", + "height": "270", + }, + "fixed_width": map[string]any{ + "url": fmt.Sprintf("https://media.giphy.com/media/%s/200w.gif", id), + "width": "200", + "height": "113", + }, + "fixed_width_still": map[string]any{ + "url": fmt.Sprintf("https://media.giphy.com/media/%s/200w_s.gif", id), + }, + }, + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + }) + server := httptest.NewServer(mux) + defer server.Close() + + gemini := NewGeminiService("dummy-key", "gemini-2.5-flash") + gemini.Client = &http.Client{Timeout: 2 * time.Second} + gemini.GenerateEndpoint = server.URL + "/generate" + + service := NewGiphyService(GiphyConfig{ + Enabled: true, + APIKey: "test-key", + MaxResults: 100, + BaseURL: server.URL, + }, gemini) + service.Client = &http.Client{Timeout: 2 * time.Second} + + resp, err := service.SearchImages("웃긴 고양이", 100) + if err != nil { + t.Fatalf("expected giphy search to succeed, got %v", err) + } + if len(resp.ExpandedQueries) != 5 { + t.Fatalf("expected 5 expanded queries, got %#v", resp.ExpandedQueries) + } + if resp.Total != 100 || len(resp.Items) != 100 { + t.Fatalf("expected capped 100 unique items, got total=%d len=%d", resp.Total, len(resp.Items)) + } + seen := map[string]bool{} + for _, item := range resp.Items { + if seen[item.ProviderID] { + t.Fatalf("found duplicate providerId %q in aggregated results", item.ProviderID) + } + seen[item.ProviderID] = true + } +} + +func TestDownloadMediaHappyPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/gif") + _, _ = w.Write([]byte("GIF89a")) + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse server url: %v", err) + } + tempDir := t.TempDir() + service := NewGiphyService(GiphyConfig{ + Enabled: true, + APIKey: "test-key", + DownloadDir: tempDir, + }, nil) + service.Client = &http.Client{ + Timeout: 2 * time.Second, + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + if strings.HasSuffix(clone.URL.Host, "giphy.com") { + clone.URL.Scheme = serverURL.Scheme + clone.URL.Host = serverURL.Host + clone.Host = serverURL.Host + } + return http.DefaultTransport.RoundTrip(clone) + }), + } + + resp, err := service.DownloadMedia(GiphyDownloadRequest{ + ProviderID: "abc123", + Title: "Funny Cat", + DownloadURL: "https://media.giphy.com/media/abc123/giphy.gif", + }) + if err != nil { + t.Fatalf("expected download to succeed, got %v", err) + } + if !resp.OK { + t.Fatalf("expected ok response, got %#v", resp) + } + if filepath.Ext(resp.FileName) != ".gif" { + t.Fatalf("expected gif extension, got %q", resp.FileName) + } + if _, err := os.Stat(resp.SavedPath); err != nil { + t.Fatalf("expected saved file at %q: %v", resp.SavedPath, err) + } +} + diff --git a/frontend/app.js b/frontend/app.js index 67166c5..79cf3ce 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -8,10 +8,15 @@ const queryVariants = document.getElementById("queryVariants"); const searchModeTitle = document.getElementById("searchModeTitle"); const searchModeHint = document.getElementById("searchModeHint"); const searchSubmitButton = document.getElementById("searchSubmitButton"); +const searchResultsViewport = document.getElementById("searchResultsViewport"); const mediaTypeToggles = Array.from(document.querySelectorAll("[data-media-type-toggle]")); const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]")); const imageSearchSandbox = document.getElementById("imageSearchSandbox"); const imagePromptChips = Array.from(document.querySelectorAll("[data-image-prompt]")); +const giphyMetaPanel = document.getElementById("giphyMetaPanel"); +const giphyOriginalQuery = document.getElementById("giphyOriginalQuery"); +const giphyResultCount = document.getElementById("giphyResultCount"); +const giphyExpandedQueries = document.getElementById("giphyExpandedQueries"); const dropzone = document.getElementById("dropzone"); const fileInput = document.getElementById("fileInput"); const uploadResult = document.getElementById("uploadResult"); @@ -98,44 +103,7 @@ const resultPreviewInflight = new Map(); let cardSummaryObserver = null; let activeMediaType = "video"; const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview"; -const MOCK_IMAGE_RESULTS = [ - { - title: "Neon Crosswalk Portrait", - tag: "Test Image", - caption: "도심 야간 조명과 보케를 강조한 테스트용 이미지 카드입니다. 향후 이미지 검색 결과 카드 레이아웃 검증에 사용할 수 있습니다.", - imageUrl: "https://placehold.co/1200x900/0f172a/f8fafc?text=Neon+Crosswalk", - }, - { - title: "Editorial Summer Street", - tag: "Prototype", - caption: "에디토리얼 라이프스타일 톤을 가정한 샘플 썸네일입니다. 카드 비율과 타이포 계층을 보기에 적당합니다.", - imageUrl: "https://placehold.co/1200x900/f59e0b/111827?text=Summer+Street", - }, - { - title: "Minimal Product Tabletop", - tag: "Mock Result", - caption: "제품 컷 기반 이미지 검색을 상정한 목업 결과입니다. 이미지 중심 레이아웃에서 텍스트 양을 테스트하기 위한 예시입니다.", - imageUrl: "https://placehold.co/1200x900/e5e7eb/111827?text=Product+Tabletop", - }, - { - title: "Vintage Fashion Frame", - tag: "Image Search", - caption: "패션 무드보드나 포스터 레퍼런스 탐색 화면에 어울리도록 구성한 테스트용 결과입니다.", - imageUrl: "https://placehold.co/1200x900/7c3aed/f5f3ff?text=Vintage+Fashion", - }, - { - title: "Botanical Studio Light", - tag: "Preview", - caption: "정적인 피사체 중심 이미지 검색 UI에서 호버 없이도 충분히 읽히는 카드 밀도를 보기 위한 샘플입니다.", - imageUrl: "https://placehold.co/1200x900/14532d/d1fae5?text=Botanical+Studio", - }, - { - title: "Magazine Cover Layout", - tag: "Test Asset", - caption: "향후 이미지 상세 모달이나 컬렉션 기능을 붙일 때 사용할 수 있는 테스트용 카드 자리입니다.", - imageUrl: "https://placehold.co/1200x900/111827/fde68a?text=Magazine+Cover", - }, -]; +let activeImageSearchResponse = null; function proxiedPreviewURL(src) { if (!src) { @@ -337,19 +305,55 @@ function syncMediaTypeButtons() { } } -function renderMockImageResults(queryText = "") { - const queryLabel = String(queryText || "").trim() || "test image"; +function renderImageEmptyState(message) { + searchResults.innerHTML = ""; + searchResults.innerHTML = `