diff --git a/backend/handlers/api.go b/backend/handlers/api.go index 1a57c73..19294b7 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -248,7 +248,8 @@ func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath s func (a *App) searchMedia(c *gin.Context) { var req struct { - Query string `json:"query"` + Query string `json:"query"` + Platforms []string `json:"platforms"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -265,8 +266,9 @@ func (a *App) searchMedia(c *gin.Context) { queryVariants = []string{req.Query} } + enabledPlatforms := normalizePlatforms(req.Platforms) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "searching Google Video, Envato, and Artgrid", "progress": 35}) - results, err := a.SearchService.SearchMedia(queryVariants) + results, err := a.SearchService.SearchMedia(queryVariants, enabledPlatforms) if err != nil { a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search failed", "progress": 100, "message": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) @@ -285,9 +287,12 @@ func (a *App) searchMedia(c *gin.Context) { rankQuery = strings.Join(queryVariants[:min(len(queryVariants), 3)], " ") } scored := rankSearchResults(rankQuery, results) - shortlist := scored[:min(len(scored), 10)] - a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing shortlisted thumbnails with Gemini Vision", "progress": 75}) - recommended, err := a.GeminiService.Recommend(req.Query, shortlist) + a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75}) + recommended := evaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored) + err = nil + if len(recommended) == 0 { + err = fmt.Errorf("gemini vision returned no recommended items across all candidate batches") + } if err != nil { fallback := make([]services.AIRecommendation, 0, min(20, len(scored))) for _, result := range scored[:min(20, len(scored))] { @@ -345,6 +350,53 @@ func min(a, b int) int { return b } +func normalizePlatforms(platforms []string) map[string]bool { + if len(platforms) == 0 { + return map[string]bool{ + "envato": true, + "artgrid": true, + "google video": true, + } + } + normalized := map[string]bool{} + for _, item := range platforms { + switch strings.ToLower(strings.TrimSpace(item)) { + case "envato": + normalized["envato"] = true + case "artgrid": + normalized["artgrid"] = true + case "google video", "google_video", "google": + normalized["google video"] = true + } + } + return normalized +} + +func evaluateAllCandidatesWithGemini(service *services.GeminiService, query string, ranked []services.SearchResult) []services.AIRecommendation { + const chunkSize = 8 + merged := make([]services.AIRecommendation, 0, len(ranked)) + seen := map[string]bool{} + for start := 0; start < len(ranked); start += chunkSize { + end := start + chunkSize + if end > len(ranked) { + end = len(ranked) + } + batch := ranked[start:end] + recommended, err := service.Recommend(query, batch) + if err != nil { + continue + } + for _, item := range recommended { + if item.Link == "" || seen[item.Link] { + continue + } + seen[item.Link] = true + merged = append(merged, item) + } + } + return merged +} + func rankSearchResults(query string, results []services.SearchResult) []services.SearchResult { queryTerms := strings.Fields(strings.ToLower(query)) positiveTerms := []string{ diff --git a/backend/services/cse.go b/backend/services/cse.go index 3521efe..e43a4a4 100644 --- a/backend/services/cse.go +++ b/backend/services/cse.go @@ -45,7 +45,7 @@ func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchServi } } -func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) { +func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[string]bool) ([]SearchResult, error) { if s.BaseURL == "" { return nil, fmt.Errorf("searxng base url is not configured") } @@ -93,6 +93,9 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) { continue } for _, source := range sources { + if len(enabledPlatforms) > 0 && !enabledPlatforms[strings.ToLower(source.name)] { + continue + } for _, searchQuery := range source.build(base) { items, err := s.search(searchQuery, source.categories, source.engine, source.name) if err != nil { @@ -205,6 +208,9 @@ func (s *SearchService) enrichArtgrid(result SearchResult) SearchResult { extractMetaContent(html, "og:image"), extractMetaContent(html, "twitter:image"), ) + if result.ThumbnailURL == "" { + result.ThumbnailURL = extractArtgridBackgroundThumbnail(html, clipID) + } } if result.PreviewVideoURL == "" { result.PreviewVideoURL = extractVideoPreviewURL(html) @@ -283,7 +289,7 @@ func buildGoogleVideoQueries(base string) []string { func buildEnvatoQueries(base string) []string { return []string{ fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:elements.envato.com`, base), - fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:videohive.net/item`, base), + fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:elements.envato.com/stock-video`, base), } } @@ -295,6 +301,10 @@ func buildArtgridQueries(base string) []string { } func isUsefulGoogleVideoResult(result SearchResult) bool { + lowerLink := strings.ToLower(result.Link) + if !(strings.Contains(lowerLink, "youtube.com/watch") || strings.Contains(lowerLink, "youtu.be/") || strings.Contains(lowerLink, "youtube.com/shorts/")) { + return false + } text := strings.ToLower(result.Title + " " + result.Snippet) for _, banned := range []string{ "tutorial", "how to", "review", "reaction", "podcast", "interview", "walkthrough", @@ -315,11 +325,8 @@ func isRenderableEnvatoResult(result SearchResult) bool { } host := strings.ToLower(parsed.Host) path := strings.Trim(parsed.Path, "/") - if strings.Contains(host, "videohive.net") { - return strings.HasPrefix(path, "item/") && len(strings.Split(path, "/")) >= 2 - } if strings.Contains(host, "elements.envato.com") { - if path == "" || strings.Contains(path, "/") { + if path == "" || strings.Contains(path, "/stock-video") || strings.Contains(path, "/video-templates") { return false } return regexp.MustCompile(`-[A-Z0-9]{6,}$`).MatchString(path) @@ -407,13 +414,24 @@ func extractVideoPreviewURL(html string) string { candidate := strings.ReplaceAll(match, `\/`, `/`) candidate = strings.ReplaceAll(candidate, `\u002F`, `/`) candidate = strings.ReplaceAll(candidate, `\\`, "") - if strings.Contains(strings.ToLower(candidate), "preview") || strings.Contains(strings.ToLower(candidate), "video") { + if strings.Contains(strings.ToLower(candidate), "preview") || strings.Contains(strings.ToLower(candidate), "video") || strings.Contains(strings.ToLower(candidate), "watermark") { return candidate } } return "" } +func extractArtgridBackgroundThumbnail(html, clipID string) string { + pattern := regexp.MustCompile(`https://[^"'\\s>]+(?:artgrid\.imgix\.net|cms-public-artifacts\.artlist\.io|artlist-content-images\.imgix\.net)[^"'\\s>]+(?:jpeg|jpg|png|webp)`) + matches := pattern.FindAllString(html, -1) + for _, match := range matches { + if strings.Contains(match, clipID) || strings.Contains(strings.ToLower(match), "graded-thumbnail") { + return match + } + } + return "" +} + func extractArtgridClipID(link string) string { matches := regexp.MustCompile(`/clip/([0-9]+)/`).FindStringSubmatch(link) if len(matches) == 2 { diff --git a/frontend/app.js b/frontend/app.js index b38ae78..f53fa1d 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -5,6 +5,7 @@ const searchQuery = document.getElementById("searchQuery"); const searchResults = document.getElementById("searchResults"); const searchWarning = document.getElementById("searchWarning"); const queryVariants = document.getElementById("queryVariants"); +const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]")); const dropzone = document.getElementById("dropzone"); const fileInput = document.getElementById("fileInput"); const uploadResult = document.getElementById("uploadResult"); @@ -35,6 +36,7 @@ let cropStart = 0; let cropEnd = 0; let cropMax = 0; let activeThumb = null; +const activePlatforms = new Set(["envato", "artgrid", "google video"]); function setStatus(label, progress) { statusLabel.textContent = label; @@ -87,6 +89,19 @@ function renderQueryVariants(queries = []) { queryVariants.classList.remove("hidden"); } +function syncPlatformButtons() { + for (const button of platformToggles) { + const platform = button.dataset.platformToggle; + const active = activePlatforms.has(platform); + button.classList.toggle("bg-white", active); + button.classList.toggle("text-black", active); + button.classList.toggle("border-white", active); + button.classList.toggle("bg-transparent", !active); + button.classList.toggle("text-zinc-300", !active); + button.classList.toggle("border-white/20", !active); + } +} + function connectWS() { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const socket = new WebSocket(`${protocol}://${window.location.host}/ws`); @@ -145,12 +160,13 @@ function renderResults(results) { if (item.previewVideoUrl) { previewVideo.src = item.previewVideoUrl; previewVideo.poster = item.thumbnailUrl || ""; - node.addEventListener("mouseenter", () => { + const mediaArea = node.querySelector(".relative"); + mediaArea.addEventListener("mouseenter", () => { overlays.forEach((overlay) => overlay.classList.add("hidden")); previewVideo.classList.remove("hidden"); previewVideo.play().catch(() => {}); }); - node.addEventListener("mouseleave", () => { + mediaArea.addEventListener("mouseleave", () => { previewVideo.pause(); previewVideo.currentTime = 0; previewVideo.classList.add("hidden"); @@ -169,7 +185,7 @@ searchForm.addEventListener("submit", async (event) => { const data = await api("/api/search", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query: searchQuery.value }), + body: JSON.stringify({ query: searchQuery.value, platforms: Array.from(activePlatforms) }), }); renderResults(data.results || []); renderQueryVariants(data.queries || []); @@ -368,6 +384,18 @@ previewThumbnail.addEventListener("load", () => { previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`; } }); +for (const button of platformToggles) { + button.addEventListener("click", () => { + const platform = button.dataset.platformToggle; + if (activePlatforms.has(platform) && activePlatforms.size > 1) { + activePlatforms.delete(platform); + } else { + activePlatforms.add(platform); + } + syncPlatformButtons(); + }); +} connectWS(); +syncPlatformButtons(); setStatus("idle", 0); diff --git a/frontend/index.html b/frontend/index.html index acccf29..b40715e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -35,6 +35,11 @@

AI Smart Discovery

+
+ + + +
@@ -139,6 +144,6 @@ - +