From 8101f17f5bddbec1dbefa3ae740afc544ada9669 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 16 Mar 2026 11:12:43 +0900 Subject: [PATCH] Refactor search fallback and preview flows --- backend/handlers/api.go | 33 +++++++----------- backend/services/gemini.go | 18 ++++------ backend/services/ranker.go | 50 +++++++++++++++++++++++++-- frontend/app.js | 69 +++++++++++++++++++++++--------------- worker/downloader.py | 68 +++++++++++++++++++++++-------------- 5 files changed, 150 insertions(+), 88 deletions(-) diff --git a/backend/handlers/api.go b/backend/handlers/api.go index a6f070e..d9ea5ad 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -321,27 +321,11 @@ func (a *App) searchMedia(c *gin.Context) { 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 := services.EvaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored) + recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored) a.debug("search gemini evaluation", geminiStats) - 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))] { - fallback = append(fallback, services.AIRecommendation{ - Title: result.Title, - Link: result.Link, - Snippet: result.Snippet, - ThumbnailURL: result.ThumbnailURL, - PreviewVideoURL: result.PreviewVideoURL, - Source: result.Source, - Reason: "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다.", - Recommended: false, - }) - } - warning := err.Error() + if geminiErr != nil && len(recommended) == 0 { + warning := geminiErr.Error() + 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}) @@ -349,8 +333,15 @@ func (a *App) searchMedia(c *gin.Context) { } merged := services.MergeRecommendations(recommended, scored, 20) - a.debug("search complete summary", summarizeRecommendationResults(merged, time.Since(started), "")) + 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 + } a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search complete", "progress": 100}) c.JSON(http.StatusOK, response) } diff --git a/backend/services/gemini.go b/backend/services/gemini.go index 32e4192..9235648 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -164,16 +164,21 @@ User query: ` + query, } maxImages := min(len(candidates), 10) + visualCount := 0 for idx := 0; idx < maxImages; idx++ { img, mimeType, err := fetchCandidateVisualInlineData(g.Client, candidates[idx]) if err != nil { continue } + visualCount++ parts = append(parts, geminiPart{"text": fmt.Sprintf("Candidate %d: title=%s source=%s link=%s", idx, candidates[idx].Title, candidates[idx].Source, candidates[idx].Link)}, geminiPart{"inlineData": map[string]string{"mimeType": mimeType, "data": img}}, ) } + if visualCount == 0 { + return nil, fmt.Errorf("no candidate thumbnails or preview frames could be fetched for gemini vision") + } body := map[string]any{ "contents": []map[string]any{ @@ -248,18 +253,7 @@ User query: ` + query, } if len(recommendations) == 0 { - for _, candidate := range candidates[:min(8, len(candidates))] { - recommendations = append(recommendations, AIRecommendation{ - Title: candidate.Title, - Link: candidate.Link, - Snippet: candidate.Snippet, - ThumbnailURL: candidate.ThumbnailURL, - PreviewVideoURL: candidate.PreviewVideoURL, - Source: candidate.Source, - Reason: "Gemini Vision 평가를 받지 못해 키워드 기준으로 보강된 결과입니다.", - Recommended: false, - }) - } + recommendations = BuildFallbackRecommendations(candidates, 8, "Gemini Vision 평가를 받지 못해 키워드 기준으로 보강된 결과입니다.") } return recommendations, nil diff --git a/backend/services/ranker.go b/backend/services/ranker.go index eed5f0f..d288882 100644 --- a/backend/services/ranker.go +++ b/backend/services/ranker.go @@ -1,11 +1,14 @@ package services import ( + "fmt" "sort" "strings" "sync" ) +const GeminiFallbackReason = "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다." + type GeminiBatchStats struct { CandidateCap int `json:"candidateCap"` Requested int `json:"requested"` @@ -84,9 +87,13 @@ func GeminiCandidateLimit(total int) int { return total } -func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranked []SearchResult) ([]AIRecommendation, GeminiBatchStats) { +func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranked []SearchResult) ([]AIRecommendation, GeminiBatchStats, error) { const chunkSize = 8 const maxConcurrentBatches = 2 + if service == nil { + return nil, GeminiBatchStats{}, fmt.Errorf("gemini service is not configured") + } + limit := GeminiCandidateLimit(len(ranked)) stats := GeminiBatchStats{ CandidateCap: limit, @@ -106,6 +113,9 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke batches = append(batches, ranked[start:end]) } stats.Batches = len(batches) + if len(batches) == 0 { + return []AIRecommendation{}, stats, nil + } results := make([]batchResult, len(batches)) var wg sync.WaitGroup @@ -146,7 +156,41 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke } } stats.RecommendedCount = len(merged) - return merged, stats + + switch { + case len(merged) > 0 && stats.Failed == 0: + return merged, stats, nil + case len(merged) > 0 && stats.Failed > 0: + return merged, stats, fmt.Errorf("gemini vision partially failed on %d of %d batches", stats.Failed, stats.Batches) + case stats.Failed == stats.Batches: + if len(stats.Errors) > 0 { + return nil, stats, fmt.Errorf("gemini vision failed for all batches: %s", strings.Join(stats.Errors, "; ")) + } + return nil, stats, fmt.Errorf("gemini vision failed for all batches") + default: + return nil, stats, fmt.Errorf("gemini vision returned no candidate evaluations") + } +} + +func BuildFallbackRecommendations(ranked []SearchResult, limit int, reason string) []AIRecommendation { + if strings.TrimSpace(reason) == "" { + reason = GeminiFallbackReason + } + + fallback := make([]AIRecommendation, 0, min(limit, len(ranked))) + for _, item := range ranked[:min(limit, len(ranked))] { + fallback = append(fallback, AIRecommendation{ + Title: item.Title, + Link: item.Link, + Snippet: item.Snippet, + ThumbnailURL: item.ThumbnailURL, + PreviewVideoURL: item.PreviewVideoURL, + Source: item.Source, + Reason: reason, + Recommended: false, + }) + } + return fallback } func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, limit int) []AIRecommendation { @@ -184,7 +228,7 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, ThumbnailURL: item.ThumbnailURL, PreviewVideoURL: item.PreviewVideoURL, Source: item.Source, - Reason: "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다.", + Reason: GeminiFallbackReason, Recommended: false, }) } diff --git a/frontend/app.js b/frontend/app.js index 075fc03..4ada852 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -56,6 +56,7 @@ let activeResultItem = null; const activePlatforms = new Set(["envato", "artgrid", "google video"]); const hlsInstances = new WeakMap(); const debugEntries = []; +const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview"; function setStatus(label, progress) { statusLabel.textContent = label; @@ -63,6 +64,18 @@ function setStatus(label, progress) { logEvent("status", { label, progress }); } +function setHidden(element, hidden, visibleDisplayClass = "flex") { + element.classList.toggle("hidden", hidden); + if (visibleDisplayClass) { + element.classList.toggle(visibleDisplayClass, !hidden); + } +} + +function showWarning(message) { + searchWarning.textContent = message; + setHidden(searchWarning, !message, ""); +} + function logEvent(type, payload) { const entry = { ts: new Date().toISOString(), @@ -318,6 +331,20 @@ function detachVideoSource(video) { video.load(); } +function resetPreviewPlayer() { + previewVideo.pause(); + detachVideoSource(previewVideo); + previewMediaFrame.style.aspectRatio = ""; +} + +function showModal(element) { + setHidden(element, false); +} + +function hideModal(element) { + setHidden(element, true); +} + function renderResults(results) { searchResults.innerHTML = ""; if (!results.length) { @@ -329,7 +356,7 @@ function renderResults(results) { const image = node.querySelector("img"); const previewVideo = node.querySelector(".preview-hover"); const overlays = node.querySelectorAll(".preview-overlay"); - image.src = item.thumbnailUrl || "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview"; + image.src = item.thumbnailUrl || PREVIEW_PLACEHOLDER; image.alt = item.title; node.querySelector("h3").textContent = item.title; node.querySelector(".result-snippet").textContent = item.snippet || item.reason || item.source || ""; @@ -389,8 +416,7 @@ function openResultModal(item) { resultModalOpenExternal.href = item.link || "#"; const canDirectDownload = item.source === "Google Video" && item.link; resultModalDownload.classList.toggle("hidden", !canDirectDownload); - resultModal.classList.remove("hidden"); - resultModal.classList.add("flex"); + showModal(resultModal); logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link }); } @@ -400,14 +426,13 @@ function closeResultViewer() { } activeResultItem = null; resultModalFrame.src = "about:blank"; - resultModal.classList.add("hidden"); - resultModal.classList.remove("flex"); + hideModal(resultModal); } searchForm.addEventListener("submit", async (event) => { event.preventDefault(); setStatus("preparing search", 5); - searchWarning.classList.add("hidden"); + showWarning(""); try { const data = await api("/api/search", { method: "POST", @@ -416,15 +441,11 @@ searchForm.addEventListener("submit", async (event) => { }); renderResults(data.results || []); renderQueryVariants(data.queries || []); - if (data.warning) { - searchWarning.textContent = data.warning; - searchWarning.classList.remove("hidden"); - } + showWarning(data.warning || ""); logEvent("search:completed", { results: data.results?.length || 0, queries: data.queries || [] }); setStatus("search complete", 100); } catch (error) { - searchWarning.textContent = error.message; - searchWarning.classList.remove("hidden"); + showWarning(error.message); renderQueryVariants([]); setStatus("search failed", 100); } @@ -445,18 +466,16 @@ async function uploadFile(file) { function openPreviewModal(preview) { logEvent("preview:modal:open", preview); previewTitle.textContent = preview.title; - previewThumbnail.src = preview.thumbnail; + previewThumbnail.src = preview.thumbnail || PREVIEW_PLACEHOLDER; previewThumbnail.alt = preview.title; - previewVideo.pause(); - detachVideoSource(previewVideo); - previewMediaFrame.style.aspectRatio = ""; + resetPreviewPlayer(); if (preview.previewStreamUrl) { attachVideoSource(previewVideo, preview.previewStreamUrl); - previewVideo.classList.remove("hidden"); - previewThumbnail.classList.add("hidden"); + setHidden(previewVideo, false, ""); + setHidden(previewThumbnail, true, ""); } else { - previewVideo.classList.add("hidden"); - previewThumbnail.classList.remove("hidden"); + setHidden(previewVideo, true, ""); + setHidden(previewThumbnail, false, ""); } previewDuration.textContent = preview.duration; qualitySelect.innerHTML = ""; @@ -470,17 +489,13 @@ function openPreviewModal(preview) { cropStart = 0; cropEnd = cropMax; syncRanges(); - previewModal.classList.remove("hidden"); - previewModal.classList.add("flex"); + showModal(previewModal); } function closeModal() { logEvent("preview:modal:close", { title: previewTitle.textContent }); - previewVideo.pause(); - detachVideoSource(previewVideo); - previewMediaFrame.style.aspectRatio = ""; - previewModal.classList.add("hidden"); - previewModal.classList.remove("flex"); + resetPreviewPlayer(); + hideModal(previewModal); cropStart = 0; cropEnd = 0; cropMax = 0; diff --git a/worker/downloader.py b/worker/downloader.py index 9fee446..bd80e87 100644 --- a/worker/downloader.py +++ b/worker/downloader.py @@ -36,12 +36,6 @@ def parse_duration(value): return f"{hours:02d}:{minutes:02d}:{seconds:02d}" -def format_label(height, ext): - if height: - return f"{height}p ({ext})" - return f"Best ({ext})" - - def build_quality_options(formats: List[dict]): heights = [] for item in formats: @@ -99,6 +93,45 @@ def probe(url): print(json.dumps(preview), flush=True) +def parse_timestamp(value: str) -> int: + text = (value or "").strip() + if not text: + return 0 + parts = text.split(":") + try: + if len(parts) == 3: + hours, minutes, seconds = parts + return int(hours) * 3600 + int(minutes) * 60 + int(float(seconds)) + if len(parts) == 2: + minutes, seconds = parts + return int(minutes) * 60 + int(float(seconds)) + return int(float(text)) + except ValueError: + return 0 + + +def resolve_source_file(tmpdir: str) -> str: + files = [os.path.join(tmpdir, name) for name in os.listdir(tmpdir)] + media_files = [path for path in files if os.path.isfile(path)] + if not media_files: + raise RuntimeError("yt-dlp did not produce an output file") + return sorted(media_files)[0] + + +def should_trim(start: str, end: str) -> bool: + start_seconds = parse_timestamp(start) + end_seconds = parse_timestamp(end) + return end_seconds > start_seconds + + +def build_ffmpeg_cmd(source_file: str, output_path: str, start: str, end: str) -> List[str]: + cmd = ["ffmpeg", "-y"] + if should_trim(start, end): + cmd.extend(["-ss", start, "-to", end]) + cmd.extend(["-i", source_file, "-c", "copy", output_path]) + return cmd + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--mode", choices=["probe", "download"], default="download") @@ -132,25 +165,10 @@ def main(): run(download_cmd) emit("downloaded", 55, "Source downloaded") - files = [os.path.join(tmpdir, name) for name in os.listdir(tmpdir)] - if not files: - raise RuntimeError("yt-dlp did not produce an output file") - source_file = sorted(files)[0] - - ffmpeg_cmd = [ - "ffmpeg", - "-y", - "-ss", - args.start, - "-to", - args.end, - "-i", - source_file, - "-c", - "copy", - args.output, - ] - emit("cropping", 75, "Cropping requested segment") + source_file = resolve_source_file(tmpdir) + ffmpeg_cmd = build_ffmpeg_cmd(source_file, args.output, args.start, args.end) + message = "Cropping requested segment" if should_trim(args.start, args.end) else "Saving downloaded media" + emit("cropping", 75, message) run(ffmpeg_cmd) emit("completed", 100, "Download complete", args.output)