package handlers import ( "bufio" "bytes" "database/sql" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "time" "ai-media-hub/backend/models" "ai-media-hub/backend/services" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/gorilla/websocket" ) type App struct { DB *sql.DB DownloadsDir string PreviewCacheDir string WorkerScript string SearchService *services.SearchService GeminiService *services.GeminiService GiphyService *services.GiphyService Hub *Hub } type Hub struct { clients map[*websocket.Conn]bool mu sync.Mutex } func NewHub() *Hub { return &Hub{clients: map[*websocket.Conn]bool{}} } func (h *Hub) Broadcast(event string, data any) { h.mu.Lock() defer h.mu.Unlock() payload, _ := json.Marshal(gin.H{"event": event, "data": data}) for conn := range h.clients { _ = conn.WriteMessage(websocket.TextMessage, payload) } } func (h *Hub) Add(conn *websocket.Conn) { h.mu.Lock() defer h.mu.Unlock() h.clients[conn] = true } func (h *Hub) Remove(conn *websocket.Conn) { h.mu.Lock() defer h.mu.Unlock() delete(h.clients, conn) _ = conn.Close() } type PreviewResponse struct { Title string `json:"title"` Thumbnail string `json:"thumbnail"` PreviewStreamURL string `json:"previewStreamUrl"` Duration string `json:"duration"` DurationSeconds int `json:"durationSeconds"` StartDefault string `json:"startDefault"` EndDefault string `json:"endDefault"` Qualities []map[string]any `json:"qualities"` } type searchDebugSummary struct { Total int `json:"total"` VisibleCount int `json:"visibleCount,omitempty"` BySource map[string]int `json:"bySource"` WithPreview int `json:"withPreview"` WithThumbnail int `json:"withThumbnail"` WithUsableThumbnail int `json:"withUsableThumbnail,omitempty"` WithLowValueThumbnail int `json:"withLowValueThumbnail,omitempty"` WithEmbedURL int `json:"withEmbedUrl,omitempty"` Top []map[string]any `json:"top"` Warning string `json:"warning,omitempty"` DurationMS int64 `json:"durationMs,omitempty"` GeminiCandidateCap int `json:"geminiCandidateCap,omitempty"` } type debugResponseWriter struct { gin.ResponseWriter body bytes.Buffer } func (w *debugResponseWriter) Write(data []byte) (int, error) { if w.body.Len() < 4096 { remaining := 4096 - w.body.Len() if len(data) > remaining { w.body.Write(data[:remaining]) } else { w.body.Write(data) } } return w.ResponseWriter.Write(data) } func RegisterRoutes(router *gin.Engine, app *App) { router.Use(func(c *gin.Context) { started := time.Now() var requestBody string if c.Request.Body != nil && (c.Request.Method == http.MethodPost || c.Request.Method == http.MethodPut || c.Request.Method == http.MethodPatch) { raw, _ := io.ReadAll(io.LimitReader(c.Request.Body, 4096)) requestBody = string(raw) c.Request.Body = io.NopCloser(bytes.NewReader(raw)) } writer := &debugResponseWriter{ResponseWriter: c.Writer} c.Writer = writer app.debug("http:request:start", gin.H{ "method": c.Request.Method, "path": c.Request.URL.Path, "query": c.Request.URL.RawQuery, "body": truncateText(strings.TrimSpace(requestBody), 4000), }) c.Next() app.debug("http:request:done", gin.H{ "method": c.Request.Method, "path": c.Request.URL.Path, "status": c.Writer.Status(), "durationMs": time.Since(started).Milliseconds(), "response": truncateText(strings.TrimSpace(writer.body.String()), 4000), }) }) router.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) router.GET("/ws", app.handleWS) router.GET("/api/history/check", app.checkDuplicate) router.GET("/api/preview/stream", app.streamPreview) router.GET("/api/preview/transcode", app.transcodePreview) router.POST("/api/download/preview", app.previewDownload) router.POST("/api/upload", app.uploadFile) 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) { if payload, err := json.Marshal(data); err == nil { fmt.Printf("[debug] %s %s\n", message, string(payload)) } a.Hub.Broadcast("debug", gin.H{"message": message, "data": data}) } func (a *App) handleWS(c *gin.Context) { upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } a.Hub.Add(conn) defer a.Hub.Remove(conn) for { if _, _, err := conn.ReadMessage(); err != nil { return } } } func (a *App) checkDuplicate(c *gin.Context) { url := strings.TrimSpace(c.Query("url")) if url == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) return } record, err := models.FindByURL(a.DB, url) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"exists": record != nil, "record": record}) } func (a *App) streamPreview(c *gin.Context) { target := strings.TrimSpace(c.Query("url")) if target == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) return } req, err := http.NewRequest(http.MethodGet, target, nil) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36") req.Header.Set("Accept-Language", "en-US,en;q=0.9") req.Header.Set("Referer", inferPreviewReferer(target)) a.debug("preview:proxy:start", gin.H{"target": target, "referer": req.Header.Get("Referer")}) resp, err := a.SearchService.Client.Do(req) if err != nil { a.debug("preview:proxy:error", gin.H{"target": target, "error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } defer resp.Body.Close() if resp.StatusCode >= 300 { a.debug("preview:proxy:bad_status", gin.H{"target": target, "status": resp.StatusCode}) c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("preview source returned %d", resp.StatusCode)}) return } contentType := resp.Header.Get("Content-Type") a.debug("preview:proxy:response", gin.H{"target": target, "contentType": contentType, "status": resp.StatusCode}) if strings.Contains(strings.ToLower(target), ".m3u8") || strings.Contains(strings.ToLower(contentType), "mpegurl") { body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } rewritten := rewriteM3U8Playlist(string(body), target) a.debug("preview:proxy:hls_rewritten", gin.H{"target": target, "bytes": len(body)}) c.Header("Content-Type", "application/vnd.apple.mpegurl") c.String(http.StatusOK, rewritten) return } if contentType != "" { c.Header("Content-Type", contentType) } c.Status(http.StatusOK) _, _ = io.Copy(c.Writer, resp.Body) } func (a *App) transcodePreview(c *gin.Context) { target := strings.TrimSpace(c.Query("url")) if target == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) return } headers := fmt.Sprintf("User-Agent: Mozilla/5.0\r\nReferer: %s\r\n", inferPreviewReferer(target)) cmd := exec.CommandContext( c.Request.Context(), "ffmpeg", "-hide_banner", "-loglevel", "error", "-headers", headers, "-i", target, "-an", "-vf", "scale='min(1280,iw)':-2", "-c:v", "libx264", "-preset", "veryfast", "-crf", "30", "-movflags", "frag_keyframe+empty_moov+faststart", "-f", "mp4", "pipe:1", ) stdout, err := cmd.StdoutPipe() if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } stderr, err := cmd.StderrPipe() if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } if err := cmd.Start(); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } c.Header("Content-Type", "video/mp4") c.Status(http.StatusOK) _, copyErr := io.Copy(c.Writer, stdout) errOutput, _ := io.ReadAll(io.LimitReader(stderr, 2048)) waitErr := cmd.Wait() if copyErr != nil { a.debug("preview:transcode:copy_error", gin.H{"target": target, "error": copyErr.Error()}) return } if waitErr != nil { a.debug("preview:transcode:error", gin.H{"target": target, "error": waitErr.Error(), "stderr": strings.TrimSpace(string(errOutput))}) return } a.debug("preview:transcode:complete", gin.H{"target": target}) } func (a *App) uploadFile(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) return } a.Hub.Broadcast("progress", gin.H{"type": "upload", "status": "started", "progress": 5, "filename": file.Filename}) safeName := normalizeFilename(file.Filename) targetPath := filepath.Join(a.DownloadsDir, safeName) if err := c.SaveUploadedFile(file, targetPath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } a.Hub.Broadcast("progress", gin.H{"type": "upload", "status": "completed", "progress": 100, "filename": safeName}) c.JSON(http.StatusOK, gin.H{"message": "uploaded", "path": targetPath, "filename": safeName}) } func (a *App) startDownload(c *gin.Context) { var req struct { URL string `json:"url"` Start string `json:"start"` End string `json:"end"` Quality string `json:"quality"` Force bool `json:"force"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } rec, err := models.FindByURL(a.DB, req.URL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if rec != nil && !req.Force { c.JSON(http.StatusConflict, gin.H{"error": "duplicate url", "record": rec}) return } outputBase := uuid.NewString() outputPath := filepath.Join(a.DownloadsDir, outputBase+".mp4") recordID, err := models.InsertDownload(a.DB, req.URL, detectSource(req.URL), outputPath, "queued") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } quality := strings.TrimSpace(req.Quality) if quality == "" { quality = "best" } go a.runDownload(recordID, req.URL, req.Start, req.End, quality, outputPath) c.JSON(http.StatusAccepted, gin.H{"message": "download started", "recordId": recordID}) } func (a *App) previewDownload(c *gin.Context) { var req struct { URL string `json:"url"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if strings.TrimSpace(req.URL) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) return } a.debug("download preview requested", gin.H{"url": req.URL}) cmd := exec.Command("python3", a.WorkerScript, "--mode", "probe", "--url", req.URL) output, err := cmd.CombinedOutput() if err != nil { a.debug("download preview failed", gin.H{"url": req.URL, "output": string(output), "error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": summarizeOutput("download preview probe failed", output, err)}) return } var preview PreviewResponse if err := json.Unmarshal(output, &preview); err != nil { a.debug("download preview invalid json", gin.H{"url": req.URL, "output": string(output)}) c.JSON(http.StatusBadGateway, gin.H{"error": summarizeOutput("download preview returned invalid JSON", output, err)}) return } a.debug("download preview succeeded", preview) c.JSON(http.StatusOK, preview) } func (a *App) translateSummary(c *gin.Context) { var req struct { Text string `json:"text"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Text = strings.TrimSpace(req.Text) if req.Text == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"}) return } translated, err := a.GeminiService.TranslateSummaryToKorean(req.Text) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"translatedText": translated}) } func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath string) { a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "queued", "progress": 0, "url": url}) a.debug("download command started", gin.H{"url": url, "start": start, "end": end, "quality": quality, "outputPath": outputPath}) cmd := exec.Command("python3", a.WorkerScript, "--url", url, "--start", start, "--end", end, "--quality", quality, "--output", outputPath) stdout, err := cmd.StdoutPipe() if err != nil { a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 0, "message": err.Error()}) _ = models.MarkDownloadCompleted(a.DB, recordID, "failed") return } cmd.Stderr = cmd.Stdout if err := cmd.Start(); err != nil { a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 0, "message": err.Error()}) _ = models.MarkDownloadCompleted(a.DB, recordID, "failed") return } scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Bytes() var msg map[string]any if err := json.Unmarshal(line, &msg); err == nil { msg["type"] = "download" a.debug("download worker event", msg) a.Hub.Broadcast("progress", msg) } } if err := scanner.Err(); err != nil { a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 100, "message": err.Error()}) } status := "completed" if err := cmd.Wait(); err != nil { status = "failed" a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 100, "message": err.Error()}) a.debug("download command failed", gin.H{"url": url, "error": err.Error()}) } a.debug("download command completed", gin.H{"url": url, "status": status, "outputPath": outputPath}) _ = models.MarkDownloadCompleted(a.DB, recordID, status) } func (a *App) searchMedia(c *gin.Context) { started := time.Now() deadline := started.Add(45 * time.Second) var req struct { Query string `json:"query"` Platforms []string `json:"platforms"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if strings.TrimSpace(req.Query) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "query is required"}) return } a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "expanding query with Gemini", "progress": 10}) queryVariants, _ := a.GeminiService.ExpandQuery(req.Query) if len(queryVariants) == 0 { queryVariants = []string{req.Query} } a.debug("search query variants", gin.H{ "query": req.Query, "platforms": req.Platforms, "variants": queryVariants, "variantCount": len(queryVariants), "requestIdHint": time.Now().UnixNano(), }) enabledPlatforms := normalizePlatforms(req.Platforms) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "searching " + selectedPlatformLabel(enabledPlatforms), "progress": 35}) results, searchMeta, err := a.SearchService.SearchMediaWithDeadline(queryVariants, enabledPlatforms, deadline.Add(-20*time.Second)) if err != nil { a.debug("search backend failed", gin.H{"error": err.Error(), "variants": queryVariants, "durationMs": time.Since(started).Milliseconds()}) 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()}) return } a.debug("search backend summary", summarizeSearchResults(results, time.Since(started), 0, "")) if len(results) == 0 { warning := "SearXNG returned no renderable results." a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "no renderable search results", "progress": 100, "message": warning}) c.JSON(http.StatusOK, gin.H{"results": []services.AIRecommendation{}, "warning": warning}) return } a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "ranking thumbnail candidates", "progress": 55}) rankQuery := req.Query if len(queryVariants) > 0 { rankQuery = strings.Join(queryVariants[:min(len(queryVariants), 3)], " ") } scored := services.RankSearchResults(rankQuery, results) a.debug("search ranked summary", summarizeSearchResults(scored, time.Since(started), services.GeminiCandidateLimit(len(scored)), "")) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75}) recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline(a.GeminiService, req.Query, scored, deadline.Add(-5*time.Second)) a.debug("search gemini evaluation", geminiStats) supplementalDeadlineLimited := false if services.NeedsSupplementalExploration(recommended) && time.Now().Before(deadline.Add(-10*time.Second)) { a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini 평가가 약해 추가 후보를 탐색하는 중", "progress": 82}) explorationQueries := buildSupplementalQueries(a.GeminiService, req.Query, queryVariants, recommended) extraResults, extraMeta, extraErr := a.SearchService.SearchMediaWithDeadline(explorationQueries, enabledPlatforms, deadline.Add(-10*time.Second)) supplementalDeadlineLimited = extraMeta.PartialDueToDeadline if extraErr == nil && len(extraResults) > 0 { results = mergeSearchResults(results, extraResults) scored = services.RankSearchResults(strings.Join(explorationQueries[:min(len(explorationQueries), 3)], " "), results) a.debug("search supplemental query variants", gin.H{"variants": explorationQueries, "variantCount": len(explorationQueries)}) reviewedLinks := services.ReviewedRecommendationLinks(recommended) supplementalCandidates := services.SelectUnevaluatedCandidates(scored, reviewedLinks, services.RemainingGeminiCapacity(recommended)) if len(supplementalCandidates) > 0 { extraRecommended, extraStats, extraGeminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline( a.GeminiService, req.Query, supplementalCandidates, deadline.Add(-3*time.Second), ) recommended = services.MergeUniqueRecommendations(recommended, extraRecommended) geminiStats = services.MergeGeminiBatchStats(geminiStats, extraStats) geminiStats.RecommendedCount = len(services.ReviewedRecommendationLinks(recommended)) geminiErr = combineSearchWarnings(geminiErr, extraGeminiErr) a.debug("search gemini evaluation after supplemental search", gin.H{ "stats": geminiStats, "supplementalCount": len(supplementalCandidates), }) } } } else if services.NeedsSupplementalExploration(recommended) { supplementalDeadlineLimited = true } if geminiErr != nil && len(recommended) == 0 { warning := geminiErr.Error() if strings.Contains(warning, "no candidate thumbnails or preview frames could be fetched for gemini vision") { warning = "AI visual review was unavailable for this search, so ranked results are being shown instead." } fallback := services.BuildFallbackRecommendations(scored, 20, "") a.debug("search fallback summary", summarizeRecommendationResults(fallback, time.Since(started), warning)) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini Vision fallback to ranked results", "progress": 90, "message": warning}) c.JSON(http.StatusOK, gin.H{"results": fallback, "warning": warning, "queries": queryVariants}) return } targetCount := 16 merged := services.MergeRecommendations(recommended, scored, targetCount) if geminiErr != nil { merged = services.BackfillRecommendations( merged, scored, targetCount, "Gemini 배치 일부가 실패해 미리보기 가능한 상위 후보를 보강했습니다.", ) } if len(merged) < targetCount && time.Now().Before(deadline.Add(-5*time.Second)) { coverageQueries := buildCoverageQueries(req.Query, queryVariants, recommended, merged) if len(coverageQueries) > 0 { a.debug("search coverage query variants", gin.H{"variants": coverageQueries, "variantCount": len(coverageQueries), "existingCount": len(merged)}) extraResults, extraMeta, extraErr := a.SearchService.SearchMediaWithDeadline(coverageQueries, enabledPlatforms, deadline.Add(-5*time.Second)) supplementalDeadlineLimited = supplementalDeadlineLimited || extraMeta.PartialDueToDeadline if extraErr == nil && len(extraResults) > 0 { results = mergeSearchResults(results, extraResults) scored = services.RankSearchResults(strings.Join(coverageQueries[:min(len(coverageQueries), 3)], " "), results) reviewedLinks := services.ReviewedRecommendationLinks(recommended) supplementalCandidates := services.SelectUnevaluatedCandidates(scored, reviewedLinks, services.RemainingGeminiCapacity(recommended)) if len(supplementalCandidates) > 0 { extraRecommended, extraStats, extraGeminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline( a.GeminiService, req.Query, supplementalCandidates, deadline.Add(-2*time.Second), ) recommended = services.MergeUniqueRecommendations(recommended, extraRecommended) geminiStats = services.MergeGeminiBatchStats(geminiStats, extraStats) geminiErr = combineSearchWarnings(geminiErr, extraGeminiErr) } merged = services.MergeRecommendations(recommended, scored, targetCount) } } } if len(merged) < targetCount { merged = services.BackfillRecommendations(merged, scored, targetCount, "추가 검색 후에도 충분한 결과가 부족해 시각 자산이 있는 후보를 제한적으로 보강했습니다.") } merged = services.RandomizeTopRecommendations(merged, 6) for idx := range merged { merged[idx] = services.DecorateRecommendationMedia(merged[idx]) } warning := "" if geminiErr != nil { warning = geminiErr.Error() } a.debug("search complete summary", summarizeRecommendationResults(merged, time.Since(started), warning)) response := gin.H{"results": merged, "queries": queryVariants} if warning != "" { response["warning"] = warning } if shouldWarnPartialSearch(searchMeta, geminiStats, supplementalDeadlineLimited, warning) { response["warning"] = "search returned partial results to avoid gateway timeout" } a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search complete", "progress": 100}) c.JSON(http.StatusOK, response) } func shouldWarnPartialSearch(meta services.SearchExecutionMeta, stats services.GeminiBatchStats, supplementalDeadlineLimited bool, warning string) bool { return warning == "" && (meta.PartialDueToDeadline || stats.DeadlineLimited || supplementalDeadlineLimited) } func normalizeFilename(name string) string { base := strings.ToLower(strings.TrimSpace(name)) ext := filepath.Ext(base) base = strings.TrimSuffix(base, ext) re := regexp.MustCompile(`[^a-z0-9]+`) base = strings.Trim(re.ReplaceAllString(base, "-"), "-") if base == "" { base = fmt.Sprintf("upload-%d", time.Now().Unix()) } if ext == "" { ext = ".bin" } return base + ext } func detectSource(url string) string { switch { case strings.Contains(url, "youtube"): return "YouTube" case strings.Contains(url, "tiktok"): return "TikTok" default: return "direct" } } func min(a, b int) int { if a < b { return a } 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 selectedPlatformLabel(platforms map[string]bool) string { labels := make([]string, 0, len(platforms)) if platforms["envato"] { labels = append(labels, "Envato") } if platforms["artgrid"] { labels = append(labels, "Artgrid") } if platforms["google video"] { labels = append(labels, "Google Video") } if len(labels) == 0 { return "selected platforms" } return strings.Join(labels, ", ") } func buildSupplementalQueries(service *services.GeminiService, query string, existing []string, reviewed []services.AIRecommendation) []string { if service != nil { if generated, err := service.BuildSupplementalQueries(query, existing, reviewed); err == nil && len(generated) > 0 { return mergeSupplementalQuerySets(existing, generated) } } return buildDeterministicSupplementalQueries(query, existing, reviewed) } func buildDeterministicSupplementalQueries(query string, existing []string, reviewed []services.AIRecommendation) []string { candidates := append([]string{}, existing...) for _, item := range reviewed { if item.Assessment == "positive" && item.SearchHint != "" { candidates = append(candidates, item.SearchHint) } if (item.Assessment == "unclear" || services.IsExcludedAssessment(item.Assessment)) && item.SearchHint != "" { candidates = append(candidates, query+" "+item.SearchHint) } } candidates = append(candidates, query+" cinematic stock footage", query+" editorial b-roll", query+" establishing shot", query+" drone footage", query+" authentic candid couple", query+" urban park lifestyle footage", ) return mergeSupplementalQuerySets(nil, candidates) } func mergeSupplementalQuerySets(base, extra []string) []string { candidates := append([]string{}, base...) candidates = append(candidates, extra...) seen := map[string]bool{} result := make([]string, 0, len(candidates)) for _, item := range candidates { trimmed := strings.Join(strings.Fields(strings.TrimSpace(item)), " ") if trimmed == "" { continue } key := strings.ToLower(trimmed) if seen[key] { continue } seen[key] = true result = append(result, trimmed) } return result } func mergeSearchResults(base, extra []services.SearchResult) []services.SearchResult { merged := make([]services.SearchResult, 0, len(base)+len(extra)) seen := map[string]bool{} for _, item := range append(base, extra...) { if item.Link == "" || seen[item.Link] { continue } seen[item.Link] = true merged = append(merged, item) } return merged } func buildCoverageQueries(query string, existing []string, reviewed []services.AIRecommendation, merged []services.AIRecommendation) []string { candidates := append([]string{}, existing...) positiveHints := 0 for _, item := range reviewed { if item.Assessment == "positive" && item.SearchHint != "" && positiveHints < 3 { candidates = append(candidates, item.SearchHint) positiveHints++ } } if len(merged) < 8 { candidates = append(candidates, query+" stock footage", query+" lifestyle footage", query+" candid couple footage", query+" editorial scene", ) } else { candidates = append(candidates, query+" establishing shot", query+" cinematic b-roll", ) } return mergeSupplementalQuerySets(nil, candidates) } func combineSearchWarnings(base, extra error) error { switch { case base == nil: return extra case extra == nil: return base case base.Error() == extra.Error(): return base default: return fmt.Errorf("%s; %s", base.Error(), extra.Error()) } } func summarizeSearchResults(results []services.SearchResult, duration time.Duration, geminiCap int, warning string) searchDebugSummary { bySource := map[string]int{} withPreview := 0 withThumbnail := 0 withUsableThumbnail := 0 withLowValueThumbnail := 0 top := make([]map[string]any, 0, min(6, len(results))) for idx, item := range results { bySource[item.Source]++ if strings.TrimSpace(item.PreviewVideoURL) != "" { withPreview++ } if strings.TrimSpace(item.ThumbnailURL) != "" { withThumbnail++ if services.HasUsableThumbnail(item.ThumbnailURL) { withUsableThumbnail++ } if services.IsLowValueThumbnail(item.ThumbnailURL) { withLowValueThumbnail++ } } if idx < 6 { top = append(top, map[string]any{ "title": truncateText(item.Title, 120), "source": item.Source, "hasPreview": item.PreviewVideoURL != "", "hasThumbnail": item.ThumbnailURL != "", "usableThumb": services.HasUsableThumbnail(item.ThumbnailURL), "displayLink": item.DisplayLink, "snippetSample": truncateText(item.Snippet, 160), }) } } return searchDebugSummary{ Total: len(results), VisibleCount: len(results), BySource: bySource, WithPreview: withPreview, WithThumbnail: withThumbnail, WithUsableThumbnail: withUsableThumbnail, WithLowValueThumbnail: withLowValueThumbnail, Top: top, Warning: warning, DurationMS: duration.Milliseconds(), GeminiCandidateCap: geminiCap, } } func summarizeRecommendationResults(results []services.AIRecommendation, duration time.Duration, warning string) searchDebugSummary { bySource := map[string]int{} withPreview := 0 withThumbnail := 0 withUsableThumbnail := 0 withLowValueThumbnail := 0 withEmbedURL := 0 top := make([]map[string]any, 0, min(6, len(results))) for idx, item := range results { bySource[item.Source]++ if strings.TrimSpace(item.PreviewVideoURL) != "" { withPreview++ } if strings.TrimSpace(item.ThumbnailURL) != "" { withThumbnail++ if services.HasUsableThumbnail(item.ThumbnailURL) { withUsableThumbnail++ } if services.IsLowValueThumbnail(item.ThumbnailURL) { withLowValueThumbnail++ } } if strings.TrimSpace(item.EmbedURL) != "" { withEmbedURL++ } if idx < 6 { top = append(top, map[string]any{ "title": truncateText(item.Title, 120), "source": item.Source, "hasPreview": item.PreviewVideoURL != "", "hasThumbnail": item.ThumbnailURL != "", "hasEmbed": item.EmbedURL != "", "mediaMode": item.MediaMode, "reasonSample": truncateText(item.Reason, 120), "snippetSample": truncateText(item.Snippet, 160), }) } } return searchDebugSummary{ Total: len(results), VisibleCount: len(results), BySource: bySource, WithPreview: withPreview, WithThumbnail: withThumbnail, WithUsableThumbnail: withUsableThumbnail, WithLowValueThumbnail: withLowValueThumbnail, WithEmbedURL: withEmbedURL, Top: top, Warning: warning, DurationMS: duration.Milliseconds(), } } func truncateText(text string, limit int) string { trimmed := strings.TrimSpace(text) if len(trimmed) <= limit { return trimmed } return trimmed[:limit] + "..." } func EnsurePaths(downloadsDir, workerScript string) error { if err := os.MkdirAll(downloadsDir, 0o755); err != nil { return err } if _, err := os.Stat(workerScript); err != nil { if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("worker script not found: %s", workerScript) } return err } return nil } func inferPreviewReferer(target string) string { lower := strings.ToLower(target) switch { case strings.Contains(lower, "envatousercontent.com"), strings.Contains(lower, "elements.envato.com"): return "https://elements.envato.com/" case strings.Contains(lower, "artgrid"), strings.Contains(lower, "artlist"): return "https://artgrid.io/" default: return "" } } func rewriteM3U8Playlist(body, target string) string { lines := strings.Split(body, "\n") baseURL := target if idx := strings.LastIndex(baseURL, "/"); idx >= 0 { baseURL = baseURL[:idx+1] } for idx, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } resolved := trimmed if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") { resolved = baseURL + strings.TrimPrefix(trimmed, "/") } lines[idx] = "/api/preview/stream?url=" + url.QueryEscape(resolved) } return strings.Join(lines, "\n") } func summarizeOutput(prefix string, output []byte, err error) string { trimmed := strings.TrimSpace(string(output)) if trimmed == "" && err != nil { return prefix + ": " + err.Error() } if trimmed == "" { return prefix } if len(trimmed) > 1200 { trimmed = trimmed[:1200] + "..." } return prefix + ": " + trimmed }