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 = `
${message}
`; +} + +function renderExpandedQueries(queries = []) { + giphyExpandedQueries.innerHTML = ""; + for (const item of queries) { + const chip = document.createElement("span"); + chip.className = "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-zinc-300"; + chip.textContent = item; + giphyExpandedQueries.appendChild(chip); + } +} + +function updateImageSearchMeta(data = null) { + activeImageSearchResponse = data; + const visible = Boolean(data); + setHidden(giphyMetaPanel, !visible, "block"); + if (!visible) { + giphyOriginalQuery.textContent = "Original query: -"; + giphyResultCount.textContent = "0 results"; + giphyExpandedQueries.innerHTML = ""; + return; + } + giphyOriginalQuery.textContent = `Original query: ${data.originalQuery || "-"}`; + giphyResultCount.textContent = `${Number(data.total || 0)} results`; + renderExpandedQueries(data.expandedQueries || []); +} + +function renderImageResults(items = []) { searchResults.innerHTML = ""; searchResults.classList.remove("xl:grid-cols-3"); searchResults.classList.add("xl:grid-cols-4"); - for (const item of MOCK_IMAGE_RESULTS) { + if (!items.length) { + renderImageEmptyState("GIPHY에서 표시할 이미지/GIF를 찾지 못했습니다."); + return; + } + for (const item of items) { const node = imageCardTemplate.content.firstElementChild.cloneNode(true); const image = node.querySelector("img"); - image.src = item.imageUrl; + image.loading = "lazy"; + image.src = item.previewStillUrl || item.previewUrl || item.fullUrl || PREVIEW_PLACEHOLDER; image.alt = item.title; - node.querySelector(".image-card-tag").textContent = `${item.tag} / ${queryLabel}`; + node.querySelector(".image-card-tag").textContent = `GIPHY / ${item.searchQuery || "query"}`; node.querySelector(".image-card-title").textContent = item.title; - node.querySelector(".image-card-caption").textContent = item.caption; + node.querySelector(".image-card-caption").textContent = item.title || "Untitled GIPHY result"; + node.querySelector(".image-card-meta").textContent = `${item.rating || "unrated"} / ${item.width || "?"}x${item.height || "?"}`; + node.addEventListener("click", () => openResultModal(item)); searchResults.appendChild(node); } } @@ -358,20 +362,23 @@ function applyMediaTypeUI() { const isImageMode = activeMediaType === "image"; syncMediaTypeButtons(); setHidden(imageSearchSandbox, !isImageMode, "block"); + setHidden(giphyMetaPanel, true, "block"); setHidden(queryVariants, true, ""); showWarning(""); + searchResultsViewport.classList.toggle("image-results-scroll", isImageMode); searchModeTitle.textContent = isImageMode ? "AI Image Discovery" : "AI Smart Discovery"; searchModeHint.textContent = isImageMode - ? "이미지 검색 프로토타입 모드입니다. 현재는 UI 전용 테스트 결과를 표시합니다." + ? "GIPHY 이미지/GIF 검색 모드입니다. Gemini가 영어 검색어 5개로 확장한 뒤 최대 100개 결과를 보여줍니다." : "비디오 검색 모드입니다. 실제 검색 API와 연결되어 있습니다."; searchQuery.placeholder = isImageMode ? "검색할 이미지를 설명하세요" : "한글 검색어를 입력하세요"; - searchSubmitButton.textContent = isImageMode ? "Image Search Test" : "AI Search"; + searchSubmitButton.textContent = isImageMode ? "Search GIPHY" : "AI Search"; for (const button of platformToggles) { button.classList.toggle("hidden", isImageMode); } if (isImageMode) { - setStatus("image prototype mode", 0); - renderMockImageResults(searchQuery.value); + updateImageSearchMeta(null); + setStatus("giphy image mode", 0); + renderImageEmptyState("GIPHY 검색어를 입력하면 여기에 최대 100개의 이미지/GIF 결과가 표시됩니다."); } else { searchResults.classList.add("xl:grid-cols-3"); searchResults.classList.remove("xl:grid-cols-4"); @@ -579,6 +586,10 @@ function resetResultModalMedia() { setHidden(resultModalGooglePanel, true, "flex"); } +function isGiphyResult(item) { + return String(item?.provider || "").toLowerCase() === "giphy" || String(item?.source || "").toLowerCase() === "giphy"; +} + function showResultModalFrame(src) { if (!src) { return; @@ -830,21 +841,36 @@ async function openResultModal(item) { logEvent("result:modal:error", { message: "result modal is not fully initialized" }); return; } + const giphyItem = isGiphyResult(item); activeResultItem = item; activeResultModalSummaryRequest += 1; const summaryRequestId = activeResultModalSummaryRequest; resultModalTitle.textContent = item.title || "Untitled"; resultModalSource.textContent = item.source || ""; - resultModalReason.textContent = summarizeReason(item.reason) || "AI 노트가 없습니다."; - const originalSummary = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다."; + resultModalReason.textContent = giphyItem + ? [ + `Original Query: ${item.originalQuery || "-"}`, + `Expanded Query: ${item.searchQuery || "-"}`, + `Rating: ${item.rating || "unrated"}`, + ].join("\n") + : (summarizeReason(item.reason) || "AI 노트가 없습니다."); + const originalSummary = giphyItem + ? `Powered by GIPHY\n${item.width || "?"} x ${item.height || "?"}\n${item.sourcePageUrl || item.openUrl || item.link || ""}`.trim() + : (item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다."); resultModalSnippet.textContent = originalSummary; - resultModalOpenExternal.href = item.link || "#"; + resultModalOpenExternal.href = item.openUrl || item.sourcePageUrl || item.link || "#"; resultModalDownload.classList.toggle("hidden", !item.actionType); resultModalDownload.textContent = item.actionLabel || "Open Source"; - const showSecondary = Boolean(item.secondaryActionLabel && item.link); + const showSecondary = Boolean(item.secondaryActionLabel && (item.openUrl || item.link)); resultModalSecondaryAction.classList.toggle("hidden", !showSecondary); resultModalSecondaryAction.textContent = item.secondaryActionLabel || "Open Source"; resetResultModalMedia(); + if (giphyItem) { + showResultModalThumbnail(item.fullUrl || item.previewUrl || item.previewStillUrl, item.title || ""); + showModal(resultModal); + logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link, provider: item.provider }); + return; + } const embedURL = buildResultModalEmbedURL(item); const fallbackReason = item.previewBlockedReason || "Embedded view was unavailable, switched to fallback preview."; let resolvedPreviewURL = item.previewVideoUrl || ""; @@ -896,9 +922,25 @@ function closeResultViewer() { searchForm.addEventListener("submit", async (event) => { event.preventDefault(); if (activeMediaType === "image") { - renderMockImageResults(searchQuery.value); - logEvent("image-search:prototype", { query: searchQuery.value, results: MOCK_IMAGE_RESULTS.length }); - setStatus("image prototype results ready", 100); + setStatus("searching GIPHY", 10); + showWarning(""); + try { + const data = await api("/api/giphy/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: searchQuery.value, maxResults: 100 }), + }); + updateImageSearchMeta(data); + renderImageResults(data.items || []); + showWarning(data.warning || ""); + logEvent("image-search:completed", { query: data.originalQuery || searchQuery.value, total: data.total || 0, expandedQueries: data.expandedQueries || [] }); + setStatus("giphy search complete", 100); + } catch (error) { + updateImageSearchMeta(null); + renderImageEmptyState("GIPHY 검색 결과를 불러오지 못했습니다."); + showWarning(error.message); + setStatus("giphy search failed", 100); + } return; } setStatus("preparing search", 5); @@ -933,8 +975,7 @@ for (const chip of imagePromptChips) { chip.addEventListener("click", () => { searchQuery.value = chip.dataset.imagePrompt || ""; if (activeMediaType === "image") { - renderMockImageResults(searchQuery.value); - setStatus("image prompt applied", 100); + setStatus("image prompt applied", 0); } }); } @@ -991,6 +1032,35 @@ function closeModal() { pendingDownload = null; } +async function downloadGiphyItem(item) { + resultModalDownload.disabled = true; + const originalLabel = resultModalDownload.textContent; + resultModalDownload.textContent = "Downloading..."; + try { + const data = await api("/api/giphy/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: item.providerId, + title: item.title, + downloadUrl: item.downloadUrl, + originalQuery: item.originalQuery, + selectedExpansionQuery: item.searchQuery, + }), + }); + downloadResult.textContent = `${data.fileName} saved to ${data.savedPath}`; + setStatus("giphy download complete", 100); + logEvent("giphy:download:completed", { providerId: item.providerId, fileName: data.fileName, savedPath: data.savedPath }); + } catch (error) { + downloadResult.textContent = error.message; + setStatus("giphy download failed", 100); + logEvent("giphy:download:error", { providerId: item.providerId, message: error.message, data: error.data || null }); + } finally { + resultModalDownload.disabled = false; + resultModalDownload.textContent = originalLabel; + } +} + dropzone.addEventListener("dragover", (event) => { event.preventDefault(); dropzone.classList.add("border-white/60", "bg-white/[0.08]"); @@ -1059,10 +1129,14 @@ if (resultModalReady) { } }); resultModalDownload.addEventListener("click", async () => { - if (!activeResultItem?.link) { + if (!activeResultItem) { return; } const currentItem = activeResultItem; + if (currentItem.actionType === "giphy_download") { + await downloadGiphyItem(currentItem); + return; + } if (currentItem.actionType === "download") { try { closeResultViewer(); @@ -1073,13 +1147,13 @@ if (resultModalReady) { } return; } - window.open(currentItem.link, "_blank", "noopener,noreferrer"); + window.open(currentItem.openUrl || currentItem.sourcePageUrl || currentItem.link, "_blank", "noopener,noreferrer"); }); resultModalSecondaryAction.addEventListener("click", () => { - if (!activeResultItem?.link) { + if (!activeResultItem) { return; } - window.open(activeResultItem.link, "_blank", "noopener,noreferrer"); + window.open(activeResultItem.openUrl || activeResultItem.sourcePageUrl || activeResultItem.link, "_blank", "noopener,noreferrer"); }); } previewModal.addEventListener("click", (event) => { @@ -1195,6 +1269,13 @@ window.addEventListener("error", (event) => { window.addEventListener("unhandledrejection", (event) => { logEvent("window:unhandledrejection", { reason: String(event.reason) }); }); +window.addEventListener("keydown", (event) => { + if (event.key !== "Escape") { + return; + } + closeModal(); + closeResultViewer(); +}); connectWS(); syncPlatformButtons(); diff --git a/frontend/index.html b/frontend/index.html index 89a6fe4..5f4044b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -55,9 +55,12 @@